# 自動化測試 20250621
https://github.com/lambdaTW/t0712
- 測試前
- 了解環境
- 了解程式是如何執行
- 如何輸入
- 輸出是什麼
- 有沒有辦法自動化
- web
- playwright
- app
- appnium
- 是否有需要非功能測試
- k6
- ...
- 如何建立(假)環境
- 如何建立(假)資料
- 測試流程是什麼
- 工程師
- 導入自動化在開發
- 在既有程式碼上面導入測試
- 提高測試覆蓋率
- 當現實遇到 bug
- 除了修好 bug 可以加上測試
- 非工程師
- 理解自動化與測試
- 可以對別人(自家公司)的網頁進行測試
- 可以理解測試邏輯與概念
- 測試人員
- 理解網頁自動化
- 提高日常工作效率
- 分辨哪些功能比較不會被修改,比較有投資自動化的價值
## Appendix
https://hackmd.io/39hHKGoKRNSwCNIxGJmIfw
https://roadmap.sh/qa
https://github.com/lambdaTW/testit
## 環境安裝與設定 - 1
- 開啟下載頁
- 內建 App
- 使用開始,搜尋 microsoft store
- 打開 microsoft store
- 網頁下載
- 搜尋 microsoft store
- 進入 apps.microsoft.com
### Visual Studio Code
- 搜尋 Visual Studio Code
- 點開 Visual Studio Code
- 安裝 Visual Studio Code
### Python
- 搜尋 Python
- 點開 Python 3.12
- 安裝 Python 3.12
### 確定 Python 環境
- 開啟命令提示字元
- 使用開始搜尋 `命令提示字元` 並開啟
- (鍵盤) Win + r
- 搜尋 `cmd`
- 開啟
- 輸入 `Python3`
- 離開方法
- `exit()`
### Python 套件
套件,簡單說就是別人寫好的程式,讓你可以直接使用,在 `Python` 我們使用 `pip` 來安裝套件
- 確定 `pip` 在環境中
- (命令提示字元) pip list
#### 隔離套件環境
在安裝以前我們會先創立隔離環境,以確保各個專案安裝的套件部會衝突,在 `Python` 我們使用 `venv` 可以創立獨立的空間讓你安裝的套件不會和系統中的套件打架
- (命令提示字元) python3 -m venv venv
創立好以後,我們要進入該獨立的環境
- (命令提示字元) venv\Scripts\activate.bat
- 你會看見提示列多出了 `(venv)`
#### 安裝套件
如上所述,我們使用 `pip` 來安裝套件,記得每次都要確定一下自己有沒有在自己專案的隔離環境內安裝喔!
- (命令提示字元)(venv) pip install pytest-playwright
#### 安裝 playwright 瀏覽器環境
- (命令提示字元)(venv) playwright install
- f"{user_profile}\\AppData\\Local\\ms-playwright"
## 我的第一個自動化測試
```python!
# test_me.py
import re
from playwright.sync_api import Page, expect
def test_has_title(page: Page):
page.goto("https://playwright.dev/")
# Expect a title "to contain" a substring.
expect(page).to_have_title(re.compile("Playwright"))
def test_get_started_link(page: Page):
page.goto("https://playwright.dev/")
# Click the get started link.
page.get_by_role("link", name="Get started").click()
# Expects page to have a heading with the name of Installation.
expect(page.get_by_role("heading", name="Installation")).to_be_visible()
```
- (命令提示字元)(venv) `pytest --headed --slowmo=1000 test_me.py`
- 換瀏覽器
```
pytest --browser webkit --headed --slowmo=1000 test_me.py
```
## 自動化工具概念
### 何謂自動化
自動化之前,想想手動,在我們操作軟體時,我們會用哪些方式與該軟體互動,舉例來說
- 網頁
- 鍵盤
- 打字
- 組合鍵
- 快捷鍵
- 滑鼠
- 點擊
- 拖拉
- 手機
- 點擊
- 輸入
- 滑動
- 多點滑動
自動化,就是改用軟體的方式模擬手動的過程,那我們該如何才能自動化呢?
軟體工程師為了方便測試自己的程式碼,後來延伸出一些工具,來直接操作該軟體 (Web -> 瀏覽器),我們只需要去尋找相對應的工具就可以自動化我們的操作
### 網站自動化:模擬購物體驗
我們接下來都會使用 https://qa-practice.netlify.app 這個網頁,來做測試,這是一個讓 QA (quality assurance 品質保證) 練習自動化的網頁,其中有各種互動元件以及各種網頁概念的測試頁面,接下來我們移動到 https://qa-practice.netlify.app/products_list 使用以下範例程式,體驗一下自動化的感覺
```python!
# test_shopping.py
import re
from playwright.sync_api import Page, expect
def test_shop_pricing(page: Page):
page.goto("https://qa-practice.netlify.app/products_list")
locator = page.get_by_role("button", name="ADD TO CART").first
locator.scroll_into_view_if_needed()
locator.click(delay=1000)
locator = page.get_by_role("button", name="ADD TO CART").nth(1)
locator.scroll_into_view_if_needed()
locator.click(delay=1000)
locator = page.get_by_role("button", name="PURCHASE")
locator.scroll_into_view_if_needed()
locator.click(delay=1000)
expect(page.locator('//*[@id="message"]/b')).to_have_text(re.compile(r"\$1142.11"))
```
- (命令提示字元)(venv) `pytest --headed test_shopping.py`
- 拿掉 `delay=1000`
## 撰寫單元測試
### 何謂單元測試
一支鉛筆包含很多零件或功能:
- 筆芯要能寫字
- 外殼不能裂開
- 橡皮擦要能擦掉字
- 印的 logo 不能掉色
| 🔍你不會等鉛筆出廠才整支一起測,而是每個功能一個一個測。
### 來寫程式吧
```python!
# guess_game.py
def guess_number(user_guess, target_number):
if user_guess < target_number:
return "低了"
elif user_guess > target_number:
return "高了"
else:
return "猜對了"
```
- 測試案例
- 高於
- 低於
- 正確
```python!
# test_guess_game.py
import pytest
from guess_game import guess_number # 假設函數儲存在 guess_game.py 檔案中
def test_correct_guess():
assert guess_number(5, 5) == "猜對了"
def test_guess_too_low():
assert guess_number(3, 5) == "低了"
def test_guess_too_high():
assert guess_number(7, 5) == "高了"
```
- (命令提示字元)(venv) `pytest test_guess_game.py`
- pytest -vvv test_guess_game.py
- 可以顯示出更詳細的測試資訊
- -vvv 是給 pytest 的參數,不涉及 pytest-playwright 的範疇
- 測試案例
- 輸入錯誤
- 不是數字
- 浮點數
- 自動轉換
- 字串
## 網站自動化操作流程
同上一堂課所做的練習,手動流程轉成自動化測試的流程如下
- 確認流程
- 手動操作
- 確定測試的
- 流程
- 要確認什麼
- 正向描述
- 反向描述
- 列出測試案例
- 實作自動化測試
- 一步一步操作
- 寫測試時最後讓他睡覺,在網頁上操作
- 把操作放到程式,反覆執行測試
- 預先調查
- 元件位置
- 操作方式
- 寫出測試
## 認識 Playwright 網頁自動化的核心架構
### Browser (瀏覽器)
- 開啟不同瀏覽器
- firefox
- chromium
```python!
# run_browsers.py
import time
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
firefox = playwright.firefox
browser = firefox.launch(headless=False)
page = browser.new_page()
page.goto("https://example.com")
time.sleep(10)
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
- (命令提示字元)(venv) python run_browsers.py
指定瀏覽器
```python=
# run_browsers.py
import time
import pathlib
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
chromium = playwright.chromium
p = "C:\\Users\\user\\AppData\\Local\\ms-playwright\\chromium-1169\\chrome-win\\chrome.exe"
browser = chromium.launch(headless=False, executable_path=p)
page = browser.new_page()
page.goto("https://example.com")
time.sleep(10)
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
### BrowserContext
當你要一次管理多個操作時,可以用不同的 BrwoserContext 來隔離 (可以想成無痕模式)
```python!
# browser_context.py
import re
import time
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
firefox = playwright.firefox
browser = firefox.launch(headless=False)
context = browser.new_context(viewport={"width": 1920, "height": 1080})
page = context.new_page()
page.goto("https://qa-practice.netlify.app/auth_ecommerce")
page.get_by_placeholder("Enter email - insert admin@").click()
page.get_by_placeholder("Enter email - insert admin@").fill("admin@admin.com")
page.get_by_placeholder("Enter email - insert admin@").press("Tab")
page.get_by_placeholder("Enter Password - insert").fill("admin123")
page.get_by_placeholder("Enter Password - insert").press("Enter")
time.sleep(3)
new_context = browser.new_context()
page = new_context.new_page()
page.goto("https://qa-practice.netlify.app/auth_ecommerce")
time.sleep(10)
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
- 瀏覽器大小
- `viewport={"width": 1920, "height": 1080}`
### Page (分頁)
你可以在同一個瀏覽器開啟不同分頁,並且切換自如
```python!
# pages.py
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
webkit = playwright.webkit
browser = webkit.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("https://example.com")
page.screenshot(path="example-screenshot.png")
new_page = context.new_page()
new_page.goto("https://playwright.dev/python/docs/api/class-page")
new_page.screenshot(path="doc.png")
page.locator("css=a").click()
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
### Locator (元件位置)
在瀏覽器中我們可以用不同方式去 Locate (定位) 不同元件,以下列出幾個常用的方法
- 傳統 Locator
- CSS
- XPath
- Playwright 自有
- get_by_role
- get_by_text
```python!
# locators.py
import time
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
chromium = playwright.chromium
browser = chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("https://qa-practice.netlify.app/checkboxes")
page.locator("#checkbox1").click()
time.sleep(2)
page.locator("//html/body/div/div/form/div/div[2]/input").click()
time.sleep(2)
for locator in page.get_by_role("checkbox").all():
locator.check()
time.sleep(2)
page.get_by_text("Reset").click()
time.sleep(10)
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
改用 for loop 且用 click() 來達成相同功能
```python
# locators.py
import time
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
chromium = playwright.chromium
browser = chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("https://qa-practice.netlify.app/checkboxes")
for locator in page.get_by_role("checkbox").all():
# locator.check()
locator.click(delay=1000)
time.sleep(2)
page.get_by_text("Reset").click()
time.sleep(10)
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
Radio button
```python
# locators.py
import time
from playwright.sync_api import sync_playwright, Playwright
def run(playwright: Playwright):
chromium = playwright.chromium
browser = chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("https://qa-practice.netlify.app/radiobuttons")
for locator in page.get_by_role("radio").all()[:-1]:
# locator.check()
locator.click(delay=1000)
browser.close()
with sync_playwright() as playwright:
run(playwright)
```
- (命令提示字元)(venv) `python locators.py`
# 網站自動化進階元件操作 - 2
## 等待載入元素
```python!
# loading.py
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://qa-practice.netlify.app/loader")
# Wait for the loader to disappear (display: none)
page.locator("#loader").wait_for(state="hidden")
print("Loader disappeared!")
browser.close()
```
## 模擬使用者行為
### 選單
```python
page.goto("https://qa-practice.netlify.app/dropdowns")
page.locator("#dropdown-menu").select_option("Taiwan")
```
### 拖曳
```python!
# drag.py
import time
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://tw.yahoo.com")
time.sleep(2)
page.locator('xpath=/html/body/div[1]/div/nav/div/div[1]/ul/li[1]').hover()
page.mouse.down()
page.mouse.move(405, 38)
page.locator("xpath=/html/body/header/div/div/div[1]/div/div/div[1]/div[1]/div/div/form/input[1]").hover()
page.mouse.up()
time.sleep(10)
browser.close()
```
改用 bounding_box
```python!
import time
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.firefox.launch(headless=False)
page = browser.new_page()
page.goto("https://tw.yahoo.com")
time.sleep(2)
page.locator('xpath=/html/body/div[1]/div/nav/div/div[1]/ul/li[1]').hover()
page.mouse.down()
search_input = page.locator("xpath=/html/body/header/div/div/div[1]/div/div/div[1]/div[1]/div/div/form/input[1]")
box = search_input.bounding_box()
page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
search_input.hover()
page.mouse.up()
time.sleep(10)
browser.close()
```
#### JS
```javascript
var x = null;
var y = null;
document.addEventListener('mousemove', onMouseUpdate, false);
document.addEventListener('mouseenter', onMouseUpdate, false);
function onMouseUpdate(e) {
x = e.pageX;
y = e.pageY;
console.log(x, y);
}
function getMouseX() {
return x;
}
function getMouseY() {
return y;
}
```
### 下載
```python!
# download.py
import time
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://data.gov.tw/dataset/137743")
time.sleep(2)
with page.expect_download() as download_info:
# Perform the action that initiates download
page.get_by_role("button", name="CSV").click()
download = download_info.value
download.save_as("/tmp/" + "down.csv")
# C:\\tmp\down.csv
time.sleep(10)
browser.close()
```
### 上傳
```python!
# upload.py
import time
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://www.compress2go.com/create-zip-file")
time.sleep(2)
with page.expect_file_chooser() as fc_info:
page.get_by_text("Choose File").click()
file_chooser = fc_info.value
file_chooser.set_files("/tmp/down.csv")
time.sleep(10)
browser.close()
```
### 按鍵
```python!
# keyboard.py
import time
from playwright.sync_api import sync_playwright
url = "https://docs.google.com/spreadsheets/d/1_r1bJBZRv8VWcw3WpHfnVWXoNfBEbSYP103S76d9Elo/edit?usp=sharing"
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto(url)
time.sleep(2)
for _ in range(10):
page.keyboard.press("Enter")
page.keyboard.type("Hello World!")
page.keyboard.press("Enter")
time.sleep(2)
for _ in range(10):
page.keyboard.press("ArrowUp")
time.sleep(2)
page.keyboard.down("Shift")
for _ in range(10):
page.keyboard.press("ArrowDown")
page.keyboard.up("Shift")
page.keyboard.press("Delete")
time.sleep(10)
browser.close()
```
## 更多元素操作
### Text
```python!
# Text input
page.get_by_role("textbox").fill("Peter")
```
### Checkboxes and radio buttons
```python!
page.get_by_label('XL').check()
expect(page.get_by_label('XL')).to_be_checked()
```
### Scrolling
```python!
# scrolling.py
import time
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
page = browser.new_page()
page.goto("https://playwright.dev/python/docs/input")
for _ in range(100):
page.mouse.wheel(0, 20)
time.sleep(5)
```
## 自動產生測試
```
playwright codegen demo.playwright.dev/todomvc
```
```
import re
from playwright.sync_api import Page, expect
import pytest
def create_todo_item(page, todo_text: str):
page.get_by_role("textbox", name="What needs to be done?").click()
page.get_by_role("textbox", name="What needs to be done?").fill(todo_text)
page.get_by_role("textbox", name="What needs to be done?").press("Enter")
def test_second_todo_in_list(page: Page):
# 新增兩個項目到列表
page.goto("https://demo.playwright.dev/todomvc/#/")
todo_input = page.get_by_role("textbox", name="What needs to be done?")
create_todo_item(page, "xxxxx")
expect(page.get_by_test_id("todo-title")).to_contain_text("xxxxx")
create_todo_item(page, "test2")
expect(page.locator("body")).to_contain_text("test2")
@pytest.fixture
def create_and_check(page: Page):
page.goto("https://demo.playwright.dev/todomvc/#/")
create_todo_item(page, "test todo")
page.get_by_role("checkbox", name="Toggle Todo").check()
def test_checked_item_in_all_list(page: Page, create_and_check) -> None:
expect(page.get_by_test_id("todo-title")).to_contain_text("test todo")
def test_checked_item_in_completed_list(page: Page, create_and_check) -> None:
page.get_by_role("link", name="Completed").click()
expect(page.get_by_test_id("todo-title")).to_contain_text("test todo")
# page.get_by_role("link", name="Active").click()
def test_checked_item_not_in_active_list(page: Page, create_and_check) -> None:
page.get_by_role("link", name="Active").click()
# 不顯示
expect(page.locator("html")).not_to_contain_text("test todo")
def test_removed_item_not_in_all_list(page: Page, create_and_check) -> None:
page.get_by_role("button", name="Clear completed").click()
# 不顯示
expect(page.locator("html")).not_to_contain_text("test todo")
def test_removed_item_not_in_active_list(page: Page, create_and_check) -> None:
page.get_by_role("link", name="Active").click()
page.get_by_role("button", name="Clear completed").click()
# 不顯示
expect(page.locator("html")).not_to_contain_text("test todo")
def test_removed_item_not_in_completed_list(page: Page, create_and_check) -> None:
page.get_by_role("link", name="Completed").click()
page.get_by_role("button", name="Clear completed").click()
# 不顯示
expect(page.locator("html")).not_to_contain_text("test todo")
def test_check_all_button(page: Page) -> None:
page.goto("https://demo.playwright.dev/todomvc/#/")
for _ in range(10):
create_todo_item(page, "test todo")
page.locator("body > section > div > section > label").click()
count = 0
for item_locator in page.locator("xpath=/html/body/section/div/section/ul/li/div/input").all():
expect(item_locator).to_be_checked()
count += 1
assert count == 10
def test_checked_item_will_not_be_affected_by_check_all_when_unchecked_item_exists(page: Page, create_and_check) -> None:
create_todo_item(page, "xxxxx")
check_all_button = page.locator("body > section > div > section > label")
check_all_button.click()
count = 0
for item_locator in page.locator("xpath=/html/body/section/div/section/ul/li/div/input").all():
expect(item_locator).to_be_checked()
count += 1
assert count == 2
# 完成項目 (checked)
# > 在 all 的頁面,會顯示 -> test_checked_item_in_all_list
# > 在 completed 的頁面,會顯示 -> test_checked_item_in_completed_list
# > 在 active 的頁面,不該顯示 -> test_checked_item_not_in_active_list
# > 清除完成項目
# > 在 all 的頁面,不該顯示 -> test_removed_item_not_in_all_list
# > 在 completed 的頁面,不該顯示 -> test_removed_item_not_in_active_list
# > 在 active 的頁面,不該顯示 -> test_removed_item_not_in_completed_list
# 完成所有項目 button
# 1 todo
# 多 todo -> test_check_all_button
# 已經有被勾選,不受影響 (checked) -> will not affect checked
# > unchecked exist
# > unchecked not exist
# 當所有的 item 都被 check 時,該按鈕會變成 uncheck all
# 1~N
# 刪除
# 1 (畫面有多個)
# > checked,不該顯示
# > unchecked,不該顯示
# 計數
# 新增要 +1
# 刪除要 -1
# checked 要 -1
```
# API 與前後端分離
## 🔗 什麼是 API?
API(Application Programming Interface),即應用程式介面,是一組定義不同軟體元件之間互動的規範。在網頁開發中,API 通常指的是 Web API,允許前端應用程式透過 HTTP 請求與後端伺服器進行資料交換。
## 📦 API 的作用
- 資料交換:前端透過 API 向後端請求資料,後端回傳 JSON、XML 等格式的資料。
- 功能封裝:後端將複雜的業務邏輯封裝在 API 中,前端只需調用即可。
- 跨平台支援:不同平台(如 Web、行動裝置)可共用相同的 API,提升開發效率。
## 🧪 傳統表單提交 vs API 呼叫
### 1️⃣ 傳統表單提交
傳統的 HTML 表單會將資料提交至伺服器,並由伺服器回傳整個新的 HTML 頁面。
```
pip install fastapi jinja2 uvicorn
pip install python-multipart
```
#### 🔧 Backend
```python!
# backend.py
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import uvicorn
app = FastAPI()
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def form(request: Request):
return templates.TemplateResponse("form.html", {"request": request})
@app.post("/greet", response_class=HTMLResponse)
async def greet(request: Request, name: str = Form(...)):
greeting = f"Hello, {name}!"
return templates.TemplateResponse("form.html", {"request": request, "greeting": greeting})
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
```
#### 📝 templates/form.html
```html
<!DOCTYPE html>
<html>
<head><title>Greeting</title></head>
<body>
<h1>Enter your name:</h1>
<form action="/greet" method="post">
<input type="text" name="name">
<button type="submit">Submit</button>
</form>
{% if greeting %}
<h2>{{ greeting }}</h2>
{% endif %}
</body>
</html>
```
### 2️⃣ 使用 Fetch API 呼叫後端 API
透過 JavaScript 的 Fetch API,可以在不重新載入整個頁面的情況下,將資料以 JSON 格式傳送至後端 API,並處理回傳的 JSON 資料。
#### 🧑🎨 前端(HTML + JS)index.html
```html
<!DOCTYPE html>
<html>
<head>
<title>Greeting App</title>
</head>
<body>
<h1>Enter your name:</h1>
<input type="text" id="nameInput">
<button onclick="sendName()">Submit</button>
<h2 id="greeting"></h2>
<script>
async function sendName() {
const name = document.getElementById("nameInput").value;
const response = await fetch("http://127.0.0.1:8000/api/greet", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ name: name })
});
const data = await response.json();
document.getElementById("greeting").innerText = data.message;
}
</script>
</body>
</html>
```
#### 🔧 Backend
```python!
# backend.py
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = FastAPI()
# 允許所有跨域請求
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允許所有來源
allow_credentials=True,
allow_methods=["*"], # 允許所有方法(GET, POST, PUT, DELETE 等)
allow_headers=["*"], # 允許所有 headers
)
class SubmitRequest(BaseModel):
name: str = ""
@app.post("/api/greet")
async def api_submit(data: SubmitRequest):
return {"message": f"Hello, {data.name}!"}
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
```
### 🔄 比較
| 項目 | 傳統表單提交 | 使用 Fetch API 呼叫 API |
| - | - | - |
| 頁面重新載入 | 是 | 否 |
資料格式 | 表單資料 | JSON
使用者體驗 | 可能較慢,整頁刷新 | 較快,局部更新
前後端耦合程度 | 較高 | 較低,易於前後端分離
# API 測試
## 🔍 什麼是 API 測試?
API 測試是指對應用程式介面(API)進行驗證,確保其功能、性能、安全性和可靠性符合預期。透過模擬各種請求,檢查 API 的回應是否正確,並驗證其在不同情境下的表現。
## ✅ 為什麼要進行 API 測試?
- 早期發現錯誤:在開發初期即發現並修正問題,降低修復成本。
- 提高開發效率:自動化測試可減少人工測試時間,加快開發進度。
- 確保系統穩定性:驗證 API 在各種情況下的表現,確保系統穩定運行。
- 促進前後端協作:明確的 API 測試有助於前後端團隊的溝通與協作。
## 🎯 為什麼使用 Playwright 進行 API 測試?
Playwright 不僅能進行瀏覽器自動化測試,API 測試功能,可以:
- 在測試中直接發送 HTTP 請求,驗證 API 回應。
- 使用同一套工具進行 UI 測試與 API 測試,簡化測試流程。
### 建立測試要用的 API
#### 將 API 跑起來
```
pip install fastcrud aiosqlite
```
```python!
import uvicorn
from typing import AsyncGenerator
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from fastcrud import FastCRUD, crud_router
from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import DeclarativeBase
from pydantic import BaseModel
class Base(DeclarativeBase):
pass
class Item(Base):
__tablename__ = 'items'
id = Column(Integer, primary_key=True)
name = Column(String)
description = Column(String)
class ItemCreateSchema(BaseModel):
name: str
description: str
class ItemUpdateSchema(BaseModel):
name: str
description: str
# Database setup (Async SQLAlchemy)
DATABASE_URL = "sqlite+aiosqlite:///./test.db"
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Database session dependency
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
yield session
# Create tables before the app start
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# FastAPI app
app = FastAPI(lifespan=lifespan)
# CRUD router setup
item_router = crud_router(
session=get_session,
model=Item,
create_schema=ItemCreateSchema,
update_schema=ItemUpdateSchema,
path="/items",
tags=["Items"],
)
app.include_router(item_router)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8000)
```
#### 手動測試
`http://localhost:8000/docs`
#### 測試增刪改查
```python!
from typing import Generator
import pytest
from playwright.sync_api import Playwright, APIRequestContext
@pytest.fixture(scope="session")
def api_request_context(
playwright: Playwright,
) -> Generator[APIRequestContext, None, None]:
request_context = playwright.request.new_context(
base_url="http://localhost:8000"
)
yield request_context
request_context.dispose()
def create_test_item(api_request_context: APIRequestContext) -> int:
response = api_request_context.post("/items", data={
"name": "Temp Item",
"description": "Temporary for testing"
})
# 4xx, 404, 500
assert response.ok
assert response.status == 200
return response.json()["id"]
def test_create_item(api_request_context: APIRequestContext):
response = api_request_context.post("/items", data={
"name": "Test Create",
"description": "Creating an item"
})
assert response.ok
data = response.json()
assert data["name"] == "Test Create"
assert data["description"] == "Creating an item"
def test_read_item(api_request_context: APIRequestContext):
item_id = create_test_item(api_request_context)
response = api_request_context.get(f"/items/{item_id}")
assert response.ok
data = response.json()
assert data["id"] == item_id
assert data["name"] == "Temp Item"
def test_update_item(api_request_context: APIRequestContext):
item_id = create_test_item(api_request_context)
response = api_request_context.patch(f"/items/{item_id}", data={
"name": "Updated Name",
"description": "Updated Description"
})
assert response.ok
response = api_request_context.get(f"/items/{item_id}")
assert response.ok
data = response.json()
assert data["name"] == "Updated Name"
assert data["description"] == "Updated Description"
def test_delete_item(api_request_context: APIRequestContext):
item_id = create_test_item(api_request_context)
response = api_request_context.delete(f"/items/{item_id}")
assert response.ok
# Verify it's deleted
response = api_request_context.get(f"/items/{item_id}")
assert response.status == 404
def test_get_not_exists_item_will_return_404(api_request_context: APIRequestContext):
item_id = create_test_item(api_request_context)
response = api_request_context.get(f"/items/{item_id + 1}")
assert response.status == 404
response = api_request_context.get("/items/osidjfospidfj-sd89fjspoifjsdfpoisjf-")
assert response.status == 422
def test_update_not_exists_item_will_return_404(api_request_context: APIRequestContext):
item_id = create_test_item(api_request_context)
response = api_request_context.patch(f"/items/{item_id + 1}", data={
"name": "Updated Name",
"description": "Updated Description"
})
assert response.status == 404
def test_delete_not_exists_item_will_return_404(api_request_context: APIRequestContext):
item_id = create_test_item(api_request_context)
response = api_request_context.delete(f"/items/{item_id}")
assert response.ok
response = api_request_context.delete(f"/items/{item_id}")
assert response.ok
@pytest.fixture(autouse=True)
def db():
# create DB
yield
# delete DB
```
# 🎯 為什麼使用 Trace Viewer?
Trace Viewer 是 Playwright 提供的圖形化工具,能夠:
- 錄製測試過程中的每個操作、網路請求、DOM 狀態與錯誤資訊。
- 在測試失敗時,快速回溯問題發生的時間點與原因。
- 支援視覺化檢查 DOM 快照、操作時間軸、主控台日誌與網路活動。
這對於前後端分離的應用測試、API 驗證、跨頁面流程測試等情境特別有幫助。
## 🛠️ 如何錄製 Trace?
使用 pytest 執行測試並錄製,在執行測試時加入 --tracing on 參數:
```shell!
pytest --tracing on
```
## 🔍 如何開啟 Trace Viewer?
### 方法一:使用 CLI 工具
```bash!
# test-results/test-it-py-test-fetch-api-chromium/trace.zip
playwright show-trace trace.zip
```
這會在本機開啟 GUI 工具,讓你逐步檢視測試過程。
### 方法二:使用線上工具
- 開啟瀏覽器,前往 [trace.playwright.dev](https://trace.playwright.dev)。
- 將 trace.zip 檔案拖曳至頁面中,或點擊「Select file(s)」按鈕上傳。
此方法適用於在不同設備或與團隊成員共享時使用。
# 🧭 Trace Viewer 功能導覽
Trace Viewer 提供多個標籤頁,協助你深入分析測試過程:
- Actions(操作):顯示每個操作的定位器、執行時間,並可檢視操作前後的 DOM 快照。
- Screenshots(螢幕截圖):以影片形式呈現測試過程,方便快速定位問題。
- Snapshots(快照):提供操作前、操作中、操作後的完整 DOM 快照,協助分析 UI 變化。
- Console:顯示測試期間的 Console 日誌,包含錯誤與警告訊息。
- Network(網路):列出所有網路請求,包含請求與回應的詳細資訊。
- Source(原始碼):對應到測試腳本中的程式碼行,方便快速定位。
- Metadata:顯示測試的環境資訊,如瀏覽器版本、視窗大小等。
# 🧰 黑箱測試(Black-box Testing)
## ✅ 定義
黑箱測試是一種不考慮程式內部結構的測試方法,測試者僅關注輸入與輸出是否符合預期,無需了解程式的內部實作。
## 🎯 特點
測試焦點:功能驗證,確認系統是否按照需求規格運作。
測試者角色:模擬最終使用者,從外部操作系統。
應用階段:適用於整合測試、系統測試與驗收測試等階段。
## 🧪 常見測試技術
- 等價劃分(Equivalence Partitioning)
- 需求:某表單要求使用者輸入年齡,合法範圍為 18 至 60 歲。
- 等價類劃分:
- 有效等價類:18–60
- 無效等價類:小於 18(如 17)、大於 60(如 61)
- 測試案例:(通常選一個有效,兩個無效)
- 選取 25(有效等價類)
- 選取 17(無效等價類)
- 選取 61(無效等價類)
- 邊界值分析(Boundary Value Analysis)
- 需求:密碼長度需介於 6 至 12 個字元之間。
- 邊界值選取:
- 最小有效值:6
- 最大有效值:12
- 小於最小值:5
- 大於最大值:13
- 測試案例:
- 輸入長度為 5 的密碼(無效)
- 輸入長度為 6 的密碼(有效)
- 輸入長度為 12 的密碼(有效)
- 輸入長度為 13 的密碼(無效)
- 決策表測試(Decision Table Testing)
- 需求:某網站提供折扣,規則如下:
- 若是會員且購買金額超過 1000 元,則享有 10% 折扣。
- 若是會員但購買金額不超過 1000 元,則享有 5% 折扣。
- 非會員則無折扣。
- 決策表:
條件 | 規則 1 | 規則 2 | 規則 3
| - | - | - | - |
是否為會員 | 是 | 是 | 否
購買金額 > 1000 | 是 | 否 | 任意
折扣 | 10% | 5% | 無
## 📌 實務範例
測試一個登入功能:輸入正確的帳號與密碼應成功登入,輸入錯誤的帳號或密碼應顯示錯誤訊息。
```
https://qa-practice.netlify.app/auth_ecommerce
```
# 🔍 白箱測試(White-box Testing)
## ✅ 定義
白箱測試是一種基於程式內部結構的測試方法,測試者需了解程式碼的邏輯與結構,設計測試案例以覆蓋各種程式路徑。
## 🎯 特點
測試焦點:程式內部邏輯與流程的正確性。
測試者角色:通常為開發人員或具備程式知識的測試人員。
應用階段:主要應用於單元測試階段。
## 🧪 常見測試技術
- 語句覆蓋(Statement Coverage)
- 分支覆蓋(Branch Coverage)
- 條件覆蓋(Condition Coverage)
- 路徑覆蓋(Path Coverage)
## 📌 實務範例
想想第一天
```
def guess_number(user_guess, target_number):
if user_guess < target_number:
return "低了"
elif user_guess > target_number:
return "高了"
else:
return "猜對了"
```
# TDD
TDD(Test-Driven Development,測試驅動開發)是一種以測試為主導的軟體開發方法,其核心理念是:
先寫測試,再寫功能,最後重構程式碼。
## 🧭 TDD 的三個步驟(紅—綠—重構)
### Red(紅):寫失敗的測試
根據需求撰寫一個單元測試。
此時還沒實作功能,因此測試會失敗(紅燈)。
### Green(綠):撰寫足夠通過測試的程式碼
撰寫剛好可以讓測試通過的程式碼。
重點是正確性而非完美設計,目的是讓測試轉為綠燈。
### Refactor(重構):優化程式碼
整理剛剛寫的程式碼,使其更清晰、更具可讀性。
這一步不能破壞功能,測試仍應全部通過。
## 🎯 TDD 的好處
✅ 提升程式正確性:每個功能都由測試保護。
✅ 快速迭代與改動無憂:有測試作為安全網。
✅ 驅動更清晰的設計:寫測試時自然會思考接口設計與模組邊界。
✅ 文件自動生成:測試程式碼本身就是活的說明文件。
## Let's TDD
### 📝 需求確認
- 輸入任何整數 n,回傳對應的 字串
- 若 n 可被 3 整除,回傳 "Fizz"
- 若 n 可被 5 整除,回傳 "Buzz"
- 若 n 同時可被 3 與 5 整除,回傳 "FizzBuzz"
- 其他情況,回傳 str(n)
### 1️⃣ 步驟一:數字預設行為
#### 測試(Red)
```python
# test_fizzbuzz.py
from fizzbuzz import fizzbuzz
def test_returns_number_as_string():
assert fizzbuzz(1) == "1"
```
執行後必定失敗,因為函式尚未實作
#### 實作(Green)
```python
# fizzbuzz.py
def fizzbuzz(n):
return str(n)
```
再次執行測試,test_returns_number_as_string 通過
### 2️⃣ 步驟二:Fizz 條件
#### 新增測試(Red)
python
```
def test_returns_fizz_for_multiples_of_three():
assert fizzbuzz(3) == "Fizz"
```
執行後,test_returns_fizz_for_multiple_of_three 失敗
#### 修正程式(Green)
```python
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
return str(n)
```
重新執行測試,兩個測試皆通過
## 3️⃣ 步驟三:Buzz 條件
#### 新增測試(Red)
```python
def test_returns_buzz_for_multiples_of_five():
assert fizzbuzz(5) == "Buzz"
```
執行後,此測試失敗
#### 修正程式(Green)
```python
def fizzbuzz(n):
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
```
重新執行所有測試,前三個測試通過
### 4️⃣ 步驟四:FizzBuzz 條件
#### 新增測試(Red)
```python
def test_returns_fizzbuzz_for_multiples_of_three_and_five():
assert fizzbuzz(15) == "FizzBuzz"
```
執行後,此測試失敗
#### 修正程式(Green)
```python
def fizzbuzz(n):
if n % 15 == 0:
return "FizzBuzz"
if n % 3 == 0:
return "Fizz"
if n % 5 == 0:
return "Buzz"
return str(n)
```
重新執行,四個測試均通過
### 🔄 Refactor
合併條件:將 n % 3 和 n % 5 的檢查先行組合,再根據結果回傳,或使用串接:
```python
def fizzbuzz(n):
res = ""
if n % 3 == 0: res += "Fizz"
if n % 5 == 0: res += "Buzz"
return res or str(n)
```
如此可減少重複且更易擴充
```
pip install pytest-cov
```
`.coveragerc`
```
[html]
show_contexts = True
```
```
pytest test_fizzbuzz.py --cov-context test --cov=. --cov-report html
```
---
# Pricing
`https://ecshweb.pchome.com.tw/search/v4.3/all/results?q=%E8%A1%8C%E5%8B%95%E9%9B%BB%E6%BA%90&page=1&pageCount=40`
```
# shopping.py
import urllib.parse
import requests
def query_elite(keyword: str):
'''
https://holmes.eslite.com/v1/search?q=+{encoded_keyword}&page_size=20&page_no=1&final_price=0,&sort=desc&branch_id=0
'''
encoded_keyword = urllib.parse.quote(keyword)
response = requests.get(
f"https://holmes.eslite.com/v1/search?q=+{encoded_keyword}&page_size=20&page_no=1&final_price=0,&sort=desc&branch_id=0"
)
data = [
{
"name": result["name"],
"pricing": float(result["final_price"]),
}
for result in response.json()["results"]
]
return data
def query_pchome(keyword: str):
encoded_keyword = urllib.parse.quote(keyword)
response = requests.get(
f"https://ecshweb.pchome.com.tw/search/v4.3/all/results?q={encoded_keyword}&page=1&pageCount=40"
)
'''
https://ecshweb.pchome.com.tw/search/v4.3/all/results?q=%E8%A1%8C%E5%8B%95%E9%9B%BB%E6%BA%90&page=1&pageCount=40
'''
data = [
{
"name": prod["Name"],
"pricing": float(prod["Price"]),
}
for prod in response.json()["Prods"]
]
return data
if __name__ == "__main__":
print(query_pchome("行動電源"))
```
```
# test_pricing_server.py
from shopping import query_pchome, query_elite
import json
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from .main import app
client = TestClient(app)
def test_pricing_sort_by_pricing_desc():
"3, 2, 1"
with mock.patch(
"main.shopping.query_elite",
return_value=[
{"name": "elite1", "pricing": 100.1},
{"name": "elite2", "pricing": 90.1}
]) as query_elite:
with mock.patch(
"main.shopping.query_pchome",
return_value=[
{"name": "pchome1", "pricing": 80.1},
{"name": "pchome2", "pricing": 110.1},
]) as query_pchome:
response = client.get("/pricing?keyword=行動電源&sort_by=pricing")
data = response.json()
for pre, curr in zip(data, data[1:]):
assert pre["pricing"] >= curr["pricing"]
def test_pricing_sort_by_pricing_asc():
"1, 2, 3"
with mock.patch(
"main.shopping.query_elite",
return_value=[
{"name": "elite1", "pricing": 100.1},
{"name": "elite2", "pricing": 90.1}
]) as query_elite:
with mock.patch(
"main.shopping.query_pchome",
return_value=[
{"name": "pchome1", "pricing": 80.1},
{"name": "pchome2", "pricing": 110.1},
]) as query_pchome:
response = client.get("/pricing?keyword=行動電源&sort_by=-pricing")
data = response.json()
for pre, curr in zip(data, data[1:]):
assert pre["pricing"] <= curr["pricing"]
def test_pricing_will_return_200_with_keyword():
with mock.patch(
"main.shopping.query_elite",
return_value=[{"name": "elite", "pricing": 100.1}]
) as query_elite:
with mock.patch(
"main.shopping.query_pchome",
return_value=[{"name": "pchome", "pricing": 100.1}]
) as query_pchome:
response = client.get("/pricing?keyword=行動電源")
query_elite.assert_called_once_with("行動電源")
query_pchome.assert_called_once_with("行動電源")
assert response.status_code == 200
assert len(response.json()) > 0
data = response.json()[0]
assert "name" in data
assert "pricing" in data
assert isinstance(data["name"], str)
assert isinstance(data["pricing"], float)
assert data["name"] == "pchome"
data = response.json()[1]
assert data["name"] == "elite"
@pytest.fixture
def pchome_battery() -> list:
with open("./pchome_battery.json", encoding="utf-8") as f:
data = json.loads(f.read())
return data
def test_query_pchome_will_return_list_of_pricing_and_name(pchome_battery):
response = mock.Mock()
response.json.return_value = pchome_battery
with mock.patch("shopping.requests.get", side_effect=[response]) as get:
result = query_pchome("行動電源")
assert len(result) > 0
assert "pricing" in result[0]
assert isinstance(result[0]["pricing"], float)
assert "name" in result[0]
assert isinstance(result[0]["name"], str)
assert result[0]["name"] == "超強行動電源"
@pytest.fixture
def elite_battery() -> list:
with open("./elite_battery.json", encoding="utf-8") as f:
data = json.loads(f.read())
return data
def test_query_elite_will_return_list_of_pricing_and_name(elite_battery):
response = mock.Mock()
response.json.return_value = elite_battery
with mock.patch("shopping.requests.get", side_effect=[response]) as get:
result = query_elite("行動電源")
assert len(result) > 0
assert "pricing" in result[0]
assert isinstance(result[0]["pricing"], float)
assert "name" in result[0]
assert isinstance(result[0]["name"], str)
assert result[0]["name"] == "大方塊行動電源"
```
elite:
```
https://holmes.eslite.com/v1/search?q=+%E8%A1%8C%E5%8B%95%E9%9B%BB%E6%BA%90&page_size=20&page_no=1&final_price=0,&sort=desc&branch_id=0
```
https://fastapi.tiangolo.com/tutorial/testing/#using-testclient
```
set PYTHONPATH=.
```
```
# main.py
from fastapi import FastAPI
import shopping
app = FastAPI()
@app.get("/")
def read_main():
return {"msg": "Hello World"}
def get_pricing(obj):
return obj["pricing"]
@app.get("/pricing")
def pricing(keyword: str, sort_by: str = None):
elite_data = shopping.query_elite(keyword)
pchome_data = shopping.query_pchome(keyword)
data = pchome_data + elite_data
if sort_by == "-pricing":
data.sort(key=get_pricing)
if sort_by == "pricing":
data.sort(key=get_pricing, reverse=True)
return data
```
```
import time
def valid_password(password):
if password != "1234":
time.sleep(10)
return False
return True
def test_valid_password():
with mock.patch(
"shopping.time.sleep",
side_effect=[None]
) as sleep:
assert valid_password("1") is False
sleep.assert_called_once_with(10)
```
`uvicorn main:app --host 0.0.0.0 --port 8000`