第七部分 - 第 2 节 | 面向 Java 工程师的 TypeScript 学习指南
Java 工程师对测试一定不陌生——JUnit、Mockito、JaCoCo 是标配。TypeScript 生态有一套对应的工具:
| 用途 | Java 工具 | TypeScript/JS 工具 |
|---|---|---|
| 单元测试框架 | JUnit 5 | Vitest / Jest |
| Mock 框架 | Mockito | Vitest 内置(vi.fn / vi.mock) |
| 断言库 | JUnit Assertions / AssertJ | Vitest expect / chai |
| 测试覆盖率 | JaCoCo | c8 / istanbul |
| E2E 测试 | Selenium / Cypress | Playwright |
| HTTP 测试 | MockMvc / RestAssured | supertest |
本节重点介绍 Vitest——它是目前 TypeScript 生态中最推荐的测试框架,原因是:
- 原生支持 TypeScript(无需额外配置)
- 原生支持 ESM
- 与 Jest API 兼容(迁移成本低)
- 基于 Vite,速度快
npm install -D vitest创建 vitest.config.ts(可选,默认会读取 vite.config.ts):
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// 测试文件匹配模式
include: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
// 测试环境(node 或 jsdom)
environment: 'node',
// 全局 API(不需要每个文件 import)
globals: true,
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
},
},
});在 package.json 中添加脚本:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}如果开启了 globals: true,需要在 tsconfig.json 中添加类型:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Java JUnit 对比:
// Java JUnit 5
class CalculatorTest {
@Test
@DisplayName("加法应该返回两数之和")
void shouldAddTwoNumbers() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3));
}
}// TypeScript Vitest
import { describe, it, expect } from 'vitest';
import { add } from './calculator.js';
describe('Calculator', () => {
// describe 类似 @Nested class,用于分组
describe('add', () => {
// it 类似 @Test 方法
it('应该返回两数之和', () => {
expect(add(2, 3)).toBe(5);
});
it('应该处理负数', () => {
expect(add(-1, 1)).toBe(0);
});
it('应该处理零', () => {
expect(add(0, 0)).toBe(0);
});
});
});术语对照:
| Vitest | JUnit 5 | 说明 |
|---|---|---|
describe() |
@Nested class |
测试分组 |
it() / test() |
@Test method |
单个测试用例 |
expect() |
assert*() |
断言 |
beforeEach() |
@BeforeEach |
每个测试前执行 |
afterEach() |
@AfterEach |
每个测试后执行 |
beforeAll() |
@BeforeAll |
所有测试前执行一次 |
afterAll() |
@AfterAll |
所有测试后执行一次 |
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
describe('UserService', () => {
let db: Database;
// 所有测试开始前执行一次(类似 @BeforeAll)
beforeAll(async () => {
db = await Database.connect(':memory:');
await db.migrate();
});
// 每个测试前执行(类似 @BeforeEach)
beforeEach(async () => {
await db.clear(); // 清空数据
await db.seed(); // 插入测试数据
});
// 每个测试后执行
afterEach(() => {
// 清理副作用
});
// 所有测试结束后执行一次
afterAll(async () => {
await db.close();
});
it('应该创建用户', async () => {
const user = await createUser(db, { name: 'Alice', email: 'alice@test.com' });
expect(user.id).toBeDefined();
expect(user.name).toBe('Alice');
});
});Vitest 的断言 API 非常丰富,以下是常用的:
import { describe, it, expect } from 'vitest';
describe('断言示例', () => {
// ===== 基本断言 =====
it('相等性', () => {
expect(1 + 1).toBe(2); // 严格相等(===)
expect({ a: 1 }).toEqual({ a: 1 }); // 深度相等(Java 的 equals)
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(1).toBeDefined();
expect(true).toBeTruthy();
expect(0).toBeFalsy();
});
// ===== 数值断言 =====
it('数值比较', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数比较
});
// ===== 字符串断言 =====
it('字符串匹配', () => {
expect('hello world').toContain('world');
expect('hello world').toMatch(/^hello/);
});
// ===== 数组断言 =====
it('数组匹配', () => {
const arr = [1, 2, 3];
expect(arr).toContain(2);
expect(arr).toHaveLength(3);
expect(arr).toEqual(expect.arrayContaining([1, 3]));
});
// ===== 对象断言 =====
it('对象匹配', () => {
const user = { name: 'Alice', age: 25, email: 'alice@test.com' };
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('name', 'Alice');
expect(user).toMatchObject({ name: 'Alice', age: 25 });
expect(user).toEqual(expect.objectContaining({ name: 'Alice' }));
});
// ===== 异常断言 =====
it('异常处理', () => {
const throwError = () => { throw new Error('boom'); };
expect(throwError).toThrow();
expect(throwError).toThrow('boom');
expect(throwError).toThrow(Error);
expect(throwError).toThrow(/boom/);
});
// ===== 异步断言 =====
it('异步异常', async () => {
const asyncThrow = async () => { throw new Error('async boom'); };
await expect(asyncThrow()).rejects.toThrow('async boom');
});
// ===== 取反 =====
it('取反断言', () => {
expect(1).not.toBe(2);
expect([1, 2]).not.toContain(3);
});
});Java AssertJ 对比:
// Java AssertJ // Vitest
assertThat(list).hasSize(3); // expect(list).toHaveLength(3);
assertThat(list).contains(1, 3); // expect(list).toEqual(expect.arrayContaining([1, 3]));
assertThat(user).extracting("name") // expect(user).toHaveProperty("name", "Alice");
.isEqualTo("Alice");
assertThatThrownBy(() -> ...) // expect(() => ...).toThrow(Error);
.isInstanceOf(RuntimeException.class);vi.fn() 类似 Mockito 的 mock(),用于创建一个可追踪调用的函数:
import { describe, it, expect, vi } from 'vitest';
describe('vi.fn() 基础', () => {
it('追踪调用', () => {
// 创建 mock 函数(类似 Mockito.mock())
const mockFn = vi.fn();
mockFn('hello');
mockFn('world');
// 检查调用次数
expect(mockFn).toHaveBeenCalledTimes(2);
// 检查调用参数
expect(mockFn).toHaveBeenCalledWith('hello');
expect(mockFn).toHaveBeenLastCalledWith('world');
// 获取所有调用记录
expect(mockFn.mock.calls).toEqual([['hello'], ['world']]);
});
it('设置返回值', () => {
// 类似 Mockito.when(...).thenReturn(...)
const mockFn = vi.fn()
.mockReturnValue('default') // 默认返回值
.mockReturnValueOnce('first call') // 第一次调用返回
.mockReturnValueOnce('second call'); // 第二次调用返回
expect(mockFn()).toBe('first call');
expect(mockFn()).toBe('second call');
expect(mockFn()).toBe('default');
});
it('模拟异步函数', async () => {
// 类似 Mockito 返回 CompletableFuture
const mockAsync = vi.fn()
.mockResolvedValue({ id: 1, name: 'Alice' }) // 返回 Promise.resolve()
.mockResolvedValueOnce({ id: 2, name: 'Bob' }); // 第一次调用
const first = await mockAsync();
expect(first).toEqual({ id: 2, name: 'Bob' });
const second = await mockAsync();
expect(second).toEqual({ id: 1, name: 'Alice' });
});
it('自定义实现', () => {
// 类似 Mockito.when(...).thenAnswer(...)
const mockFn = vi.fn((x: number, y: number) => x + y);
expect(mockFn(2, 3)).toBe(5);
expect(mockFn).toHaveBeenCalledWith(2, 3);
});
});vi.mock() 可以 mock 整个模块,类似 Mockito 的 @Mock + @InjectMocks:
// src/services/user-service.ts
import { db } from '../db/client.js';
import { sendEmail } from '../utils/email.js';
export async function registerUser(name: string, email: string) {
const user = await db.user.create({ data: { name, email } });
await sendEmail(email, `Welcome, ${name}!`);
return user;
}// src/services/user-service.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock 整个模块(类似 Mockito @Mock 注解)
vi.mock('../db/client.js', () => ({
db: {
user: {
create: vi.fn(),
},
},
}));
vi.mock('../utils/email.js', () => ({
sendEmail: vi.fn(),
}));
// 导入被 mock 的模块
import { db } from '../db/client.js';
import { sendEmail } from '../utils/email.js';
import { registerUser } from './user-service.js';
describe('registerUser', () => {
beforeEach(() => {
vi.clearAllMocks(); // 清除所有 mock 状态
});
it('应该创建用户并发送邮件', async () => {
// 设置 mock 返回值(类似 Mockito.when().thenReturn())
const mockUser = { id: 1, name: 'Alice', email: 'alice@test.com' };
vi.mocked(db.user.create).mockResolvedValue(mockUser);
vi.mocked(sendEmail).mockResolvedValue(undefined);
// 执行
const result = await registerUser('Alice', 'alice@test.com');
// 验证返回值
expect(result).toEqual(mockUser);
// 验证调用(类似 Mockito.verify())
expect(db.user.create).toHaveBeenCalledWith({
data: { name: 'Alice', email: 'alice@test.com' },
});
expect(sendEmail).toHaveBeenCalledWith('alice@test.com', 'Welcome, Alice!');
});
it('数据库失败时应该抛出异常', async () => {
vi.mocked(db.user.create).mockRejectedValue(new Error('DB error'));
await expect(registerUser('Alice', 'alice@test.com'))
.rejects.toThrow('DB error');
// 数据库失败,邮件不应该被发送
expect(sendEmail).not.toHaveBeenCalled();
});
});Mockito 对比:
// Java Mockito
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository userRepo;
@Mock EmailService emailService;
@InjectMocks UserService userService;
@Test
void shouldRegisterUser() {
when(userRepo.save(any())).thenReturn(new User(1L, "Alice"));
User result = userService.register("Alice", "alice@test.com");
verify(userRepo).save(any());
verify(emailService).send(eq("alice@test.com"), anyString());
}
}vi.spyOn() 类似 Mockito 的 spy()——保留原始实现,但可以追踪调用:
import { describe, it, expect, vi } from 'vitest';
describe('vi.spyOn()', () => {
it('监控 console.log', () => {
// spy 不改变原始行为,只是监控
const consoleSpy = vi.spyOn(console, 'log');
console.log('hello');
expect(consoleSpy).toHaveBeenCalledWith('hello');
// 清理
consoleSpy.mockRestore();
});
it('替换实现', () => {
const obj = {
greet(name: string) {
return `Hello, ${name}!`;
},
};
// spy 并替换实现
const spy = vi.spyOn(obj, 'greet').mockReturnValue('Mocked!');
expect(obj.greet('Alice')).toBe('Mocked!');
expect(spy).toHaveBeenCalledWith('Alice');
// 恢复原始实现
spy.mockRestore();
expect(obj.greet('Alice')).toBe('Hello, Alice!');
});
it('监控 Date', () => {
// 固定时间(测试中常用)
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-01-01'));
expect(new Date().getFullYear()).toBe(2025);
vi.useRealTimers();
});
});在测试中,经常需要处理类型。以下是常见模式:
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
// 测试 Express 中间件
describe('authMiddleware', () => {
it('应该验证 token', () => {
// 创建部分 mock 对象(不需要实现完整的 Request 接口)
const mockReq = {
headers: {
authorization: 'Bearer valid-token',
},
} as unknown as Request;
const mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
} as unknown as Response;
const mockNext = vi.fn();
authMiddleware(mockReq, mockRes, mockNext);
expect(mockNext).toHaveBeenCalled();
});
});使用 vi.mocked() 来获得正确的 Mock 类型:
import { vi } from 'vitest';
import { getUser } from './user-repository.js';
vi.mock('./user-repository.js');
// vi.mocked() 将函数类型转换为 Mock 类型
// 等价于 Java 中 Mockito.when(getUser(anyLong())) 的类型推导
const mockedGetUser = vi.mocked(getUser);
// 现在 TypeScript 知道它是 Mock 函数,可以调用 .mockResolvedValue() 等
mockedGetUser.mockResolvedValue({ id: 1, name: 'Alice' });import type { Mock } from 'vitest';
// 为复杂 mock 定义类型
interface MockDatabase {
query: Mock<(sql: string) => Promise<unknown[]>>;
execute: Mock<(sql: string) => Promise<void>>;
close: Mock<() => Promise<void>>;
}
function createMockDatabase(): MockDatabase {
return {
query: vi.fn().mockResolvedValue([]),
execute: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}Vitest 内置覆盖率支持,使用 v8 或 istanbul 引擎:
# 安装覆盖率工具
npm install -D @vitest/coverage-v8在 vitest.config.ts 中配置:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
// 使用 v8 引擎(更快)
provider: 'v8',
// 报告格式
reporter: ['text', 'html', 'lcov'],
// 要统计覆盖率的文件
include: ['src/**/*.ts'],
// 排除的文件
exclude: [
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/**/index.ts', // 纯导出文件
'src/types/**', // 纯类型文件
],
// 覆盖率阈值
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});# 运行测试并生成覆盖率报告
npx vitest run --coverage输出示例:
✓ src/services/user-service.test.ts (3 tests)
✓ src/utils/validator.test.ts (8 tests)
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 87.5 | 83.3 | 90.0 | 87.5 |
services/ | |
user-service.ts | 85.0 | 80.0 | 100.0 | 85.0 |
utils/ | |
validator.ts | 90.0 | 86.7 | 80.0 | 90.0 |
--------------------|---------|----------|---------|---------|
Java 对比:这和 JaCoCo 的输出非常类似。HTML 报告在 coverage/ 目录下,可以浏览器打开查看详细的行级覆盖情况。
以下是一个完整的测试示例,测试一个笔记 API 的各个层次:
// src/utils/slug.ts
export function generateSlug(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}// src/utils/slug.test.ts
import { describe, it, expect } from 'vitest';
import { generateSlug } from './slug.js';
describe('generateSlug', () => {
it('应该将标题转为 URL 友好的格式', () => {
expect(generateSlug('Hello World')).toBe('hello-world');
});
it('应该处理特殊字符', () => {
expect(generateSlug('Hello, World! 2024')).toBe('hello-world-2024');
});
it('应该处理多个空格和连字符', () => {
expect(generateSlug('hello world---test')).toBe('hello-world-test');
});
it('应该移除首尾连字符', () => {
expect(generateSlug(' -hello- ')).toBe('hello');
});
it('应该处理空字符串', () => {
expect(generateSlug('')).toBe('');
});
});// src/middleware/auth.ts
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing token' });
return;
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET ?? 'secret');
(req as Record<string, unknown>).user = payload;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
}// src/middleware/auth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
// Mock jsonwebtoken 模块
vi.mock('jsonwebtoken', () => ({
default: {
verify: vi.fn(),
},
}));
import jwt from 'jsonwebtoken';
import { authMiddleware } from './auth.js';
describe('authMiddleware', () => {
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockNext: NextFunction;
beforeEach(() => {
vi.clearAllMocks();
mockReq = {
headers: {},
};
mockRes = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis(),
};
mockNext = vi.fn();
});
it('没有 Authorization header 时应返回 401', () => {
authMiddleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Missing token' });
expect(mockNext).not.toHaveBeenCalled();
});
it('Authorization 格式错误时应返回 401', () => {
mockReq.headers = { authorization: 'Invalid format' };
authMiddleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockNext).not.toHaveBeenCalled();
});
it('token 有效时应调用 next()', () => {
const mockPayload = { userId: 1, email: 'test@test.com' };
mockReq.headers = { authorization: 'Bearer valid-token' };
vi.mocked(jwt.verify).mockReturnValue(mockPayload as unknown as ReturnType<typeof jwt.verify>);
authMiddleware(mockReq as Request, mockRes as Response, mockNext);
expect(jwt.verify).toHaveBeenCalledWith('valid-token', expect.any(String));
expect(mockNext).toHaveBeenCalled();
expect((mockReq as Record<string, unknown>).user).toEqual(mockPayload);
});
it('token 无效时应返回 401', () => {
mockReq.headers = { authorization: 'Bearer invalid-token' };
vi.mocked(jwt.verify).mockImplementation(() => {
throw new Error('invalid token');
});
authMiddleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockRes.status).toHaveBeenCalledWith(401);
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Invalid token' });
expect(mockNext).not.toHaveBeenCalled();
});
});npm install -D supertest @types/supertest// src/app.ts
import express from 'express';
import { notesRouter } from './routes/notes.js';
export function createApp() {
const app = express();
app.use(express.json());
app.use('/api/notes', notesRouter);
return app;
}// src/integration/notes.test.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
import { setupTestDatabase, teardownTestDatabase, clearTestData } from './helpers.js';
describe('Notes API 集成测试', () => {
const app = createApp();
let authToken: string;
beforeAll(async () => {
await setupTestDatabase();
});
afterAll(async () => {
await teardownTestDatabase();
});
beforeEach(async () => {
await clearTestData();
});
// 注册并获取 token
async function getAuthToken(): Promise<string> {
const res = await request(app)
.post('/api/auth/register')
.send({ name: 'Test', email: 'test@test.com', password: 'Test1234!' });
return res.body.token as string;
}
describe('POST /api/notes', () => {
it('应该创建笔记', async () => {
authToken = await getAuthToken();
const res = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '测试笔记', content: '这是内容' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
title: '测试笔记',
content: '这是内容',
});
expect(res.body.id).toBeDefined();
});
it('未认证应返回 401', async () => {
const res = await request(app)
.post('/api/notes')
.send({ title: '测试', content: '内容' });
expect(res.status).toBe(401);
});
it('缺少标题应返回 400', async () => {
authToken = await getAuthToken();
const res = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${authToken}`)
.send({ content: '只有内容' });
expect(res.status).toBe(400);
});
});
describe('GET /api/notes', () => {
it('应该返回当前用户的笔记列表', async () => {
authToken = await getAuthToken();
// 先创建两条笔记
await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '笔记1', content: '内容1' });
await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '笔记2', content: '内容2' });
// 获取列表
const res = await request(app)
.get('/api/notes')
.set('Authorization', `Bearer ${authToken}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
});
it('不应返回其他用户的笔记', async () => {
// 用户 A 创建笔记
const tokenA = await getAuthToken();
await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${tokenA}`)
.send({ title: '用户A的笔记', content: '私密内容' });
// 用户 B 查询
const resB = await request(app)
.post('/api/auth/register')
.send({ name: 'UserB', email: 'b@test.com', password: 'Test1234!' });
const tokenB = resB.body.token as string;
const res = await request(app)
.get('/api/notes')
.set('Authorization', `Bearer ${tokenB}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(0);
});
});
describe('PUT /api/notes/:id', () => {
it('应该更新笔记', async () => {
authToken = await getAuthToken();
// 创建
const createRes = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '原标题', content: '原内容' });
const noteId = createRes.body.id as number;
// 更新
const updateRes = await request(app)
.put(`/api/notes/${noteId}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '新标题', content: '新内容' });
expect(updateRes.status).toBe(200);
expect(updateRes.body.title).toBe('新标题');
});
});
describe('DELETE /api/notes/:id', () => {
it('应该删除笔记', async () => {
authToken = await getAuthToken();
const createRes = await request(app)
.post('/api/notes')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: '待删除', content: '内容' });
const noteId = createRes.body.id as number;
const deleteRes = await request(app)
.delete(`/api/notes/${noteId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(deleteRes.status).toBe(204);
// 确认已删除
const getRes = await request(app)
.get(`/api/notes/${noteId}`)
.set('Authorization', `Bearer ${authToken}`);
expect(getRes.status).toBe(404);
});
});
});Playwright 是微软开发的端到端(E2E)测试框架。它可以自动化浏览器操作,模拟真实用户行为。
Java 对比:类似 Selenium,但 API 更现代、更稳定。
| 概念 | Selenium | Playwright |
|---|---|---|
| 浏览器驱动 | WebDriver(需要下载驱动) | 内置(自动下载浏览器) |
| 等待机制 | 显式/隐式等待 | 自动等待(auto-wait) |
| 选择器 | CSS / XPath | CSS / XPath / text / role |
| 并行执行 | 需要 Grid | 内置支持 |
npm install -D @playwright/test
# 安装浏览器(Chromium、Firefox、WebKit)
npx playwright install// e2e/notes-app.spec.ts
import { test, expect } from '@playwright/test';
test.describe('笔记应用', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5173');
});
test('应该显示登录页面', async ({ page }) => {
// 检查页面标题
await expect(page).toHaveTitle('笔记应用');
// 检查登录表单存在
await expect(page.getByRole('heading', { name: '登录' })).toBeVisible();
await expect(page.getByLabel('邮箱')).toBeVisible();
await expect(page.getByLabel('密码')).toBeVisible();
});
test('应该能登录并看到笔记列表', async ({ page }) => {
// 填写登录表单
await page.getByLabel('邮箱').fill('test@test.com');
await page.getByLabel('密码').fill('Test1234!');
await page.getByRole('button', { name: '登录' }).click();
// 等待跳转到笔记列表
await expect(page.getByRole('heading', { name: '我的笔记' })).toBeVisible();
});
test('应该能创建新笔记', async ({ page }) => {
// 先登录
await page.getByLabel('邮箱').fill('test@test.com');
await page.getByLabel('密码').fill('Test1234!');
await page.getByRole('button', { name: '登录' }).click();
// 点击新建按钮
await page.getByRole('button', { name: '新建笔记' }).click();
// 填写笔记
await page.getByLabel('标题').fill('E2E 测试笔记');
await page.getByLabel('内容').fill('这是通过 Playwright 创建的笔记');
await page.getByRole('button', { name: '保存' }).click();
// 验证笔记出现在列表中
await expect(page.getByText('E2E 测试笔记')).toBeVisible();
});
});// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30000,
retries: 1,
use: {
baseURL: 'http://localhost:5173',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
// 测试前启动开发服务器
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
],
});运行 E2E 测试:
# 运行所有 E2E 测试
npx playwright test
# 带界面运行(调试用)
npx playwright test --ui
# 查看测试报告
npx playwright show-report / E2E \ 少量:验证关键用户流程
/----------\
/ 集成测试 \ 中量:验证模块间协作
/--------------\
/ 单元测试 \ 大量:验证单个函数/模块
/------------------\
| 测试类型 | 数量 | 速度 | 维护成本 | 典型工具 |
|---|---|---|---|---|
| 单元测试 | 多 | 快 | 低 | Vitest |
| 集成测试 | 中 | 中 | 中 | Vitest + supertest |
| E2E 测试 | 少 | 慢 | 高 | Playwright |
推荐使用"应该..."(should)或者行为描述的命名方式:
// 推荐:描述行为
describe('UserService', () => {
describe('register', () => {
it('应该创建用户并返回 token', () => { /* ... */ });
it('邮箱已存在时应该抛出 ConflictError', () => { /* ... */ });
it('密码不符合规则时应该抛出 ValidationError', () => { /* ... */ });
});
});
// 不推荐:描述实现
describe('UserService', () => {
it('test register', () => { /* ... */ });
it('test error', () => { /* ... */ });
});每个测试遵循 Arrange-Act-Assert 模式(Java 工程师应该熟悉 Given-When-Then):
it('应该计算订单总价', () => {
// Arrange(准备)
const items = [
{ name: '商品A', price: 100, quantity: 2 },
{ name: '商品B', price: 50, quantity: 1 },
];
// Act(执行)
const total = calculateTotal(items);
// Assert(断言)
expect(total).toBe(250);
});| 工具 | 用途 | Java 对比 |
|---|---|---|
| Vitest | 单元/集成测试框架 | JUnit 5 |
| vi.fn() | Mock 函数 | Mockito.mock() |
| vi.mock() | Mock 模块 | @Mock + @InjectMocks |
| vi.spyOn() | 监控函数调用 | Mockito.spy() |
| supertest | HTTP 接口测试 | MockMvc / RestAssured |
| @vitest/coverage-v8 | 测试覆盖率 | JaCoCo |
| Playwright | 端到端测试 | Selenium |
推荐的 package.json 脚本:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
}
}下一节:我们将学习 Monorepo 架构和 Docker 部署,将前后端项目整合到一起。