跳轉到

5.2 測試策略:從單元測試到端對端測試#

測試金字塔與成本模型#

\[ \text{測試成本} \propto \text{測試層級高度}, \quad \text{信心} \propto \text{測試層級高度} \]
        /  E2E  \        少而貴:全流程,最接近真實
       / 整合測試 \       中:多模組協作(API + DB)
      /  單元測試   \      多而廉:純函數,快如閃電

測試策略的核心是把業務邏輯放在低層可測的位置 —— 架構與可測性互為因果:分層清晰的系統,純函數自然多。

Rust 端:測試即公民#

單元測試(與程式碼同檔)#

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn slug_title_trims_whitespace() {
        assert_eq!(slug("  hello  "), "hello");
    }

    #[test]
    fn empty_title_is_invalid() {
        assert!(CreateTodo { title: "".into() }.validate().is_err());
    }
}

cargo test 內建執行器,無需框架 —— 官方工具鏈統一的又一次體現。

整合測試(tests/ 目錄 + 真實資料庫)#

// tests/api.rs
#[tokio::test]
async fn create_then_toggle_todo() {
    let app = test_app().await;                        // 測試專用 DB,隨機 schema
    let token = app.login("a@b.c", "pw123456").await;

    let todo: Todo = app.post("/todos", &json!({"title": "write tests"}), &token).await;
    assert!(!todo.done);

    let toggled: Todo = app.patch(&format!("/todos/{}", todo.id), &token).await;
    assert!(toggled.done);
}

關鍵實務:每個測試用獨立 schema/交易回滾,測試可並行且互不污染。

Property-Based Testing:從例子到性質#

use proptest::prelude::*;

proptest! {
    // 性質:對任意標題,slug 永不含空白且不超長
    #[test]
    fn slug_never_has_spaces(s in "[a-zA-Z0-9 ]{0,200}") {
        let out = slug(&s);
        prop_assert!(!out.contains(' '));
        prop_assert!(out.len() <= 200);
    }
}

範例測試驗證「這幾個輸入對」;性質測試驗證不變式對所有輸入成立 —— 從歸納到演繹的信心躍升,特別適合解析器、序列化往返等高度不變的領域。

序列化往返性質#

proptest! {
    fn todo_json_roundtrip(t in todo_strategy()) {
        let json = serde_json::to_string(&t).unwrap();
        let back: Todo = serde_json::from_str(&json).unwrap();
        prop_assert_eq!(t, back);      // 序列化必須是等價變換
    }
}

這直接守護 4.4 節的型別管道 —— 序列化語意一改,CI 立刻爆。

TypeScript 端#

元件測試(React Native Testing Library)#

it('renders done marker when todo.done', () => {
  render(<TodoItem todo={{ id: '1', title: 'x', done: true }} onToggle={jest.fn()} />);
  expect(screen.getByText(/✓/)).toBeTruthy();
});

驗證層測試#

it('rejects malformed todo from server', () => {
  expect(() => TodoSchema.parse({ id: 'not-uuid' })).toThrow();
});

E2E:Maestro(Expo 官方推薦)#

# .maestro/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "a@b.c"
- tapOn: "Password"
- inputText: "pw123456"
- tapOn: "登入"
- assertVisible: "我的待辦"

E2E 數量要克制:只覆蓋關鍵路徑(登入、核心 CRUD)。維護成本與脆弱性隨數量超線性成長。

CI 中的測試矩陣#

# .github/workflows/ci.yml(節選)
jobs:
  backend:
    runs-on: ubuntu-latest
    services:
      postgres: { image: postgres:16, env: { POSTGRES_PASSWORD: test } }
    steps:
      - uses: actions/checkout@v4
      - run: cargo fmt --check && cargo clippy -- -D warnings
      - run: cargo test
  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npx tsc --noEmit           # 型別檢查本身也是測試!
      - run: npm test

tsc --noEmit 是最便宜的一批「測試」 —— 它驗證的是型別契約,零維護成本。這再次印證本書主題:型別系統是最便宜的自動化測試。

習題#

  1. 把 5.1 的 toggle 端點寫成三個整合測試:成功、不存在、他人的 todo。
  2. 設計一個序列化往返的 property test,覆蓋 Option<NaiveDate>。
  3. 為什麼 E2E 只測關鍵路徑?用成本模型論證。