為什麼要分層
一個很常見的問題:「我是不是每個 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(truncate 或 sync({ 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 Test | Integration Test | E2E |
|---|---|---|---|
| Express | Jest + ts-jest | Jest + supertest | Playwright / Cypress |
| FastAPI | pytest | pytest + httpx(TestClient) | Playwright |
| NestJS | Jest(內建) | Jest + @nestjs/testing | Playwright |
| Spring Boot | JUnit 5 + Mockito | Spring Boot Test + MockMvc | Selenium / Playwright |
| Laravel | PHPUnit | PHPUnit + RefreshDatabase | Dusk |
| Rails | RSpec / Minitest | RSpec + FactoryBot + DatabaseCleaner | Capybara |
FastAPI 的 TestClient 值得特別提:它讓 integration test 可以直接 from main import app 然後用 TestClient(app) 打,不需要真的起 HTTP server,速度比 supertest 快一些。
