---
# System prepended metadata

title: 自動化測試 20250621

---

# 自動化測試 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`