公司动态

软件测试基础:从单元测试到E2E的完整实践指南

📅 2026/8/6 5:08:14
软件测试基础:从单元测试到E2E的完整实践指南
1. 项目概述为什么“Test Module基础”是每个开发者的必修课在软件开发的日常里我们每天都在和代码打交道。写新功能、修复Bug、重构旧逻辑……每一次代码的变更都像是一次小心翼翼的“手术”。你如何确保这次“手术”没有引入新的问题如何保证昨天还能正常登录的用户今天不会因为你的一个“优化”而卡在登录界面答案就藏在“Test Module基础”里。这不仅仅是写几个测试函数那么简单它是一套保障代码质量、提升开发效率、甚至改变团队协作模式的工程实践。我见过太多项目初期为了赶进度而忽视测试结果在后期陷入了“修改一个Bug引入两个新Bug”的恶性循环维护成本指数级上升。因此无论你是刚入门的新手还是有一定经验的开发者系统性地掌握测试模块的基础都是你从“代码编写者”迈向“软件工程师”的关键一步。它能让你交付的代码更可靠让你在团队中更受信任也能让你在深夜被紧急告警电话叫醒的次数显著减少。2. 测试模块的核心价值与设计哲学2.1 超越“找Bug”测试的四大核心价值很多人对测试的理解停留在“发现程序错误”的层面这其实大大低估了它的价值。一套完善的测试体系至少为项目和团队带来四个维度的收益第一是充当“活文档”与设计验证器。测试代码尤其是单元测试是对业务逻辑最精确、最不会过时的描述。当你接手一段陌生的代码时阅读其测试用例往往比阅读注释或文档更能快速理解这段代码的设计意图和边界条件。同时编写测试的过程会迫使你思考模块的接口是否清晰、职责是否单一这本身就是一种驱动更好软件设计的力量。第二是提供重构的“安全网”。这是测试对开发者个人最大的价值。没有测试覆盖的代码重构起来如同在黑暗中拆解一枚炸弹心惊胆战。而有了高覆盖率的测试套件你可以大胆地调整内部实现只要所有测试用例都能通过你就有极高的信心保证外部行为没有改变。这种安全感是提升代码质量、进行持续优化的基础。第三是支持可持续的团队协作与集成。在多人协作的项目中测试是保证集成顺利进行的守门员。通过持续集成CI系统自动运行测试可以快速捕获因代码合并引入的回归缺陷避免问题流入主干分支甚至生产环境。它建立了一种“契约”任何人提交的代码都不能破坏已有的功能。第四是辅助设计与调试。测试驱动开发TDD要求先写测试再写实现。这能帮你厘清需求聚焦于接口而非实现细节。而在调试时一个能够稳定复现问题的测试用例是定位问题根源的最高效工具。2.2 测试金字塔构建健康测试策略的蓝图如何组织不同类型的测试测试金字塔是一个经典且实用的模型。它形象地告诉我们测试投入的分布应该像一座金字塔单元测试底层最多针对最小的可测试单元通常是函数或类进行测试。它们运行速度极快毫秒级隔离性好是测试的基石。目标应是高覆盖率如行覆盖率80%以上。集成测试中层适量测试多个模块之间的交互是否正确。例如测试服务层与数据库的交互或微服务之间的API调用。它们比单元测试慢但能发现接口适配和数据流问题。端到端测试顶层最少模拟真实用户操作测试整个应用流程。例如通过浏览器自动化工具测试从登录到完成一个订单的全过程。这类测试运行最慢、最脆弱但能验证整个系统是否协同工作。一个健康的项目单元测试应该数量最多运行最快集成测试次之端到端测试则只覆盖最关键的核心业务流程。很多团队犯的错误是金字塔倒置——写了大量沉重且不稳定的端到端测试而忽略了轻快可靠的单元测试导致测试套件运行缓慢、经常失败最终大家都不愿意运行测试。注意测试覆盖率只是一个参考指标而非终极目标。盲目追求100%覆盖率可能导致编写大量无意义的测试。更重要的是测试用例的质量——是否覆盖了正常路径、边界条件和异常情况。3. 核心测试类型详解与工具选型3.1 单元测试专注、快速与隔离单元测试的核心原则是“隔离”。被测试的单元应该与其依赖如数据库、网络、文件系统、其他复杂类隔离开。我们通过“测试替身”来实现这种隔离。常用测试替身类型Mock模拟对象用于验证交互行为。例如验证某个方法是否被以特定的参数调用了一次。当你关心“对象是否被正确调用”时用Mock。Stub桩为被测对象提供预设的间接输入。例如当被测函数调用一个外部服务接口时Stub可以硬编码返回一个成功或失败的响应而无需真正发起网络请求。Fake伪造对象一个轻量级的、可工作的实现用于替代真实的重型依赖。例如用一个基于内存的Fake Repository替代真实的数据库Repository用于测试。工具选型以JavaScript/TypeScript生态为例测试运行器Jest是目前最主流的选择。它开箱即用内置了断言库、Mock功能和覆盖率报告配置简单生态丰富。断言库Jest内置的断言已足够强大。如果需要更贴近自然语言的断言可以考虑Chai。Mock库Jest内置的Mock功能非常完善。对于更复杂的场景Sinon.js是一个历史悠久且功能强大的独立库。一个单元测试的实操示例假设我们有一个简单的UserService其中有一个register方法它依赖于一个UserRepository来保存用户。// 被测服务 class UserService { constructor(private userRepo: UserRepository) {} async register(username: string, password: string): PromiseUser { if (await this.userRepo.findByUsername(username)) { throw new Error(Username already exists); } const hashedPassword this.hashPassword(password); return this.userRepo.create({ username, password: hashedPassword }); } private hashPassword(pwd: string): string { /* ... */ } }// 对应的单元测试 import { UserService } from ./UserService; import { UserRepository } from ./UserRepository; // 使用Jest进行测试 describe(UserService, () { let userService: UserService; let mockUserRepo: jest.MockedUserRepository; // 声明一个Mock对象 beforeEach(() { // 创建UserRepository的Mock实例 mockUserRepo { findByUsername: jest.fn(), create: jest.fn(), } as jest.MockedUserRepository; // 类型断言 userService new UserService(mockUserRepo); }); it(should register a new user successfully, async () { // 1. 准备阶段 (Arrange) const username testUser; const password testPass; mockUserRepo.findByUsername.mockResolvedValue(null); // Stub: 模拟用户不存在 const mockUser { id: 1, username, password: hashed }; mockUserRepo.create.mockResolvedValue(mockUser); // Stub: 模拟创建成功 // 2. 执行阶段 (Act) const result await userService.register(username, password); // 3. 断言阶段 (Assert) expect(mockUserRepo.findByUsername).toHaveBeenCalledWith(username); // 验证调用 expect(mockUserRepo.create).toHaveBeenCalledWith( expect.objectContaining({ username }) // 验证部分参数 ); expect(result).toEqual(mockUser); // 验证返回结果 }); it(should throw error if username already exists, async () { // Arrange const existingUser { id: 1, username: existing, password: xxx }; mockUserRepo.findByUsername.mockResolvedValue(existingUser); // Stub: 模拟用户已存在 // Act Assert await expect(userService.register(existing, anypass)) .rejects .toThrow(Username already exists); expect(mockUserRepo.create).not.toHaveBeenCalled(); // 验证未调用创建方法 }); });实操心得在编写单元测试时我习惯遵循Arrange-Act-Assert (AAA)模式这能让测试结构非常清晰。另外测试的命名很重要一个好的测试名应该能清晰地表达它的意图例如it(should ... when ...)的格式就很好用。避免在测试中出现复杂的逻辑测试代码本身应该简单到一眼就能看懂。3.2 集成测试验证模块间的契约当单元测试保证每个零件没问题后我们需要验证这些零件组装起来是否能工作。这就是集成测试的职责。测试重点数据库集成测试ORM映射、查询语句、事务处理是否正确。API集成测试控制器Controller层验证HTTP请求和响应包括状态码、响应体、头部信息等。外部服务集成测试与第三方API如支付、短信的交互通常需要使用Mock Server来模拟第三方。工具与实操以Node.js API测试为例我们可以使用Supertest来测试Express或Koa应用。import request from supertest; import app from ../app; // 你的Express应用实例 import { setupDatabase, tearDownDatabase } from ./test-utils; // 测试用的数据库工具 describe(User API Integration Tests, () { beforeAll(async () { await setupDatabase(); // 测试前搭建测试数据库如使用一个独立的测试库 }); afterAll(async () { await tearDownDatabase(); // 测试后清理测试数据库 }); it(POST /api/users should create a new user, async () { // Arrange const newUser { username: apiTestUser, password: apiTestPass }; // Act const response await request(app) .post(/api/users) .send(newUser) .set(Accept, application/json); // Assert expect(response.statusCode).toBe(201); expect(response.body).toHaveProperty(id); expect(response.body.username).toBe(newUser.username); // 注意不应该返回密码即使是哈希值 expect(response.body).not.toHaveProperty(password); }); it(POST /api/users should return 400 for duplicate username, async () { // 先创建一个用户 await request(app).post(/api/users).send({ username: duplicate, password: pwd }); // 尝试用相同用户名再创建 const response await request(app) .post(/api/users) .send({ username: duplicate, password: anotherpwd }); expect(response.statusCode).toBe(400); expect(response.body.error).toContain(already exists); }); });注意事项集成测试的关键是测试环境隔离。务必使用一个独立的测试数据库并在每次测试前后进行清理或使用事务回滚确保测试之间不会相互影响。我通常会在测试启动时运行数据库迁移脚本构建出与生产环境一致的Schema。3.3 端到端测试模拟真实用户旅程端到端测试是测试金字塔的顶端它从一个用户的角度出发测试整个系统是否工作。对于Web应用这意味着要控制一个真实的浏览器。工具选型Playwright目前社区势头最猛的工具由微软开发。支持Chromium、Firefox、WebKit三大浏览器引擎API设计现代自动等待机制做得好录屏和追踪功能强大。Cypress另一个非常流行的选择特点是测试运行在浏览器内部调试体验极佳。但对浏览器类型和多标签页的支持有一定限制。Selenium老牌工具支持语言和浏览器最广但配置相对复杂编写稳定测试的难度较高。Playwright实操片段假设测试一个登录流程。import { test, expect } from playwright/test; test(user can log in and see the dashboard, async ({ page }) { // 1. 导航到登录页 await page.goto(https://your-app.com/login); // 2. 填写表单并提交 await page.fill(input[nameusername], testuser); await page.fill(input[namepassword], securepassword123); await page.click(button[typesubmit]); // 3. 等待导航并验证结果 // Playwright会自动等待大多数操作这里我们显式等待URL变化 await page.waitForURL(**/dashboard); // 4. 断言登录成功后的页面元素 await expect(page.locator(h1)).toHaveText(Welcome, testuser!); await expect(page.getByRole(link, { name: Logout })).toBeVisible(); }); test(login fails with wrong password, async ({ page }) { await page.goto(https://your-app.com/login); await page.fill(input[nameusername], testuser); await page.fill(input[namepassword], wrongpassword); await page.click(button[typesubmit]); // 应该停留在登录页并显示错误信息 await expect(page).toHaveURL(https://your-app.com/login); await expect(page.locator(.alert-error)).toContainText(Invalid credentials); });避坑指南E2E测试最让人头疼的就是“脆弱性”——页面一个无关紧要的CSS类名改了就可能导致测试失败。为此我总结了几个原则使用面向用户的定位器优先使用getByRole,getByText,getByLabel而不是脆弱的CSS选择器如.btn-primary:nth-child(2)。充分利用自动等待Playwright和Cypress都有良好的自动等待机制除非必要不要使用硬编码的page.waitForTimeout(5000)。测试关键路径只对最重要的、不常变动的核心业务流程编写E2E测试不要试图覆盖所有细节。管理测试数据确保测试从一个已知的状态开始。可以通过API在测试前创建数据测试后清理。4. 测试环境的搭建与配置实战4.1 项目初始化与测试框架配置一个清晰的测试目录结构能极大提升维护效率。我推荐的结构如下your-project/ ├── src/ │ ├── services/ │ │ ├── UserService.ts │ │ └── UserService.test.ts # 单元测试紧邻源文件 │ ├── controllers/ │ │ └── userController.ts │ └── app.ts ├── tests/ │ ├── integration/ # 集成测试 │ │ └── userApi.test.ts │ ├── e2e/ # 端到端测试 │ │ └── login-flow.spec.ts │ └── fixtures/ # 测试夹具共享的测试数据 │ └── users.ts ├── jest.config.js # Jest配置 ├── playwright.config.ts # Playwright配置 └── package.jsonJest基础配置 (jest.config.js):module.exports { preset: ts-jest, // 如果你用TypeScript testEnvironment: node, // 测试环境 roots: [rootDir/src], // 在哪里找测试文件 testMatch: [**/*.test.ts, **/*.spec.ts], // 测试文件匹配模式 collectCoverageFrom: [ // 收集覆盖率的源文件 src/**/*.{ts,js}, !src/**/*.d.ts, // 排除类型声明文件 !src/index.ts, // 排除入口文件 ], coverageThreshold: { // 覆盖率阈值可根据项目阶段调整 global: { branches: 70, functions: 70, lines: 70, statements: 70, }, }, };在package.json中添加脚本{ scripts: { test: jest, test:watch: jest --watch, test:coverage: jest --coverage, test:integration: jest tests/integration --config jest.integration.config.js, test:e2e: playwright test } }4.2 测试数据管理与数据库隔离这是集成测试中最容易出问题的一环。核心目标是测试独立、可重复、不影响生产数据。方案一使用独立的测试数据库这是最干净的方法。在运行测试套件前连接到一个专门用于测试的数据库实例可以是本地启动的Docker容器也可以是云上的一个独立实例。// test-utils.ts import { PrismaClient } from prisma/client; // 以Prisma ORM为例 import { execSync } from child_process; const testDatabaseName test_db_${process.env.JEST_WORKER_ID || default}; let prisma: PrismaClient; export async function setupDatabase() { // 1. 创建或重置一个独立的测试数据库 // 这里假设你有一个主数据库连接用于管理 const adminPrisma new PrismaClient(); await adminPrisma.$executeRawUnsafe(DROP DATABASE IF EXISTS ${testDatabaseName}); await adminPrisma.$executeRawUnsafe(CREATE DATABASE ${testDatabaseName}); await adminPrisma.$disconnect(); // 2. 生成指向测试数据库的Prisma Client process.env.DATABASE_URL postgresql://user:passlocalhost:5432/${testDatabaseName}; prisma new PrismaClient(); // 3. 运行迁移创建表结构 execSync(npx prisma db push --accept-data-loss, { stdio: inherit }); } export async function tearDownDatabase() { if (prisma) { await prisma.$disconnect(); } // 可选删除测试数据库 } export function getTestPrismaClient() { if (!prisma) { throw new Error(Test database not set up. Call setupDatabase first.); } return prisma; }方案二使用事务回滚更轻量如果觉得管理独立数据库实例太重量级可以在每个测试用例中开启一个事务并在测试结束后回滚。import { PrismaClient } from prisma/client; describe(UserService with Transaction, () { let prisma: PrismaClient; let transaction: any; // 用于存储事务实例 beforeEach(async () { prisma new PrismaClient(); // 开始一个事务并返回一个在事务内操作的Prisma Client实例 transaction await prisma.$transaction(async (tx) { // 在这个回调里tx就是事务client // 你的服务层应该接收这个tx作为数据库依赖 return tx; }); // 注意这里需要将你的服务实例化并注入transaction作为repo的依赖 }); afterEach(async () { // 由于是事务实际上不需要显式回滚测试结束连接关闭即可。 // 但更稳妥的做法是在测试逻辑中不提交事务。 await prisma.$disconnect(); }); it(should work in transaction, async () { const userService new UserService(transaction.user); // ... 你的测试逻辑 // 测试结束后事务会自动回滚数据库状态不变 }); });提示事务回滚方案需要注意有些操作如创建数据库序列、某些DDL语句在事务中可能不被支持。对于大多数CRUD操作的测试这是一个非常高效的方案。4.3 模拟外部依赖与API对于支付网关、短信服务、邮件服务等外部HTTP API绝对不能在测试中真实调用。我们使用“Mock Server”来模拟它们。使用nock库Node.jsnock可以直接拦截指定URL的HTTP请求并返回预设的响应。import nock from nock; import { PaymentService } from ./PaymentService; describe(PaymentService with external API, () { afterEach(() { nock.cleanAll(); // 每个测试后清理Mock }); it(should handle successful payment, async () { // 1. 拦截对支付网关的请求 const mockResponse { transactionId: tx_123, status: succeeded }; nock(https://api.payment-gateway.com) .post(/v1/charges) .reply(200, mockResponse); // 模拟成功响应 // 2. 执行测试 const service new PaymentService(); const result await service.charge(1000, tok_abc); // 3. 断言 expect(result.success).toBe(true); expect(result.transactionId).toBe(tx_123); }); it(should handle payment gateway failure, async () { // 模拟网关返回500错误 nock(https://api.payment-gateway.com) .post(/v1/charges) .reply(500, { error: Internal Server Error }); const service new PaymentService(); await expect(service.charge(1000, tok_abc)) .rejects .toThrow(Payment gateway unavailable); }); });使用 Mock Service Worker (MSW)MSW是一个更强大的库它可以在网络层面拦截请求不仅适用于Node.js环境也适用于浏览器如测试React/Vue组件时。它更像是声明了一套“网络行为规则”。// 在测试设置文件中 import { setupServer } from msw/node; import { rest } from msw; const server setupServer( rest.post(https://api.payment-gateway.com/v1/charges, (req, res, ctx) { return res( ctx.status(200), ctx.json({ transactionId: msw_tx_123, status: succeeded }) ); }), rest.get(https://api.example.com/user/:id, (req, res, ctx) { const { id } req.params; return res( ctx.json({ id, name: Mocked User ${id} }) ); }) ); // 在测试生命周期中启动和关闭服务器 beforeAll(() server.listen()); afterEach(() server.resetHandlers()); afterAll(() server.close());5. 编写高质量测试的进阶模式与技巧5.1 测试驱动开发入门与实践测试驱动开发是一种“先写测试后写实现”的开发方式。它的循环被称为“红-绿-重构”红编写一个失败的测试描述你期望的功能。绿编写最少量的代码让这个测试通过。重构在测试通过的保护下改进代码的设计和结构。TDD实战实现一个简单的字符串计算器需求一个add函数接收一个字符串返回所有数字的和。字符串中以逗号分隔数字。第一步红我们先写一个注定失败的测试。// calculator.test.ts import { add } from ./calculator; describe(String Calculator, () { it(should return 0 for an empty string, () { expect(add()).toBe(0); }); });运行测试失败红因为add函数还不存在。第二步绿编写最简单的实现让测试通过。// calculator.ts export function add(numbers: string): number { return 0; // 最简单的实现能通过第一个测试 }运行测试通过绿。第三步增加新需求红增加一个测试输入单个数字应返回该数字。it(should return the number itself for a single number, () { expect(add(5)).toBe(5); });运行测试失败红因为我们的实现总是返回0。第四步绿修改实现通过新测试。export function add(numbers: string): number { if (numbers ) return 0; return parseInt(numbers, 10); // 现在能处理单个数字了 }运行所有测试通过绿。第五步继续循环不断添加新的测试用例如1,2返回3处理换行符1\n2,3并逐步修改实现直到满足所有需求。在这个过程中你会自然地被驱动去思考函数接口、边界情况负数怎么办空字符串怎么办从而得到设计良好、经过充分测试的代码。TDD的心得刚开始实践TDD会觉得慢甚至有点“反直觉”。但长期坚持下来它会带来巨大的好处你的代码覆盖率天然就高你对需求的理解更深刻而且你几乎不会写出用不到的“过度设计”的代码。我建议从小的工具函数或算法题开始练习TDD感受它带来的节奏感和安全感。5.2 测试模式Given-When-Then与表格驱动测试Given-When-Then (GWT)是一种描述测试用例的结构化方式让测试意图更清晰。Given设定测试的初始状态和前提条件。When描述所执行的操作或事件。Then断言期望的结果。前面的AAA模式Arrange-Act-Assert与GWT异曲同工。在写测试时用注释标出这三个部分对阅读者非常友好。表格驱动测试则适用于参数化测试即用多组输入输出数据测试同一个逻辑。describe(String Calculator - Parameterized Tests, () { // 使用一个数组定义多组测试数据 const testCases [ { input: , expected: 0, description: empty string }, { input: 5, expected: 5, description: single number }, { input: 1,2, expected: 3, description: two numbers }, { input: 1\n2,3, expected: 6, description: newline delimiter }, { input: //;\n1;2, expected: 3, description: custom delimiter }, ]; testCases.forEach(({ input, expected, description }) { it(should return ${expected} for input ${input} (${description}), () { // Given (Arrange) - 这里前提条件简单就是输入参数 // When (Act) const result add(input); // Then (Assert) expect(result).toBe(expected); }); }); });这种方式避免了为每个类似用例重复编写it块让测试代码更简洁也更容易添加新的测试用例。5.3 测试的可靠性与避免“脆弱测试”“脆弱测试”是指那些很容易失败但失败原因与代码功能无关的测试。比如一个测试依赖当前时间、一个未排序的列表顺序、或者一个第三方服务的实时数据。让测试更可靠的技巧控制时间避免使用new Date()或Date.now()。使用依赖注入传入一个可以模拟的“时钟”。// 不好的做法 function isDiscountActive() { return new Date() someExpiryDate; } // 好的做法 function isDiscountActive(now: Date) { return now someExpiryDate; } // 测试时可以传入一个固定的时间 test(discount is active before expiry, () { const fixedDate new Date(2023-12-25); expect(isDiscountActive(fixedDate, expiryDate)).toBe(true); });避免测试实现细节测试应该关注“行为”输出是什么而不是“实现”内部怎么做的。例如不要断言一个内部私有方法被调用了多少次除非这是契约的一部分。否则一旦你重构内部实现测试就会毫无意义地失败。使用确定性的数据测试数据应该是固定的、可预测的。避免使用随机数如果业务逻辑需要随机性可以注入一个随机数生成器并在测试中固定其种子。等待但要有超时和明确条件在E2E或集成测试中等待元素出现时不要用固定的sleep。使用工具提供的等待条件如waitForSelector,waitForFunction并设置合理的超时时间。6. 将测试集成到开发工作流6.1 Git Hooks提交前的自动检查使用husky和lint-staged可以在代码提交前自动运行测试防止有问题的代码进入仓库。// package.json { scripts: { test: jest, test:related: jest --findRelatedTests // 仅测试与暂存文件相关的用例 }, devDependencies: { husky: ^8.0.0, lint-staged: ^13.0.0 }, lint-staged: { src/**/*.{ts,js}: [ eslint --fix, // 先运行代码检查 jest --bail --findRelatedTests // 再运行相关测试--bail表示一个失败就停止 ] } }# 安装husky并设置pre-commit钩子 npx husky install npx husky add .husky/pre-commit npx lint-staged这样每次执行git commit时只会对你本次修改的文件运行相关的测试速度很快又能保证提交的代码不会破坏现有功能。6.2 持续集成中的测试策略在CI/CD流水线如GitHub Actions, GitLab CI, Jenkins中测试通常分阶段运行快速反馈阶段运行单元测试和静态检查ESLint, TypeScript编译。这阶段必须快通常在几分钟内完成。集成测试阶段运行需要外部依赖如数据库的集成测试。这阶段可以并行化。端到端测试阶段运行耗时较长的E2E测试。可以考虑只在对主分支如main的合并请求中运行或者每天定时运行。GitHub Actions配置示例 (.github/workflows/test.yml)name: CI Tests on: [push, pull_request] jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkoutv3 - uses: actions/setup-nodev3 with: { node-version: 18 } - run: npm ci - run: npm run test:coverage # 运行单元测试并生成覆盖率报告 - name: Upload coverage uses: codecov/codecov-actionv3 # 可选上传覆盖率报告到Codecov integration-test: runs-on: ubuntu-latest needs: unit-test # 依赖单元测试阶段成功 services: postgres: image: postgres:15 env: { POSTGRES_PASSWORD: postgres } options: - --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkoutv3 - uses: actions/setup-nodev3 - run: npm ci - run: npm run test:integration env: DATABASE_URL: postgresql://postgres:postgreslocalhost:5432/postgres6.3 测试覆盖率有用的度量而非圣杯测试覆盖率工具如Jest的--coverage Istanbul能生成报告告诉你代码的哪些部分被测试执行过。如何看待覆盖率行覆盖率最基本的指标但一行代码被执行过不代表它被正确测试了。分支覆盖率更重要。它衡量了代码中每个判断分支如if/else是否都被测试到。函数覆盖率每个函数是否被调用过。语句覆盖率类似于行覆盖率。我通常会在CI中设置一个最低覆盖率阈值比如80%作为质量门禁。但这只是一个底线。更重要的是查看覆盖率报告中的“未覆盖”部分思考这些代码为什么没被覆盖是因为它们不重要也许是死代码还是因为它们太难测需要补充测试切忌为了覆盖率而写测试。不要写那种只调用函数但不做任何有意义的断言的测试或者用一堆try-catch包裹起来防止测试失败的“假测试”。这样的测试除了让数字好看没有任何价值反而增加了维护成本。7. 常见问题排查与调试技巧7.1 测试失败排查清单当测试失败时不要慌张按照以下步骤排查是偶发失败还是稳定失败运行几次测试。如果是偶发Flaky Test问题可能出在异步操作、定时器、或外部依赖的不稳定性上。阅读错误信息Jest等框架会给出清晰的堆栈跟踪。找到错误最先出现在你的测试文件或源代码的哪一行。隔离问题单独运行这个失败的测试文件或用例jest path/to/specific.test.ts。检查Mock和Stub这是单元测试失败的常见原因。确认Mock对象的调用次数和参数是否符合预期Stub返回的数据是否正确检查测试数据状态对于集成测试确认数据库在测试前/后的状态是否符合预期。是不是上一个测试没有清理干净数据检查异步代码是否忘记了async/await或returnPromise在Jest中如果测试函数是async的确保使用了await或者返回Promise。使用调试器在测试代码中打上debugger语句然后用node --inspect-brk或 IDE的调试功能来逐步执行。7.2 调试异步测试与定时器异步测试和定时器setTimeout,setInterval是测试中的难点。Jest对定时器的模拟Jest提供了jest.useFakeTimers()来模拟定时器让你可以“快进”时间。describe(A function with timer, () { beforeEach(() { jest.useFakeTimers(); // 启用假定时器 }); afterEach(() { jest.useRealTimers(); // 恢复真实定时器 }); it(should call callback after 1 second, () { const callback jest.fn(); myFunctionThatUsesSetTimeout(callback, 1000); // 此时回调还未被调用 expect(callback).not.toHaveBeenCalled(); // “快进”1秒 jest.advanceTimersByTime(1000); // 现在回调应该被调用了 expect(callback).toHaveBeenCalledTimes(1); }); });处理Promise和setTimeout混合有时你会遇到在Promise中嵌套setTimeout的情况。Jest的假定时器默认不会自动推进由Promise排队的微任务。你可能需要结合jest.advanceTimersByTime和Promise.resolve()来推进。it(should handle promise with timeout, async () { const myAsyncFunc () new Promise(resolve { setTimeout(() resolve(done), 1000); }); const promise myAsyncFunc(); // 快进时间 jest.advanceTimersByTime(1000); // 等待Promise解决 await expect(promise).resolves.toBe(done); });7.3 处理“脆弱”的端到端测试E2E测试的脆弱性主要源于网络延迟、动画、动态内容加载等。以下技巧可以提升稳定性优先使用面向内容的定位器// 脆弱 await page.click(.container div:nth-child(2) button.btn-primary); // 健壮 await page.click(button, { hasText: Submit }); await page.getByRole(button, { name: Submit }).click();显式等待与重试Playwright的定位器自带自动等待和重试机制。但对于复杂条件可以使用page.waitForFunction。// 等待某个元素具有特定状态 await page.waitForFunction( () document.querySelector(.status).textContent.includes(Complete), { timeout: 10000 } // 10秒超时 );在CI中配置更长的超时和重试CI环境通常比本地慢。在Playwright配置中增加全局超时和重试次数。// playwright.config.ts import { defineConfig } from playwright/test; export default defineConfig({ timeout: 30000, // 每个测试30秒超时 retries: process.env.CI ? 2 : 0, // CI环境下失败重试2次 use: { actionTimeout: 10000, // 每个操作如click10秒超时 }, });录制与追踪当测试失败时Playwright可以自动录制视频、保存截图和追踪文件。这能极大帮助你复现和理解失败时的上下文。// 在配置中启用 export default defineConfig({ use: { trace: on-first-retry, // 第一次重试时保存追踪文件 screenshot: only-on-failure, // 仅失败时截图 video: retain-on-failure, // 失败时保留视频 }, });掌握测试模块的基础远不止学会几个API。它关乎你如何思考软件、如何协作、如何交付可靠的产品。从今天开始尝试为你写的下一个函数先写一个测试感受TDD的节奏在下次修复Bug时先写一个复现Bug的测试用例。当你把这些实践融入日常你会发现代码不再是易碎的玻璃而是由安全网保护的、可以自信修改和演进的资产。测试不是负担而是让你走得更快、更稳的翅膀。