為什麼要分層

一個很常見的問題:「我是不是每個 function 都要寫 unit test?」

答案是否。不是每一行 code 都值得測試——值得測試的是行為,不是實作細節

分層的目的是:用最低的成本驗證最多的行為。每一層有不同的成本和信心值:

層次執行速度成本信心值
Unit Test毫秒局部行為正確
Integration Test元件間串接正確
E2E Test分鐘使用者流程正確

Test Pyramid 的意思:Unit test 最多(快、便宜)、Integration test 中等、E2E 最少(慢、維護成本高)。倒過來(E2E 最多)叫 ice cream cone,是反 pattern——執行慢、容易 flaky、feedback loop 長。


Unit Test:測業務邏輯,不測框架

Unit test 的核心定義:不碰 I/O(不查資料庫、不打網路、不讀檔案)。

什麼值得寫 unit test:

// ✅ 純業務邏輯 — 值得 unit test
class PricingService {
  calculateDiscount(price: number, memberTier: 'gold' | 'silver' | 'regular'): number {
    if (memberTier === 'gold') return price * 0.8;
    if (memberTier === 'silver') return price * 0.9;
    return price;
  }
}
 
// test
it('gold member gets 20% discount', () => {
  const service = new PricingService();
  expect(service.calculateDiscount(100, 'gold')).toBe(80);
});

什麼不值得單獨寫 unit test:

// ❌ 只是 ORM 呼叫 — 沒有業務邏輯,測了什麼?
class UserRepository {
  findById(id: number) {
    return User.findByPk(id); // 這是在測 Sequelize,不是在測你的 code
  }
}

Repository 的 findById 沒有業務邏輯——測它等於測 Sequelize 有沒有壞掉。讓 integration test 去驗資料庫操作是否正確,unit test 專注在有判斷邏輯的業務 code。

Service 層的 mock 策略

// UserService 的 unit test:mock repository,只測 Service 的邏輯
describe('UserService', () => {
  let userService: UserService;
  let mockUserRepo: jest.Mocked<UserRepository>;
 
  beforeEach(() => {
    mockUserRepo = {
      findById: jest.fn(),
      create: jest.fn(),
    } as any;
    userService = new UserService(mockUserRepo);
  });
 
  it('throws NotFoundError when user does not exist', async () => {
    mockUserRepo.findById.mockResolvedValue(null);
    await expect(userService.findById(999)).rejects.toThrow(NotFoundError);
  });
});

Integration Test:測元件串接,用真實 DB

Integration test 的核心定義:測多個元件串接在一起的行為,通常需要真實的外部依賴(DB、Redis)。

最有價值的 integration test 是 API 層的 integration test(也叫 contract test 或 API test):

// Express + supertest — API integration test
describe('POST /users', () => {
  beforeEach(async () => {
    await db.sync({ force: true }); // 清空 DB
    await runSeeders();              // 跑必要初始資料
  });
 
  it('creates a user and returns 201', async () => {
    const response = await request(app)
      .post('/users')
      .send({ name: 'Alice', email: 'alice@example.com', age: 25 });
 
    expect(response.status).toBe(201);
    expect(response.body.data.email).toBe('alice@example.com');
 
    // 確認資料真的進了 DB
    const user = await User.findOne({ where: { email: 'alice@example.com' } });
    expect(user).not.toBeNull();
  });
 
  it('returns 400 when email is invalid', async () => {
    const response = await request(app)
      .post('/users')
      .send({ name: 'Bob', email: 'not-an-email', age: 25 });
 
    expect(response.status).toBe(400);
  });
});

Integration test 的 DB 策略

選項一:每個 test 前清空 DB(truncatesync({ force: true })

  • 優點:每個 test 隔離,不互相影響
  • 缺點:慢(特別是有外鍵的 table)

選項二:每個 test 在 transaction 裡跑,test 結束後 rollback

  • 優點:快
  • 缺點:有些測試案例本身就在測 transaction 行為,會有問題

選項三:用 Testcontainers 起一個 fresh DB 每次 test suite

  • 優點:最乾淨,不會有 test 間的資料汙染
  • 缺點:起 container 需要時間(通常 1-3 秒)

實務建議:小專案用選項一;中大型專案用選項三(Testcontainers)隔離 test suite,同一個 suite 內用 transaction rollback。


E2E Test:測使用者流程,從外部打進來

E2E test 把整個系統當作黑盒,從最外層(HTTP request 或 UI 操作)進去,驗整個 user journey。

// E2E:完整的使用者流程
describe('User registration and login flow', () => {
  it('user can register and login', async () => {
    // 1. 註冊
    const register = await request(baseUrl)
      .post('/auth/register')
      .send({ name: 'Alice', email: 'alice@example.com', password: 'Secure123!' });
    expect(register.status).toBe(201);
 
    // 2. 登入
    const login = await request(baseUrl)
      .post('/auth/login')
      .send({ email: 'alice@example.com', password: 'Secure123!' });
    expect(login.status).toBe(200);
    const token = login.body.data.token;
 
    // 3. 存取受保護的資源
    const profile = await request(baseUrl)
      .get('/users/me')
      .set('Authorization', `Bearer ${token}`);
    expect(profile.status).toBe(200);
    expect(profile.body.data.email).toBe('alice@example.com');
  });
});

E2E 的特性:

  • 測的是「整個 happy path / critical path」,不是每個 edge case
  • 通常跑在 staging 環境,不是本地
  • 執行慢,CI 可以放在 deployment 後而不是 PR check

什麼情況下 E2E 測試很重要

  • Auth 流程(register / login / token refresh)
  • 付款流程
  • 核心業務流程(電商的「下單」、SaaS 的「開 workspace」)

Coverage 數字怎麼看

Coverage 是測試品質的下限指標,不是上限。

100% coverage 不代表你的 code 沒有 bug——它只代表每一行都被執行到了,不代表每一個邊界條件都被測到了。

實務的 coverage 設定

// jest.config.js
{
  "coverageThreshold": {
    "global": {
      "branches": 80,
      "functions": 85,
      "lines": 85
    },
    // 核心業務邏輯要求更高
    "./src/app/services/": {
      "branches": 90,
      "lines": 90
    }
  }
}

Service 層(有業務邏輯)要求高 coverage;Repository 層(只是 ORM 呼叫)不需要強制高 coverage——讓 integration test 去驗。


各框架的測試工具

框架Unit TestIntegration TestE2E
ExpressJest + ts-jestJest + supertestPlaywright / Cypress
FastAPIpytestpytest + httpx(TestClientPlaywright
NestJSJest(內建)Jest + @nestjs/testingPlaywright
Spring BootJUnit 5 + MockitoSpring Boot Test + MockMvcSelenium / Playwright
LaravelPHPUnitPHPUnit + RefreshDatabaseDusk
RailsRSpec / MinitestRSpec + FactoryBot + DatabaseCleanerCapybara

FastAPI 的 TestClient 值得特別提:它讓 integration test 可以直接 from main import app 然後用 TestClient(app) 打,不需要真的起 HTTP server,速度比 supertest 快一些。


延伸閱讀