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