测试与质量:单元/集成/E2E 测试、性能监控、错误追踪
掌握软件测试的完整体系:测试金字塔、单元测试、集成测试、E2E 测试、测试覆盖率、Mock、TDD,学会用 AI 生成测试,掌握性能监控和错误追踪工具,建立质量保障体系
- 理解测试金字塔:单元测试(多而快)、集成测试(中)、E2E 测试(少而慢),知道每层测什么
- 就抓单元测试的核心:隔离、Mock、断言、测试框架(Jest/Vitest/pytest),学会写好的单元测试
- 集成测试要测的是模块间怎么协作,还有API接口、数据库的测试。这里要注意用测试数据库,操作完用事务回滚。
- E2E测试就是模拟真实用户操作,用Playwright或者Cypress测核心用户流程
- 理解测试覆盖率的意义和局限,追求100%覆盖率是误区
- 学会用 AI 生成测试用例,理解 AI 生成测试的优势和局限
- 性能监控和错误追踪,就看这几类东西:APM工具(Sentry/New Relic/阿里云ARMS)、日志、指标、告警
- 了解 TDD 的理念和适用场景,知道什么时候用什么时候不用
为什么需要测试
没测试的开发就是死循环:改了A坏了B,上线后用户揪出Bug,再紧急修。测试的用处:防止回归——改完旧代码跑测试,就知道有没有破坏新功能;是最好的代码文档——看测试用例就懂这个函数该怎么用、该有什么行为;给你重构的底气——有测试就不怕改坏;逼你优化设计——难测的代码一般耦合高、依赖多、副作用大,写测试会倒逼你写得更合理;减少线上Bug——能揪出大部分明显问题,少出线上故障。测试不是额外活儿,是让你迭代更快更稳的投资。
没有测试 vs 有测试的开发循环
没有测试:改代码 → 手动测几个场景 → 觉得没问题 → 上线 → 用户发现 Bug → 紧急修复 → 又引入新 Bug → 循环有测试:改代码 → 跑自动化测试(几分钟)→ 全过 → 上线 → 有问题也能快速定位 → 加测试防止再犯 → 循环关键区别:有测试时,「有没有破坏现有功能」是机器自动验证的,不是靠人「觉得没问题」测试=让迭代更快更稳的投资,不是额外工作
测试金字塔:不同层测不同的东西
测试不是一种,而是分层次的。测试金字塔(Test Pyramid)是最经典的模型:底层是大量的单元测试(快、便宜、隔离),中层是集成测试(测模块协作),顶层是少量的 E2E 测试(慢、贵、测完整流程)。越往下测试越多越快,越往上测试越少越慢。
测试金字塔
/E2E 测试\ 顶层:少量(5-10%),慢,贵,测完整用户流程 /集成测试\ 中层:中量(15-20%),测模块间协作/API/数据库 /单元测试\ 底层:大量(70-80%),快,便宜,隔离,测单个函数/类各层测什么:单元测试:一个函数/一个类的逻辑,输入→输出,隔离外部依赖(Mock 掉数据库/API/时间)集成测试:多个模块协作,如 API 接口(发请求→查数据库→验证响应)、服务间调用E2E 测试:完整用户流程,如「注册→登录→创建课程→学习→答题→查看进度」,模拟真实用户操作反模式:冰淇淋蛋筒(Ice Cream Cone)——E2E 测试很多,单元测试很少 问题:E2E 慢(跑一次半小时)、不稳定(经常因为环境/时序问题 flaky)、难定位(失败了不知道哪层坏的) 正确:单元测试多而快,E2E 只测最核心的几个流程
单元测试:质量的基石
单元测试(Unit Test)测最小的代码单元——一个函数、一个类、一个组件。核心要求:隔离(只测这个单元的逻辑,外部依赖用 Mock/Stub 替代,不测数据库、不测网络、不测时间);快(毫秒级,几百个测试几秒跑完);独立(每个测试不依赖其他测试的结果,可独立运行,顺序无关);可重复(每次跑结果一样,不依赖外部状态)。
// 单元测试示例(Vitest/Jest,TypeScript)
// 被测函数:
// export function calculateDiscount(price: number, discountPercent: number): number {
// if (price < 0) throw new Error('价格不能为负');
// if (discountPercent < 0 || discountPercent > 100) throw new Error('折扣比例必须在0-100之间');
// const discount = price * (discountPercent / 100);
// return Math.round((price - discount) * 100) / 100;
// }
//
// 测试文件:
// import { describe, it, expect } from 'vitest';
// import { calculateDiscount } from './discount';
//
// describe('calculateDiscount', () => {
// // 正常场景
// it('应该正确计算折扣', () => {
// expect(calculateDiscount(100, 20)).toBe(80);
// expect(calculateDiscount(50, 50)).toBe(25);
// expect(calculateDiscount(99.99, 10)).toBe(89.99);
// });
//
// // 边界场景
// it('折扣为0时返回原价', () => {
// expect(calculateDiscount(100, 0)).toBe(100);
// });
// it('折扣为100时返回0', () => {
// expect(calculateDiscount(100, 100)).toBe(0);
// });
//
// // 异常场景
// it('价格为负时抛出错误', () => {
// expect(() => calculateDiscount(-10, 20)).toThrow('价格不能为负');
// });
// it('折扣比例超出范围时抛出错误', () => {
// expect(() => calculateDiscount(100, -5)).toThrow();
// expect(() => calculateDiscount(100, 110)).toThrow();
// });
//
// // 浮点数精度
// it('应该正确处理浮点数精度', () => {
// expect(calculateDiscount(0.1, 10)).toBe(0.09);
// });
// });
//
// 好的单元测试特点:
// - 测试名描述行为(「应该正确计算折扣」),不是「测试1」「test1」
// - 每个测试只测一个场景
// - 覆盖正常、边界、异常三类场景
// - 不依赖外部状态(不连数据库、不调API、不依赖当前时间)
// - 断言清晰(期望什么结果)Mock:隔离外部依赖
单元测试要隔离,外部依赖(数据库、API、文件系统、时间、随机数)要用 Mock(模拟)替代。Mock 的作用:控制外部依赖的返回值(让数据库返回特定数据,测试不同场景);避免真实调用(不连真实数据库/API,测试快、不产生副作用、不依赖网络);验证调用(确认函数确实调用了某个 API,传了正确的参数)。工具:Jest/Vitest 内置 mock、Sinon.js、Python 的 unittest.mock、pytest-mock。
// Mock 示例(Vitest)
// 被测代码:从 API 获取用户信息
// import axios from 'axios';
// export async function getUserInfo(userId: string) {
// const response = await axios.get(`/api/users/${userId}`);
// if (response.status !== 200) throw new Error('获取用户失败');
// return response.data;
// }
//
// 测试:Mock 掉 axios,不真实发请求
// import { describe, it, expect, vi } from 'vitest';
// import axios from 'axios';
// import { getUserInfo } from './user';
//
// vi.mock('axios'); // Mock 整个 axios 模块
//
// describe('getUserInfo', () => {
// it('应该返回用户信息', async () => {
// // 设置 Mock 的返回值
// (axios.get as any).mockResolvedValue({ status: 200, data: { id: '1', name: '张三' } });
//
// const user = await getUserInfo('1');
//
// expect(user).toEqual({ id: '1', name: '张三' });
// // 验证调用了正确的 URL
// expect(axios.get).toHaveBeenCalledWith('/api/users/1');
// });
//
// it('API 返回非200时抛出错误', async () => {
// (axios.get as any).mockResolvedValue({ status: 404 });
// await expect(getUserInfo('999')).rejects.toThrow('获取用户失败');
// });
// });测试名是「应该...」的行为描述,不是「测试函数1」;每个测试只测一个行为,一个断言为主(可以多个相关断言);Arrange-Act-Assert 三段式(准备数据→执行操作→断言结果),结构清晰;覆盖三类场景:正常(Happy Path)、边界(Edge Case,0/空/最大值/临界值)、异常(Error Case,非法输入/网络失败/超时);不依赖外部状态(不连真实数据库/API/文件系统/当前时间/随机数,用 Mock);快(毫秒级,整个测试套件几秒跑完);独立(测试之间不共享状态,顺序无关,可单独运行);可重复(每次跑结果一样);测试代码和生产代码一样重要,要维护、要重构、要命名清晰。本课程的朋友好学 App 也用 Vitest 给判题引擎、工具函数、状态管理等核心逻辑写了单元测试(tests/unit、tests/audit),并保持全部通过,核心逻辑有测试保障。
集成测试:模块协作
集成测试(Integration Test)测试多个模块/组件之间的协作,验证它们组合在一起是否正常工作。常见的集成测试:API 接口测试(发 HTTP 请求 → 后端处理 → 查数据库 → 验证响应,用测试数据库);数据库访问层测试(真实连测试数据库,验证 SQL/ORM 操作正确);服务间调用测试(微服务之间的调用);前端组件+状态管理+API 的集成(组件和 store 的协作)。集成测试比单元测试慢(要连数据库/起服务),但能发现单元测试发现不了的问题(模块间接口不匹配、数据格式不一致、事务问题)。
// API 集成测试示例(Supertest + 测试数据库)
// import request from 'supertest';
// import app from './app';
// import { prisma } from './db';
//
// describe('POST /api/auth/login', () => {
// // 每个测试前清理测试数据库
// beforeEach(async () => {
// await prisma.user.deleteMany();
// // 创建一个测试用户
// await prisma.user.create({
// data: { email: 'test@example.com', passwordHash: 'hashed_password', name: '测试用户' }
// });
// });
//
// afterAll(async () => {
// await prisma.$disconnect();
// });
//
// it('正确的邮箱密码应该返回 token', async () => {
// const res = await request(app)
// .post('/api/auth/login')
// .send({ email: 'test@example.com', password: 'correct_password' });
//
// expect(res.status).toBe(200);
// expect(res.body).toHaveProperty('token');
// expect(res.body.user.email).toBe('test@example.com');
// });
//
// it('错误的密码应该返回 401', async () => {
// const res = await request(app)
// .post('/api/auth/login')
// .send({ email: 'test@example.com', password: 'wrong_password' });
//
// expect(res.status).toBe(401);
// expect(res.body.error).toBe('INVALID_CREDENTIALS');
// });
//
// it('缺少参数应该返回 400', async () => {
// const res = await request(app)
// .post('/api/auth/login')
// .send({ email: 'test@example.com' }); // 没传 password
//
// expect(res.status).toBe(400);
// });
// });
//
// 集成测试要点:
// - 用独立的测试数据库(不要连开发/生产数据库)
// - 每个测试前清理数据(deleteMany 或事务回滚)
// - 测试完整的请求-响应流程(包括数据库写入)
// - 覆盖正常和异常场景用独立的测试数据库(如 myapp_test),和开发/生产数据库完全隔离;每个测试在事务里运行,结束后回滚(rollback),不需要手动清理数据,速度快——Prisma/Django/Rails 都有内置支持;如果不支持事务回滚,用 beforeEach 清理数据(deleteMany/truncate),注意清理顺序(外键约束);测试数据用工厂函数(factory)生成(如 createTestUser()、createTestCourse()),不要每个测试里重复写创建逻辑;不要用 mock 数据库(如 sqlite 内存库代替 PostgreSQL)——不同数据库的行为有差异(如 JSON 操作、约束、索引),用真实数据库测试更可靠;测试数据库的 schema 要和生产一致(用 migration 建表,不要手动建)。
E2E 测试:真实用户视角
E2E(End-to-End,端到端)测试模拟真实用户操作,测试完整的用户流程。工具:Playwright(微软,快、多浏览器、自动等待,最推荐)、Cypress(流行、文档好、但只支持 Chromium 系、跨浏览器弱)、Selenium(老牌、支持所有浏览器、但慢且不稳定)。E2E 测试写得少而精——只测最核心的用户流程(如注册→登录→核心功能→支付),不要测每个页面每个按钮(那是单元/集成测试的事)。
// E2E 测试示例(Playwright)
// import { test, expect } from '@playwright/test';
//
// test.describe('用户学习流程', () => {
// test('注册→登录→学习课程→答题', async ({ page }) => {
// // 1. 注册
// await page.goto('http://localhost:3000/register');
// await page.fill('input[name="email"]', 'test@example.com');
// await page.fill('input[name="password"]', 'Test123456');
// await page.fill('input[name="confirmPassword"]', 'Test123456');
// await page.click('button[type="submit"]');
// await expect(page).toHaveURL('http://localhost:3000/dashboard');
//
// // 2. 进入课程
// await page.click('text=Python 入门');
// await expect(page.locator('h1')).toContainText('Python 入门');
//
// // 3. 开始学习第一节课
// await page.click('text=什么是编程');
// await expect(page.locator('h1')).toContainText('什么是编程');
//
// // 4. 答题
// await page.click('text=开始答题');
// await page.check('input[value="A"]'); // 选答案A
// await page.click('text=提交');
// await expect(page.locator('.result')).toContainText('正确');
//
// // 5. 验证进度保存
// await page.goto('http://localhost:3000/dashboard');
// await expect(page.locator('.progress')).toContainText('1/37');
// });
// });
//
// E2E 测试要点:
// - 只测最核心的用户流程(5-10个测试就够)
// - 用真实的测试环境(真实数据库、真实后端)
// - Playwright 自动等待(不用手动 sleep,它会等元素出现/可点击)
// - 测试要稳定(不要依赖时序、不要用固定的等待时间)
// - 失败时截图/录屏,方便定位问题测试覆盖率:意义和局限
测试覆盖率(Code Coverage)衡量有多少代码被测试执行过——行覆盖率、分支覆盖率、函数覆盖率。工具:Vitest/Jest 内置 --coverage、Istanbul、Python coverage.py、pytest-cov。覆盖率的意义:发现没被测试到的代码(可能是死代码、可能是漏测的重要逻辑);衡量测试投入的大致指标;CI 里设置最低覆盖率门槛(如 80%),防止测试退化。覆盖率的局限:100% 覆盖率不等于没有 Bug——覆盖率只说明「代码被执行过」,不说明「执行结果正确」(一个测试没有断言也能 100% 覆盖);追求 100% 覆盖率是误区——最后 20% 的覆盖率(异常分支、边界条件、UI 渲染)成本很高收益很低;覆盖率高不代表测试质量高——可能有很多「凑数」的测试(没有断言、只测 getter/setter)。正确态度:核心逻辑(业务规则、算法、工具函数)覆盖率要高(80%+),UI 渲染、配置、样板代码覆盖率可以低,不要追求全局 100%。
测试实现细节而不是行为:别测「这个函数内部调用了 helper(x)」,要测「输入 A 输出 B」——实现会变,行为不变,测实现细节的测试一重构就全挂;过度 Mock:别把所有东西都 Mock,包括被测函数内部调用的自己的工具函数——测试和实现强耦合,重构就挂;脆弱测试(Flaky Test):有时过有时不过,通常是因为依赖时序、随机数、外部状态、测试之间共享数据——必须修复,不能忽略(「偶尔失败」=「不可靠」);测试之间有依赖:测试 B 依赖测试 A 创建的数据,单独跑 B 就失败——测试必须独立;巨型测试:一个测试测 10 个场景,失败了不知道哪个坏了——一个测试一个场景;没有断言的测试:跑了代码但不验证结果,等于没测;只测 Happy Path:不测边界和异常,Bug 都出在那里。
用 AI 生成测试
AI 可以提高测试编写效率。方法:把被测函数/类的代码给 AI,让它生成单元测试(正常/边界/异常场景);把 API 接口文档给 AI,让它生成集成测试;把用户流程描述给 AI,让它生成 Playwright E2E 测试;用 AI 分析现有测试,找漏测的场景和边界条件。AI 生成测试的特点:快(几分钟生成几十个测试,人工要几小时);覆盖全(AI 能想到很多你忽略的边界和异常场景);格式规范(AI 生成的测试结构一致、命名规范)。局限:可能生成无效/重复的测试(需要审查);可能误解代码逻辑(生成的测试断言错误);复杂业务逻辑的测试需要人来设计(AI 不理解业务规则);Mock 可能过度或不足。最佳实践:AI 生成初稿 → 人审查和调整(删无效测试、补业务场景、修断言)→ 跑测试验证通过。
// 让 AI 生成测试的提示词示例
// """
// 你是一个有 10 年经验的测试工程师,精通 Vitest + TypeScript。
//
// 请为以下函数编写完整的单元测试:
//
// [粘贴被测函数代码]
//
// 要求:
// 1. 用 Vitest 语法(describe/it/expect)
// 2. 覆盖正常场景、边界场景(0/空/最大值/临界值)、异常场景(非法输入/错误处理)
// 3. 测试名用「应该...」的行为描述
// 4. 每个测试只测一个场景
// 5. 外部依赖用 vi.mock 隔离
// 6. 用 Arrange-Act-Assert 三段式结构
// 7. 代码有中文注释
// 8. 不要测试实现细节,只测试输入输出行为
// """性能监控与错误追踪
测试能在上线前发现问题,但线上出了问题怎么快速发现和定位?需要监控和追踪。三大支柱:日志(Logging):记录发生了什么(请求、错误、关键操作),用于事后排查;指标(Metrics):量化系统状态(QPS、延迟、错误率、CPU/内存、数据库连接数),用于实时监控和告警;追踪(Tracing):一个请求经过哪些服务/函数,每步花了多久,用于定位性能瓶颈和错误根源(分布式追踪)。工具:
三个黄金信号(Google SRE):延迟(Latency,请求处理时间,区分成功和失败)、流量(Traffic,QPS/并发数)、错误(Errors,错误率,按类型分)、饱和度(Saturation,CPU/内存/磁盘/数据库连接的使用比例)——监控这四个就够了;告警要「可操作」——每个告警都要有对应的处理手册(Runbook),告警了就知道该怎么办,别发「CPU 高了」这种没法直接处理的告警,应该是「CPU 持续 5 分钟 > 80%,可能原因:流量突增死循环慢查询,处理步骤:...」;避免告警疲劳——告警太多人就忽略了,只告警真正需要人工介入的问题(错误率>5%、P99延迟>2s、服务不可用),警告级别的用仪表盘展示不用告警;日志要结构化(JSON 格式,包含时间/级别/请求ID/用户ID/操作/耗时/错误),别用纯文本,难查询难分析;请求 ID 串联——一个请求从入口到出口带同一个 trace_id,所有日志都带这个 ID,出问题能串联整个请求的所有日志;前端也要监控——JS 错误、白屏、页面加载性能(LCP/FID/CLS)、API 失败率,Sentry 前端 SDK 一行代码接入;本课程的朋友好学 App 前端目前没有接 Sentry 这类前端监控,后端已有日志和 PM2 进程守护;要做完整的线上可观测,应再接入 Sentry(前端+后端错误追踪)和基本指标监控(QPS/延迟/错误率)。
TDD:测试驱动开发
TDD(Test-Driven Development,测试驱动开发)是「先写测试,再写代码」的开发方式:红(Red):先写一个失败的测试(描述期望的行为);绿(Green):写最少的代码让测试通过;重构(Refactor):在测试保护下重构代码,优化结构。循环往复。TDD 的价值:先写测试就是先定义「这个函数应该怎么用」,强制想清楚接口设计;每个功能都先有测试,测试覆盖率天然高;测试保护下敢重构,有重构信心;测试失败直接定位问题,减少调试。TDD 的局限:不适合所有场景,探索性编程、UI 开发、原型验证、需求不明确时,先写测试很难;学习曲线陡,新手不知道怎么写测试,因为还不知道代码长什么样;为了可测试性可能引入不必要的抽象,过度设计;小功能也要先写测试,初期慢,长期快。适用场景:核心业务逻辑、算法、库/框架开发、需求明确的功能。不适用:UI 原型、探索性开发、一次性脚本、需求频繁变化的早期产品。本课程的朋友好学 App 的核心逻辑(判题引擎、工具函数)适合 TDD,UI 和原型不适合。
本节小结
测试价值:防止回归、文档作用、重构信心、设计反馈、减少线上 Bug。测试金字塔:单元测试多而快(70-80%,测单个函数隔离外部依赖)、集成测试中(15-20%,测模块协作/API/数据库)、E2E 少而精(5-10%,测核心用户流程)。单元测试原则:隔离(Mock 外部依赖)、快、独立、可重复,覆盖正常/边界/异常三类场景,Arrange-Act-Assert 三段式,测试名描述行为。集成测试用独立测试数据库,事务回滚或 beforeEach 清理,测完整请求-响应。E2E 用 Playwright(推荐),只测最核心流程,自动等待不用 sleep。测试覆盖率:核心逻辑 80%+,不追求全局 100%,覆盖率高不等于没 Bug。反模式:测实现细节、过度 Mock、脆弱测试、测试间依赖、巨型测试、无断言、只测 Happy Path。AI 生成测试:AI 出初稿(快/全/规范),人审查调整(删无效/补业务/修断言),跑测试验证。监控三支柱:日志(结构化JSON+请求ID)、指标(延迟/流量/错误/饱和度四黄金信号)、追踪(分布式 trace);工具 Sentry(错误追踪最流行)、New Relic/Datadog(全栈APM)、Prometheus+Grafana(开源指标)、ELK/Loki(日志);告警要可操作、避免疲劳、只告警需人工介入的。TDD:红→绿→重构,适合核心逻辑/算法/库,不适合UI原型/探索性开发,是工具不是宗教。下一节讲后端部署——测试做好了,怎么上线。
资深工程师加餐
底层原理 · 大厂视角 · 工程经验,点卡片展开
纯网页包壳容易被应用商店以“最小功能不足”驳回,关键在于用原生能力补足 Web 做不到的部分:权限申请(麦克风/摄像头)、安全键盘与键盘高度、状态栏与安全区、文件与分享、版本更新、扫码等。让 Web 负责快速迭代的内容与 UI,原生负责系统能力与体验兜底,这种混合架构才是套壳 App 的合规且高体验形态。