自動化測試是將人對軟體的測試行為轉化為由機器執行的實踐,能夠大幅提升測試效率和一致性。
什麼是自動化測試?#
自動化測試的本質:先寫一段程式碼,然後去測試另一段程式碼。實現自動化測試本身屬於開發工作,需要投入時間和精力。
自動化測試的價值#
| 優勢 | 說明 |
|---|---|
| 替代重複性工作 | 讓測試工程師專注於案例設計和新功能測試 |
| 提升回歸效率 | 非常適合敏捷開發的頻繁回歸需求 |
| 利用無人值守時間 | 夜間執行測試,白天分析結果 |
| 實現特殊測試 | 7×24 穩定性測試、高並行壓力測試 |
| 保證一致性 | 避免人為遺漏或疏忽 |
自動化測試的局限#
業界玩笑話:「開發手一抖,自動化測試忙一宿」——自動化測試的維護成本是實施前必須考慮的關鍵因素。
| 局限 | 說明 |
|---|---|
| 不能取代手工測試 | 只能替代高頻率、機械化的步驟 |
| 維護成本高 | 被測系統變化時需要更新案例 |
| 開發工作量大 | 執行次數 ≥ 5 次才能收回成本 |
| 依賴案例品質 | 不穩定的案例比沒有更糟糕 |
適合自動化測試的場景#
適合自動化的條件檢查清單:
├── ☑ 需求穩定,不頻繁變更
├── ☑ 研發周期長,需要頻繁回歸
├── ☑ 需要跨平台重複執行
├── ☑ 手工測試無法實現(如高並行)
├── ☑ 開發過程規範,具備可測試性
└── ☑ 團隊具備程式設計能力ROI 分析#
自動化測試 ROI = (手工測試成本 × 執行次數 - 自動化開發成本 - 維護成本) / 自動化投入
經驗法則:
- 執行次數 ≥ 5 次,自動化才划算
- 20% 的精力覆蓋 80% 的回歸測試單元測試#
單元測試是對軟體中最小可測試單元(通常是函式或類)進行隔離測試。
單元測試的價值#
電視機生產的類比:
├── 電子元器件 ─────────── 單元(函式/類)
├── 功能電路板 ─────────── 模組
└── 完整電視機 ─────────── 系統
先測試元器件,再組裝,比組裝後逐級排查問題效率高得多。單元測試案例的組成#
單元測試案例是「輸入資料」和「預期輸出」的集合。但這兩者都比想像中複雜得多。
輸入資料包括:
- 被測函式的輸入參數
- 函式內部讀取的全局/靜態變數
- 函式內部讀取的成員變數
- 函式內部呼叫子函式獲得/改寫的資料
預期輸出包括:
- 被測函式的回傳值
- 被測函式的輸出參數
- 被測函式改寫的成員變數/全域變數
- 被測函式進行的檔案/資料庫/訊息佇列更新
驅動程式碼、樁程式碼和 Mock 程式碼#
┌─────────────────────────────────────────────────┐
│ 測試程式碼 │
│ ┌──────────────────────────────────────────┐ │
│ │ 驅動程式碼 (Driver) │ │
│ │ - 呼叫被測函式 │ │
│ │ - 準備測試資料 │ │
│ │ - 驗證結果 │ │
│ └──────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────┐ │
│ │ 被測函式 │ │
│ └──────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────┐ │
│ │ 樁程式碼 (Stub) / Mock 程式碼 │ │
│ │ - 代替被測函式呼叫的真實程式碼 │ │
│ │ - 控制執行路徑 │ │
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘Stub vs Mock#
| 特性 | Stub(樁程式碼) | Mock(模擬程式碼) |
|---|---|---|
| 目的 | 控制被測函式的執行路徑 | 驗證交互行為 |
| 關注點 | 回傳值 | 呼叫方式、參數、次數、順序 |
| Assert 位置 | 驅動程式碼中 | Mock 函式中 |
// Stub 範例:控制回傳值
@Test
void testWithStub() {
// Stub:讓 userService.getUser() 回傳固定值
when(userService.getUser(anyLong())).thenReturn(testUser);
// 執行被測方法
String result = orderService.createOrder(userId);
// 在驅動程式碼中驗證結果
assertEquals("SUCCESS", result);
}
// Mock 範例:驗證交互
@Test
void testWithMock() {
orderService.createOrder(userId);
// 驗證 Mock 是否被正確呼叫
verify(emailService, times(1)).sendConfirmation(any());
verify(inventoryService).reduceStock(productId, 1);
}測試替身(Test Double)完整分類#
「Stub」與「Mock」只是測試替身家族中的兩位成員。日常溝通常把所有替身一律稱為「Mock」,但這會掩蓋它們在意圖上的根本差異。完整的五類分類能讓團隊術語對齊,也讓你在設計測試時更清楚「我到底想驗證什麼」。
區分測試替身的關鍵問題只有一個:驗證點落在哪裡? 落在「狀態 / 回傳值」是 Stub 家族;落在「互動行為」才是 Mock。
| 替身類型 | 用途 | 是否驗證互動 | 典型場景 |
|---|---|---|---|
| Dummy | 僅填充參數簽名,從不被真正使用 | 否 | 建構子需要一個物件,但被測路徑不會碰它 |
| Fake | 有可運作的簡化實作,但不適用於生產 | 否 | 記憶體版 Repository、記憶體版檔案系統 |
| Stub | 餵入固定回傳值,控制被測程式的輸入 | 否 | 讓查詢回傳預設資料、讓相依服務回傳成功/失敗 |
| Spy | 包住真實或假物件,記錄呼叫歷史 | 事後可選驗證 | 既要走真實邏輯,又想確認某方法被呼叫過幾次 |
| Mock | 預先設定期望,未滿足期望即測試失敗 | 是(核心) | 確認「寄出通知」「扣庫存」這類副作用確實發生了 |
flowchart TD
A[需要替身] --> B{驗證點在哪?}
B -->|狀態 / 回傳值| C[Stub 家族]
B -->|互動行為| D[Mock]
C --> C1[Dummy: 只佔位]
C --> C2[Fake: 簡化實作]
C --> C3[Stub: 控制輸入]
D --> D1[Spy: 先記錄, 事後驗證]
D --> D2[Mock: 預設期望, 自動驗證]Fake:用記憶體實作取代真實相依
// Fake:記憶體版的 UserRepository,行為完整但不落地資料庫
class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> store = new HashMap<>();
@Override
public void save(User user) {
store.put(user.getId(), user);
}
@Override
public User findById(Long id) {
return store.get(id);
}
}Spy:走真實邏輯,但記錄互動
@Test
void shouldNotifyOnceAfterRegister() {
// spy 包住真實物件,未 stub 的方法仍走真實邏輯
NotificationService spy = Mockito.spy(new NotificationService());
userService.register("alice@example.com", spy);
// 事後驗證互動發生過,且只發生一次
verify(spy, times(1)).notifyWelcome("alice@example.com");
}Stub 控制輸入,Mock 驗證互動:若把 Stub 也拿來
verify,等於用「控制輸入的工具」來驗證行為,會讓測試同時關注輸入與互動,意圖含糊。一個測試最好只有一個 Mock(一個被驗證的互動關注點),其餘相依一律用 Stub 餵資料。
整合測試#
整合測試關注軟體模組之間的介面呼叫和資料傳遞。
整合測試 vs 單元測試#
| 方面 | 單元測試 | 整合測試 |
|---|---|---|
| 範圍 | 單個函式/類 | 多個模組協作 |
| 依賴處理 | 使用 Stub/Mock | 使用真實依賴 |
| 目標 | 驗證邏輯正確性 | 驗證介面協作 |
抽樁(Un-stub)#
單元測試階段:函式 A 呼叫 Stub 函式 B
↓
整合測試階段:將 Stub 函式 B 替換為真實函式 B(抽樁)API 自動化測試#
API 測試的三大步驟:
// 1. 準備測試資料
UserDTO testUser = new UserDTO("test@example.com", "password123");
// 2. 發起 API 呼叫
Response response = given()
.contentType(ContentType.JSON)
.body(testUser)
.when()
.post("/api/users");
// 3. 驗證回傳結果
response.then()
.statusCode(201)
.body("id", notNullValue())
.body("email", equalTo("test@example.com"));API 測試框架選擇#
| 框架 | 語言 | 特點 |
|---|---|---|
| REST Assured | Java | 流式 API,易於使用 |
| Postman/Newman | JS | 界面友好,支持 CI/CD |
| pytest + requests | Python | 靈活,社區活躍 |
| Karate | Java | DSL 風格,無需編碼 |
GUI 自動化測試#
Selenium 工作原理#
┌──────────────────────────────────────────────────────┐
│ 測試程式碼 (Client) │
│ driver.findElement(By.id("login")) │
└──────────────────────────────────────────────────────┘
│
HTTP Request
(WebDriver 協定)
↓
┌──────────────────────────────────────────────────────┐
│ Remote Server (WebDriver) │
│ 解析請求,呼叫瀏覽器原生 WebDriver │
└──────────────────────────────────────────────────────┘
│
原生 API 呼叫
↓
┌──────────────────────────────────────────────────────┐
│ 瀏覽器 │
│ 執行頁面操作,回傳結果 │
└──────────────────────────────────────────────────────┘頁面物件模型(Page Object Model)#
頁面物件模型將頁面元素和操作封裝成獨立的類,使測試程式碼更易維護。
// 頁面物件類
public class LoginPage {
private WebDriver driver;
@FindBy(id = "username")
private WebElement usernameInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(id = "login-btn")
private WebElement loginButton;
public void login(String username, String password) {
usernameInput.sendKeys(username);
passwordInput.sendKeys(password);
loginButton.click();
}
}
// 測試類
public class LoginTest {
@Test
void testLogin() {
LoginPage loginPage = new LoginPage(driver);
loginPage.login("user@example.com", "password");
// 驗證登入成功
}
}測試資料管理#
創建方式#
| 方式 | 說明 | 適用場景 |
|---|---|---|
| API 呼叫 | 通過產品 API 創建資料 | 資料準確性要求高 |
| 資料庫操作 | 直接操作資料庫 | API 不支持,需要大量資料 |
| 綜合方式 | API + 資料庫 | 創建特定狀態的資料 |
創建時機#
| 時機 | 說明 | 優缺點 |
|---|---|---|
| On-the-fly | 測試執行時即時創建 | 資料隔離好,但效率低 |
| Out-of-box | 預先創建好「開箱即用」 | 效率高,但可能被污染 |
最佳實踐:穩定資料(如商品類目)用 Out-of-box,一次性資料(如訂單)用 On-the-fly。
單元測試最佳實踐#
FIRST 原則#
| 原則 | 說明 |
|---|---|
| Fast | 快速執行,秒級反饋 |
| Independent | 案例之間相互獨立 |
| Repeatable | 任何環境下結果一致 |
| Self-validating | 自動判斷通過/失敗 |
| Timely | 與生產程式碼同時編寫 |
測試程式碼組織(AAA 模式)#
@Test
void shouldCalculateTotalPrice() {
// Arrange(準備)
ShoppingCart cart = new ShoppingCart();
cart.add(new Product("Apple", 10.0), 2);
cart.add(new Product("Banana", 5.0), 3);
// Act(執行)
double total = cart.calculateTotal();
// Assert(驗證)
assertEquals(35.0, total, 0.001);
}好測試的三大支柱#
FIRST 是入門檢核;當測試規模成長後,真正決定一套測試是資產還是負債的,是以下三大支柱。它們互相牽制:過度追求其一往往傷害其二。
flowchart LR
R[可信賴性<br/>Trustworthiness] --- M[可維護性<br/>Maintainability] --- L[可讀性<br/>Readability]
L --- R支柱一:可信賴性(Trustworthiness)
測試的通過/失敗必須忠實反映程式的真實狀態。一套會「假綠燈」或「無故閃紅燈」的測試,比沒有測試更危險,因為它會逐步侵蝕團隊的信任。
| Do | Don’t |
|---|---|
| 失敗時能明確指向哪段生產程式碼壞了 | 用 try/catch 把例外吞掉,讓測試永遠通過 |
| 測試行為與契約,不測實作細節 | 為了讓紅燈變綠而隨手改斷言、註解掉測試 |
| 不穩定(flaky)測試立即隔離修復 | 對偶發失敗視而不見、反覆重跑直到碰巧通過 |
支柱二:可維護性(Maintainability)
生產程式碼一改,測試就大面積崩壞,是測試被團隊放棄的頭號原因。可維護性的核心是:只對公開契約下斷言、避免過度指定(over-specification)。
| Do | Don’t |
|---|---|
| 測公開行為與最終狀態 | 對內部呼叫順序、私有欄位做不必要的 verify |
| 用工廠 / builder 抽掉重複的建置程式碼 | 每個測試各自複製貼上一大段 setup |
| 只 stub 真正影響被測路徑的相依 | 把所有相依都 mock 起來、過度指定每一次互動 |
支柱三:可讀性(Readability)
測試是規格的活文件。讀不懂的測試無法在失敗時提供任何線索。
| Do | Don’t |
|---|---|
測試名描述「情境 + 預期」(shouldX_whenY) | 用 test1、testOrder 之類無意義名稱 |
變數命名點出意圖(expiredCoupon) | 滿篇 a、b、obj 與魔術數字 |
| 一個測試只驗證一個概念,斷言清晰分離 | 把多個無關情境塞進同一個測試方法 |
測試反模式#
以下是侵蝕三大支柱的常見壞味道,各附簡短反例與改法。
反模式一:測試內含控制流(if / for / while)
控制流意味著「同一個測試在不同輸入下走不同路徑」,失敗時你無法一眼看出是哪條路徑壞了,且分支本身可能藏 bug。
// ✗ 反例:測試裡有迴圈與分支
@Test
void testDiscount() {
for (int amount : new int[]{100, 500, 1000}) {
double d = pricing.discount(amount);
if (amount >= 1000) {
assertEquals(0.2, d);
} else {
assertEquals(0.0, d);
}
}
}
// ✓ 改法:用參數化測試,每組資料是獨立、可單獨失敗的案例
@ParameterizedTest
@CsvSource({ "100, 0.0", "500, 0.0", "1000, 0.2" })
void shouldApplyDiscount(int amount, double expected) {
assertEquals(expected, pricing.discount(amount));
}反模式二:動態計算預期值
如果預期值是用「跟生產程式碼相同的公式」算出來的,那測試只是把 bug 抄了一遍——程式錯,測試也跟著錯,永遠綠燈。
// ✗ 反例:預期值用同一套邏輯算出來
@Test
void testTax() {
double expected = price * 0.05; // 與生產程式碼相同公式
assertEquals(expected, calculator.tax(price));
}
// ✓ 改法:用人工算好的字面量當預期值
@Test
void shouldCalculateTax() {
assertEquals(5.0, calculator.tax(100.0)); // 100 * 5% = 5
}反模式三:單一測試多重關注點斷言
一個測試方法驗證多個不相關的行為,第一個斷言失敗後其餘就不再執行,掩蓋了後面的問題;命名也無法精確描述意圖。
// ✗ 反例:登入成功 + 寄信 + 寫稽核 全擠在一起
@Test
void testLogin() {
LoginResult r = auth.login("alice", "pw");
assertTrue(r.isSuccess());
verify(mailer).sendLoginAlert(any());
verify(auditLog).record(any());
}
// ✓ 改法:拆成各自聚焦的測試
@Test
void shouldSucceed_whenCredentialsValid() {
assertTrue(auth.login("alice", "pw").isSuccess());
}
@Test
void shouldSendAlert_whenLoginSucceeds() {
auth.login("alice", "pw");
verify(mailer).sendLoginAlert(any());
}反模式四:測試 private 方法
私有方法是實作細節。直接用反射去測它,會把測試焊死在實作上(傷害可維護性),也暗示這個類別職責過多。
想測 private 方法時,先問:它是否該被抽成獨立、可公開測試的協作類別?通常「想測私有方法」是一個責任過載、需要重構的信號,而不是該動用反射的理由。
// ✗ 反例:用反射硬測私有方法
Method m = Calculator.class.getDeclaredMethod("normalize", String.class);
m.setAccessible(true);
// ✓ 改法 A:透過公開方法間接驗證其效果
// ✓ 改法 B:若邏輯夠重要,抽成獨立類別 Normalizer 並公開測試Java(JUnit5 + Mockito)的具體實作手法——如何驅動相依、隔離邊界、組織測試專案——見 Java 單元測試深度章節;本節聚焦跨語言通用的測試品質框架。
實施單元測試的挑戰#
常見挑戰及解決方案
| 挑戰 | 解決方案 |
|---|---|
| 緊密耦合的程式碼難以隔離 | 重構程式碼,引入依賴注入 |
| 程式碼可測試性差 | 設計時考慮可測試性 |
| 無法模擬系統底層函式 | 使用 PowerMock 等工具 |
| 覆蓋率越往後越難提高 | 設定合理目標,不追求 100% |
自動化測試框架選型#
單元測試框架#
| 語言 | 框架 | 特點 |
|---|---|---|
| Java | JUnit 5, TestNG | 註解驅動,生態豐富 |
| Python | pytest, unittest | pytest 更靈活 |
| JavaScript | Jest, Mocha | Jest 內建 Mock |
| Go | testing + testify | 內建支持 |
Mock 框架#
| 語言 | 框架 |
|---|---|
| Java | Mockito, PowerMock |
| Python | unittest.mock, pytest-mock |
| JavaScript | Jest (內建), Sinon.js |
程式碼覆蓋率工具#
| 語言 | 工具 |
|---|---|
| Java | JaCoCo |
| JavaScript | Istanbul, nyc |
| Python | coverage.py |
| Go | go test -cover |