Skip to content

Latest commit

 

History

History
1174 lines (907 loc) · 30.3 KB

02-testing.md

File metadata and controls

1174 lines (907 loc) · 30.3 KB

测试

第七部分 - 第 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,速度快

1. Vitest 详解

1.1 安装和配置

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"]
  }
}

1.2 describe / it / expect 语法

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 所有测试后执行一次

1.3 测试生命周期

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');
  });
});

1.4 断言 API

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);

2. Mock 和 Stub

2.1 vi.fn() — 创建 Mock 函数

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);
  });
});

2.2 vi.mock() — 模块 Mock

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());
    }
}

2.3 vi.spyOn() — 监控调用

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();
  });
});

3. TypeScript 中的测试类型

3.1 测试中的类型断言

在测试中,经常需要处理类型。以下是常见模式:

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();
  });
});

3.2 Mock 类型定义

使用 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' });

3.3 辅助类型工具

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),
  };
}

4. 测试覆盖率

4.1 配置覆盖率

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,
      },
    },
  },
});

4.2 运行覆盖率

# 运行测试并生成覆盖率报告
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/ 目录下,可以浏览器打开查看详细的行级覆盖情况。


5. 实战示例:测试 Express API

以下是一个完整的测试示例,测试一个笔记 API 的各个层次:

5.1 工具函数测试

// 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('');
  });
});

5.2 中间件测试

// 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();
  });
});

5.3 集成测试(supertest)

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);
    });
  });
});

6. E2E 测试简介

6.1 Playwright 基础概念

Playwright 是微软开发的端到端(E2E)测试框架。它可以自动化浏览器操作,模拟真实用户行为。

Java 对比:类似 Selenium,但 API 更现代、更稳定。

概念 Selenium Playwright
浏览器驱动 WebDriver(需要下载驱动) 内置(自动下载浏览器)
等待机制 显式/隐式等待 自动等待(auto-wait)
选择器 CSS / XPath CSS / XPath / text / role
并行执行 需要 Grid 内置支持

6.2 安装

npm install -D @playwright/test

# 安装浏览器(Chromium、Firefox、WebKit)
npx playwright install

6.3 基本用法

// 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();
  });
});

6.4 Playwright 配置

// 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

7. 测试最佳实践

7.1 测试金字塔

        /  E2E  \          少量:验证关键用户流程
       /----------\
      / 集成测试   \        中量:验证模块间协作
     /--------------\
    /   单元测试     \      大量:验证单个函数/模块
   /------------------\
测试类型 数量 速度 维护成本 典型工具
单元测试 多 快 低 Vitest
集成测试 中 中 中 Vitest + supertest
E2E 测试 少 慢 高 Playwright

7.2 测试命名规范

推荐使用"应该..."(should)或者行为描述的命名方式:

// 推荐:描述行为
describe('UserService', () => {
  describe('register', () => {
    it('应该创建用户并返回 token', () => { /* ... */ });
    it('邮箱已存在时应该抛出 ConflictError', () => { /* ... */ });
    it('密码不符合规则时应该抛出 ValidationError', () => { /* ... */ });
  });
});

// 不推荐:描述实现
describe('UserService', () => {
  it('test register', () => { /* ... */ });
  it('test error', () => { /* ... */ });
});

7.3 AAA 模式

每个测试遵循 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 部署,将前后端项目整合到一起。