自動化測試是將人對軟體的測試行為轉化為由機器執行的實踐,能夠大幅提升測試效率和一致性。

什麼是自動化測試?#

自動化測試的本質:先寫一段程式碼,然後去測試另一段程式碼。實現自動化測試本身屬於開發工作,需要投入時間和精力。

自動化測試的價值#

優勢說明
替代重複性工作讓測試工程師專注於案例設計和新功能測試
提升回歸效率非常適合敏捷開發的頻繁回歸需求
利用無人值守時間夜間執行測試,白天分析結果
實現特殊測試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 AssuredJava流式 API,易於使用
Postman/NewmanJS界面友好,支持 CI/CD
pytest + requestsPython靈活,社區活躍
KarateJavaDSL 風格,無需編碼

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)

測試的通過/失敗必須忠實反映程式的真實狀態。一套會「假綠燈」或「無故閃紅燈」的測試,比沒有測試更危險,因為它會逐步侵蝕團隊的信任。

DoDon’t
失敗時能明確指向哪段生產程式碼壞了try/catch 把例外吞掉,讓測試永遠通過
測試行為與契約,不測實作細節為了讓紅燈變綠而隨手改斷言、註解掉測試
不穩定(flaky)測試立即隔離修復對偶發失敗視而不見、反覆重跑直到碰巧通過

支柱二:可維護性(Maintainability)

生產程式碼一改,測試就大面積崩壞,是測試被團隊放棄的頭號原因。可維護性的核心是:只對公開契約下斷言、避免過度指定(over-specification)。

DoDon’t
測公開行為與最終狀態對內部呼叫順序、私有欄位做不必要的 verify
用工廠 / builder 抽掉重複的建置程式碼每個測試各自複製貼上一大段 setup
只 stub 真正影響被測路徑的相依把所有相依都 mock 起來、過度指定每一次互動

支柱三:可讀性(Readability)

測試是規格的活文件。讀不懂的測試無法在失敗時提供任何線索。

DoDon’t
測試名描述「情境 + 預期」(shouldX_whenYtest1testOrder 之類無意義名稱
變數命名點出意圖(expiredCoupon滿篇 abobj 與魔術數字
一個測試只驗證一個概念,斷言清晰分離把多個無關情境塞進同一個測試方法

測試反模式#

以下是侵蝕三大支柱的常見壞味道,各附簡短反例與改法。

反模式一:測試內含控制流(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%

自動化測試框架選型#

單元測試框架#

語言框架特點
JavaJUnit 5, TestNG註解驅動,生態豐富
Pythonpytest, unittestpytest 更靈活
JavaScriptJest, MochaJest 內建 Mock
Gotesting + testify內建支持

Mock 框架#

語言框架
JavaMockito, PowerMock
Pythonunittest.mock, pytest-mock
JavaScriptJest (內建), Sinon.js

程式碼覆蓋率工具#

語言工具
JavaJaCoCo
JavaScriptIstanbul, nyc
Pythoncoverage.py
Gogo test -cover