面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 02026年5月31日 15:55

Jest 中 describe、test 和 it 该怎么分工?

describe 负责分组,test 和 it 负责定义具体用例;test 与 it 在 Jest 里是同一个能力,只是表达风格不同。写测试时不要把 describe 当成必填包装,也不要为了层级好看一层套一层。好的组织方式是让读者从外层看到模块或场景,从内层看到行为,再从断言里确认结果。简单说,describe 管上下文,test/it 管一个可以独立失败的事实。很多测试文件难维护,不是因为 Jest API 难,而是命名和分组把问题藏起来了。比如 describe('utils') 下面放几十个用例,失败时只能知道 utils 坏了,却不知道是格式化、校验还是边界值坏了。反过来,如果每个分支都新开一层 describe,文件会像迷宫,读者要上下跳着看 beforeEach。好的组织方式应该让失败标题连起来就是一句清楚的话。describe('Calculator.add', () => { test('adds two positive numbers', () => { expect(add(2, 3)).toBe(5); }); it('handles negative numbers', () => { expect(add(-2, 3)).toBe(1); });});追问test 和 it 到底有没有功能差异?没有,it 是 test 的别名,超时参数、异步测试、skip、only 等能力都一样。区别主要是可读性:it('returns empty array') 读起来像行为描述,test('returns empty array') 更直白。团队里最好统一一种风格,否则同一个文件里混着写会显得很乱。边界是 BDD 风格明显的组件行为测试可以用 it,工具函数或数据结构测试用 test 也很自然。取舍不是谁更高级,而是谁能让失败信息更像人话。describe 应该嵌套几层才合适?通常一到两层就够了,外层放模块名或组件名,内层放方法、状态或场景。三层以上会让测试标题很长,失败输出也不容易快速定位。嵌套太深还容易把共享变量塞进外层,最后不同用例之间互相影响。更稳的做法是把复杂场景拆成多个文件,或者用清晰的测试数据工厂代替深层 describe。如果一个 describe 下面只有一个测试,也要警惕它是不是只是为了凑结构。beforeEach 放在 describe 里有什么坑?beforeEach 适合做重复但便宜的初始化,比如创建 store、清空 mock、挂载轻量组件。不要在里面做慢请求、真实数据库连接或复杂全局配置,否则每个用例都会付一次成本。更隐蔽的坑是外层 beforeEach 和内层 beforeEach 叠加后,读者很难知道一个用例运行前到底发生了什么。只要初始化逻辑超过几行,就应该考虑抽成 setup(),让每个测试自己显式调用。这个取舍会让测试多写一行,但换来的是用例输入更清楚。一个 test 里可以写多个 expect 吗?可以,但多个断言应该服务于同一个行为。比如“登录成功后写入 token、更新用户信息、关闭 loading”属于一个完整结果,可以放在同一个用例里。若断言覆盖了多个原因不同的分支,失败时就很难判断到底是哪段逻辑坏了。取舍标准是:如果其中一个断言失败后,其他断言仍然代表独立需求,就拆成多个测试。另一个边界是异步流程,多个 expect 之间如果依赖执行顺序,就要确保前面的 await 已经真正结束。skip、only 和 todo 应该怎么用?test.only 和 describe.only 只适合本地临时调试,不能进入主分支。skip 可以用于记录暂时无法运行的用例,但要写清楚原因,否则它会变成永久漏测。todo 适合先标记测试计划,尤其是修 bug 前先列出缺失场景。团队踩坑最多的是忘删 .only,建议开启 eslint-plugin-jest 的 no-focused-tests 和 no-disabled-tests 规则。边界是迁移期的大量失败用例,可以短期 skip,但要配 issue 或过期时间,否则它们很快没人敢动。写段配置// eslint.config.jsmodule.exports = { plugins: ['jest'], extends: ['plugin:jest/recommended'], rules: { 'jest/no-focused-tests': 'error', 'jest/no-disabled-tests': 'warn', 'jest/expect-expect': 'error' }};组织 Jest 测试时,目标不是把层级写得像目录树,而是让失败信息能直接说明哪个行为坏了。describe 少而准,test/it 小而独立,再配合必要的 lint 约束,测试文件会比单纯追求“格式统一”更好维护。代码评审时也可以顺手看测试标题:如果只读标题看不懂行为,后面的人排查失败时也一样看不懂。
服务端阅读 02026年5月31日 15:55

Jest 测试跑得太慢时该从哪些地方优化?

Jest 测试变慢时,先不要急着把所有用例都改成 mock。更稳的做法是先量出慢在哪里,再从运行范围、测试环境、并发、转换缓存和外部依赖几个点逐个处理。通常收益最大的是三件事:只跑相关测试、把不需要 DOM 的用例放到 node 环境、把网络和计时器这类不稳定依赖隔离掉。CI 上还要控制 worker 数量,因为机器核数看起来很多,不代表同时跑满就最快,I/O、转译和内存都会抢资源。优化前最好先固定基线:记录完整测试耗时、最慢的测试文件、是否开启 coverage、是否每次都重新转译。很多团队感觉“Jest 越来越慢”,实际是新增了 jsdom 用例、覆盖率范围过大、mock 泄漏导致重试,或者 CI 容器内存不足。把这些因素拆开之后,优化才不会变成凭感觉调参数。// jest.config.jsmodule.exports = { testEnvironment: 'node', maxWorkers: process.env.CI ? '50%' : '75%', testTimeout: 5000, cacheDirectory: '<rootDir>/.jest-cache', collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', '!src/**/*.stories.{js,jsx,ts,tsx}', '!src/**/*.d.ts' ]};追问为什么先用 --runInBand 或 --detectOpenHandles 排查,而不是直接加 maxWorkers?maxWorkers 只能调度并发,不能解决测试本身卡住的问题。遇到数据库连接没关闭、定时器没清理、Promise 没 await 时,并发越高日志越乱,定位反而更慢。--runInBand 能把问题压成单进程复现,--detectOpenHandles 可以暴露遗留句柄。代价是这两个参数会明显拖慢执行速度,所以适合排查,不适合长期放进默认 CI 命令。边界也要注意:如果问题只在并发下出现,单进程可能复现不了,这时可以先降低 worker 数量,再逐步缩小到具体文件。testEnvironment 选 node 还是 jsdom,有什么取舍?纯函数、Node 服务端逻辑、数据转换这类测试应该优先用 node,启动快、内存少,也少了 DOM 模拟层带来的噪音。组件测试、依赖 document、window、布局事件的用例才需要 jsdom。踩坑点是有些工具库会偷偷读取浏览器全局对象,如果全局配置成 node,这些用例会突然失败。比较稳的做法是默认 node,只在需要 DOM 的测试文件顶部用 /** @jest-environment jsdom */ 单独声明。这样做的取舍是配置会分散一些,但换来的是大部分非 UI 测试能保持轻量。覆盖率收集为什么会拖慢 Jest?覆盖率需要对代码插桩,转译和文件扫描都会增加开销,尤其是 TypeScript 项目和大仓库更明显。日常本地开发可以不默认打开 coverage,只在提交前或 CI 的独立阶段运行。collectCoverageFrom 要排除声明文件、story、mock、生成代码,否则数字看起来完整,实际是在统计无意义文件。边界是核心库、支付、权限这类高风险模块仍然应该保留覆盖率门禁,不能为了速度完全取消。如果覆盖率阶段太慢,可以把单元测试和 coverage 拆成两个 CI job,让开发先拿到基础测试反馈。Mock 外部依赖会不会让测试失真?会,所以 mock 要用在边界上,而不是把所有内部逻辑都替换掉。API、时间、随机数、文件系统、第三方 SDK 适合 mock,因为它们慢且不稳定;业务分支和状态变更如果也全 mock,测试就只是在验证 mock 写得对。一个常见坑是 mock 没有在 afterEach 里恢复,导致后面的用例继承了错误状态。可以配合 jest.clearAllMocks() 或 jest.restoreAllMocks(),让用例之间保持隔离。取舍上,少量集成测试仍然要保留真实调用链,只把网络层替换掉,这样才能发现模块之间的契约问题。watch、onlyChanged 和 CI 命令应该怎么分开?本地开发追求反馈快,jest --watch 或 jest --onlyChanged 很合适,因为它们只跑和改动相关的测试。CI 追求确定性,应该跑完整测试,并把 worker、coverage、缓存目录固定下来。不要把 test.only、describe.only 当成选择性运行方案,它们很容易被误提交。团队里可以加 ESLint 规则或 pre-commit 检查禁止 .only,这比事后排查漏测便宜得多。还有一个边界是单体仓库:只跑 changed 可能漏掉跨包依赖,CI 最好结合依赖图或至少在合并前跑一次全量。写段代码{ "scripts": { "test": "jest --watch", "test:changed": "jest --onlyChanged", "test:ci": "jest --ci --coverage --maxWorkers=50%", "test:debug": "jest --runInBand --detectOpenHandles" }}Jest 性能优化的关键不是把命令堆满,而是给不同场景配不同命令。本地要快,CI 要稳,排查要可复现。只要把环境、并发、覆盖率和 mock 边界分清,大多数“测试越来越慢”的问题都能被压回可控范围。真正需要重写测试时,也应该先从最慢、最不稳定、最依赖外部资源的文件开始,而不是把整个测试目录推倒重来。
服务端阅读 02026年5月31日 11:08

Jest 如何测试异常处理并正确使用 toThrow 和 rejects?

异常测试不是为了证明代码“会报错”,而是确认它在错误输入、依赖失败和边界条件下报出正确的错,并且调用方能按预期处理。Jest 里同步异常主要用 toThrow,Promise 拒绝主要用 rejects,回调错误则要显式等待测试结束。最常见的误区是把函数先执行了,再把结果交给 expect,这样异常会在断言前就抛出。同步异常怎么测?toThrow 接收的是一个函数包装,而不是函数调用结果。可以匹配错误类型、完整消息、部分字符串或正则。function divide(a, b) { if (b === 0) throw new RangeError('Division by zero') return a / b}test('除数为 0 时抛出 RangeError', () => { expect(() => divide(10, 0)).toThrow(RangeError) expect(() => divide(10, 0)).toThrow(/zero/)})边界是不要过度依赖完整错误文案。文案经常为了用户体验调整,测试也会跟着碎。更稳定的断言是错误类型、错误 code,或关键短语。Promise 拒绝怎么测?异步函数返回 Promise 时,用 await expect(promise).rejects…。不要忘记 await 或 return,否则测试可能在 Promise 拒绝前就结束,形成假通过。async function fetchUser(api) { const res = await api.get('/user') if (!res.ok) throw new Error('API Error') return res.data}test('接口失败时 rejects', async () => { const api = { get: jest.fn().mockResolvedValue({ ok: false }) } await expect(fetchUser(api)).rejects.toThrow('API Error')})如果你需要检查错误对象上的多个字段,可以用 try/catch,但要加 expect.assertions,避免没有抛错时测试仍然通过。test('保留错误 code', async () => { expect.assertions(2) try { await readConfig('/missing') } catch (error) { expect(error).toBeInstanceOf(Error) expect(error.code).toBe('ENOENT') }})回调错误和框架错误怎么测?Node 风格回调可以用 done,但必须保证错误路径和成功路径都能结束测试。React 错误边界、日志上报这类场景还要临时 mock console.error,并在测试后恢复。test('callback 返回错误', done => { loadFile('bad.txt', err => { expect(err).toBeInstanceOf(Error) expect(err.message).toContain('bad') done() })})追问toThrow 为什么必须包一层函数?因为 Jest 需要自己调用这段代码,才能捕获抛出的异常。写成 expect(divide(1, 0)).toThrow() 时,异常已经在 expect 执行前抛出,断言根本没机会运行。这个边界只针对同步异常;异步函数即使内部 throw,也会变成 rejected Promise。踩坑是把同步和异步写法混用,导致测试报错位置看起来很奇怪。rejects.toThrow 和 try/catch 怎么取舍?rejects.toThrow 简洁,适合只关心错误类型或消息的场景。try/catch 更啰嗦,但适合检查多个字段,比如 code、status、details。取舍标准是断言复杂度:一两个断言用 rejects,多字段检查用 try/catch。坑是 try/catch 里忘记 expect.assertions,当函数没有抛错时测试也可能悄悄通过。错误消息应该精确匹配吗?一般不建议完整精确匹配,除非这个消息本身就是公开 API。内部错误文案经常调整,精确匹配会让测试过于脆弱。更好的选择是匹配错误类型、错误码或关键关键词。边界是表单校验、CLI 输出、SDK 对外错误这类场景,用户依赖文案时就应该精确测试。如何测试“没有抛错”?同步函数可以写 expect(() => fn()).not.toThrow(),异步函数可以写 await expect(fn()).resolves.toEqual(…)。但不要滥用“没有抛错”作为唯一断言,因为函数可能什么也没做也能通过。取舍是它适合覆盖边界输入不崩溃,更关键的业务结果仍要单独断言。踩坑是只测 not.toThrow,漏掉返回值或副作用错误。Mock 抛错时要注意什么?同步依赖用 mockImplementation(() => { throw error }),异步依赖用 mockRejectedValue(error),不要混着用。错误对象最好带上真实业务会用到的字段,比如 code 或 response.status。边界是有些库抛出的不是 Error,而是普通对象,测试要和真实库行为一致。踩坑是 mock 得太理想,生产里的错误结构不同,catch 分支读取字段时再次报错。
服务端阅读 02026年5月31日 11:08

Jest 如何测试 TypeScript 项目并配置 ts-jest?

Jest 测 TypeScript 项目时,先要分清两个问题:测试运行时怎么把 TS 转成 JS,以及类型错误由谁负责检查。ts-jest 可以在 Jest 运行时编译 TypeScript,配置直观,适合希望测试和 tsconfig 保持一致的项目。另一个常见选择是 Babel 或 SWC 转译,它们更快,但通常不做完整类型检查。ts-jest 基础配置怎么写?先安装 Jest、类型声明和 ts-jest。Node 项目一般使用 node 环境,前端组件或 DOM 工具才需要 jsdom。npm i -D jest ts-jest @types/jest typescriptmodule.exports = { preset: 'ts-jest', testEnvironment: 'node', testMatch: ['**/?(*.)+(spec|test).ts'], moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts']}tsconfig.json 里要包含 Jest 类型,否则 describe、test、expect 可能被编辑器标红。{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "strict": true, "esModuleInterop": true, "types": ["jest", "node"] }}如果项目本身是 ESM,配置会更敏感,需要使用 preset: 'ts-jest/presets/default-esm',并处理 extensionsToTreatAsEsm。这是 ts-jest 里最常见的坑之一。类型安全的测试怎么写?测试不应该为了通过而到处 as any。TypeScript 的价值在于让测试数据、Mock 和返回值也遵守业务类型。export interface User { id: number; name: string }export function formatUser(user: User): string { return `${user.id}:${user.name}`}test('格式化用户信息', () => { const user: User = { id: 1, name: 'Ada' } expect(formatUser(user)).toBe('1:Ada')})Mock 函数可以用 jest.MockedFunction 或 jest.mocked,避免 mockResolvedValue 的类型丢失。import { fetchUser } from './api'jest.mock('./api')const mockedFetchUser = jest.mocked(fetchUser)test('加载用户', async () => { mockedFetchUser.mockResolvedValue({ id: 1, name: 'Ada' }) await expect(loadUserName(1)).resolves.toBe('Ada')})要不要让 Jest 做类型检查?ts-jest 可以诊断类型错误,但大型项目里会拖慢测试。很多团队会把 tsc --noEmit 放到 CI 的单独步骤,让 Jest 专注行为测试。这样失败信息更清晰:类型错归类型检查,逻辑错归单元测试。追问ts-jest、babel-jest 和 swc-jest 怎么选?ts-jest 最贴近 TypeScript 编译器,路径、装饰器和部分 tsconfig 行为更容易对齐。Babel 或 SWC 通常更快,适合大型前端项目,但它们主要是转译,不负责完整类型检查。取舍是准确性和速度:配置复杂、依赖 TS 编译特性的项目优先 ts-jest;追求测试速度并已有独立 tsc --noEmit 的项目可以选 SWC。踩坑是以为 Jest 通过就代表类型没问题,实际上转译型方案可能放过类型错误。路径别名为什么在测试里经常失效?TypeScript 的 paths 只告诉编译器怎么解析,不会自动教 Jest 解析模块。Jest 需要单独配置 moduleNameMapper,或者用 pathsToModuleNameMapper 从 tsconfig 生成。边界是 monorepo 里 rootDir、baseUrl 和包边界更复杂,不能简单复制单包配置。常见坑是源码能编译,测试却报 Cannot find module '@/xxx'。ESM 项目配置 Jest 有什么坑?ESM 下 type: module、tsconfig 的 module、Jest preset 和导入扩展名必须互相匹配。很多错误不是业务代码错,而是 CJS/ESM 混用导致模块加载失败。取舍是如果项目没有强 ESM 需求,测试环境保持 CJS 会省事很多;如果库要发布 ESM,就应该尽早把测试跑在接近发布格式的环境里。踩坑是 mock ESM 模块方式和 CJS 不同,旧的 jest.mock 习惯可能失效。TypeScript 测试里什么时候可以用 as any?as any 可以用于刻意构造非法输入,测试运行时防御逻辑,例如后端收到脏数据。除此之外应尽量避免,因为它会绕开类型系统,让测试数据变得不可信。取舍是:为了测边界可以局部使用,但要用注释说明这是故意破坏类型。踩坑是为了省事大量 as any,最后测试覆盖了一个现实中根本不会出现的类型形状。类型测试和行为测试要分开吗?通常要分开。Jest 擅长测运行时行为,类型层面的断言可以用 tsd、expect-type 或 tsc --noEmit。边界是工具库、SDK、泛型函数这类类型就是产品能力的代码,应该补类型测试。普通业务项目则不必把所有类型都放进 Jest,否则会让测试意图变模糊。
服务端阅读 02026年5月31日 11:08

Jest 如何测试 fs 文件系统和 I/O 操作?

测试文件系统代码时,最重要的问题不是“能不能读写文件”,而是“你的业务逻辑在文件存在、缺失、权限不足、内容损坏时是否处理正确”。Jest 可以 mock fs,也可以配合临时目录做接近真实的集成测试。两种方式都该会用,因为纯 Mock 快但容易脱离真实行为,真实 I/O 准但慢且需要清理。什么时候 Mock fs?如果函数只是包装读取、解析和错误处理,mock fs 很合适。它能让测试不依赖本机路径,也不会污染项目目录。const fs = require('fs')jest.mock('fs')function readConfig(path) { const raw = fs.readFileSync(path, 'utf8') return JSON.parse(raw)}test('读取并解析配置文件', () => { fs.readFileSync.mockReturnValue('{"port":3000}') expect(readConfig('/app/config.json')).toEqual({ port: 3000 }) expect(fs.readFileSync).toHaveBeenCalledWith('/app/config.json', 'utf8')})边界是:mock 出来的 fs 不会模拟所有 Node 行为,比如真实错误对象的 code、路径分隔符、权限差异、符号链接。只靠 mock,可能会漏掉生产环境里的路径问题。异步 fs.promises 怎么测?现代 Node 项目更常用 fs/promises。这时要 mock 对应模块,并用 mockResolvedValue 或 mockRejectedValue 表达成功和失败。jest.mock('fs/promises', () => ({ readFile: jest.fn(), writeFile: jest.fn(), mkdir: jest.fn()}))const fs = require('fs/promises')test('文件不存在时返回默认配置', async () => { fs.readFile.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) await expect(loadConfig('/no-file.json')).resolves.toEqual({})})这里的坑是不要只 mock happy path。I/O 最容易出问题的地方恰恰是 ENOENT、EACCES、JSON 格式错误、磁盘写入中断和目录不存在。什么时候用真实临时文件?涉及路径拼接、目录创建、文件遍历、编码、换行符或原子写入时,最好用临时目录跑一层集成测试。const os = require('os')const path = require('path')const fs = require('fs/promises')test('写入后可以再次读取', async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'jest-fs-')) const file = path.join(dir, 'data.txt') await saveText(file, 'hello') await expect(fs.readFile(file, 'utf8')).resolves.toBe('hello') await fs.rm(dir, { recursive: true, force: true })})清理必须放在 afterEach 或 try/finally 中,否则 CI 上一次失败会影响下一次运行。追问Mock fs 和使用临时目录怎么取舍?Mock fs 速度快、断言清晰,适合覆盖业务分支和错误处理。临时目录更接近真实环境,适合验证路径、编码、目录递归和跨平台行为。取舍标准是测试目标:想验证“函数是否调用了 fs”用 Mock,想验证“文件结果是否真的正确”用临时目录。踩坑是把所有测试都写成真实 I/O,最后测试套件变慢且偶发失败。如何测试文件不存在和权限不足?不要只断言抛错,要断言你的代码对错误的处理策略。文件不存在可能返回默认值,也可能提示用户创建配置;权限不足通常应该向上抛出更明确的错误。边界是不同系统的错误消息不稳定,不要用完整 message 做强匹配。更稳的是匹配 error.code,例如 ENOENT 或 EACCES。为什么手动 mock fs 容易失真?Node 的 fs API 细节很多,同步、异步、callback、promise 版本行为并不完全一样。手动 mock 只覆盖你想到的分支,没想到的地方会变成测试盲区。取舍是:小函数手动 mock 足够,大量文件操作可以考虑 memfs 这类内存文件系统。坑是 memfs 也不是完整操作系统,权限和符号链接仍可能与真实环境不同。文件遍历测试要注意什么边界?目录遍历要测空目录、嵌套目录、隐藏文件、扩展名过滤和排序稳定性。跨平台时还要避免硬编码 /,应该使用 path.join 或 path.resolve。取舍是测试里是否固定排序:如果业务需要稳定输出,就应该排序并测试;如果不需要,断言可以用集合方式。踩坑是 macOS 本地文件顺序和 Linux CI 不一致,导致测试偶发失败。写入文件时如何避免测试污染?每个测试都应创建独立临时目录,不要共用项目里的 fixtures/output。清理逻辑要放在 finally 或 afterEach,即使断言失败也能删除文件。边界是调试失败时你可能想保留文件,可以通过环境变量控制是否清理。不要在测试里写固定路径,尤其不要写用户主目录或仓库根目录。
服务端阅读 02026年5月31日 11:08

Jest 如何测试 Redux 的 Action、Reducer 和 Selector?

Redux 测试最好按代码职责拆开:action creator 测返回的 action,reducer 测状态如何变化,selector 测派生数据,异步 thunk 或 RTK Query 再测副作用边界。不要把所有东西都塞进一个 React 组件测试里,否则失败时很难判断是 UI、store、接口 Mock,还是 reducer 写错了。Jest 的价值在于让这些纯逻辑可以被快速、稳定地验证。从 reducer 开始最划算reducer 通常是纯函数,输入旧 state 和 action,输出新 state。它不需要 DOM,也不需要 mock store,是 Redux 里最适合单元测试的部分。const initialState = { count: 0 }function counterReducer(state = initialState, action) { switch (action.type) { case 'counter/increment': return { ...state, count: state.count + 1 } case 'counter/add': return { ...state, count: state.count + action.payload } default: return state }}test('处理 add action', () => { expect(counterReducer({ count: 2 }, { type: 'counter/add', payload: 3 })) .toEqual({ count: 5 })})这里要特别测 default 分支和不可变更新。一个常见坑是 reducer 直接修改原对象,简单断言结果可能看不出来,但会影响 React-Redux 的引用比较。可以在测试里冻结输入对象,或者使用 Redux Toolkit 的 Immer,让写法和不可变语义保持一致。action 和 selector 怎么测?action creator 如果只是返回对象,测试应保持克制;过度测试会让代码变得像快照翻译。selector 更值得测,因为它经常藏着筛选、排序、权限判断和空值兼容。export const addTodo = text => ({ type: 'todos/add', payload: text })export const selectDoneTodos = state => state.todos.items.filter(x => x.done)test('创建 addTodo action', () => { expect(addTodo('写测试')).toEqual({ type: 'todos/add', payload: '写测试' })})test('筛选已完成 todos', () => { const state = { todos: { items: [{ done: true }, { done: false }] } } expect(selectDoneTodos(state)).toHaveLength(1)})selector 的边界包括空数组、缺字段、排序稳定性和 memoized selector 的引用复用。尤其是 Reselect,如果 selector 每次都返回新数组,组件会无意义重渲染。异步逻辑如何隔离?传统 thunk 可以 mock API,再断言 dispatch 顺序。Redux Toolkit 的 createAsyncThunk 更推荐测 fulfilled/rejected 对 reducer 的影响,少测内部实现。test('fetchUser 成功后派发 success', async () => { const api = { getUser: jest.fn().mockResolvedValue({ id: 1 }) } const dispatch = jest.fn() await fetchUser(1, api)(dispatch) expect(dispatch).toHaveBeenCalledWith({ type: 'user/success', payload: { id: 1 } })})追问为什么 reducer 测试比 action 测试更重要?reducer 承担业务规则,状态加减、列表合并、错误回滚都在这里发生。action creator 很多时候只是对象工厂,测太细会带来重复断言。取舍是:复杂 action payload 需要测试,简单 action 可以靠 reducer 或集成测试覆盖。踩坑是只测 action 不测 reducer,最后 action 发出去了但状态根本没变。selector 应该测返回值还是 memoization?普通 selector 测返回值即可,重点是不同 state 下结果是否符合业务预期。使用 Reselect 时,可以补一两个引用稳定性的测试,确认相同输入不会重新生成对象。边界是不要把 memoization 细节测得过死,否则换实现时测试会阻碍重构。真正有性能风险的 selector,例如大列表过滤和权限树计算,才值得额外测缓存行为。mock store 和真实 store 怎么选?mock store 适合验证 thunk 派发了哪些 action,速度快,也容易断言顺序。真实 store 更适合验证 reducer、middleware 和组件一起工作后的最终 UI。取舍点是你关心“过程”还是“结果”:过程用 mock store,结果用真实 store。坑在于 redux-mock-store 不会真的更新 state,拿它测组件状态变化会得到误导性结果。Redux Toolkit 的 slice 还需要单独测 action 吗?大多数情况下不需要单独测自动生成的 action creator,因为它们由库保证。更有价值的是测试 slice reducer 对 pending、fulfilled、rejected 的处理,以及 payload 边界。边界是如果你写了 prepare 回调,它包含格式化、生成 id 或清洗输入,就应该单测。否则测试越多越像在测试 Redux Toolkit 本身。React-Redux 组件测试要 mock useSelector 吗?可以 mock,但不建议作为默认方案。mock useSelector 很快,却会绕过 Provider、store shape 和订阅更新,容易让测试与真实运行环境脱节。更稳的方式是创建一个测试 store,用 Provider 包住组件,然后从用户视角断言页面。只有组件很小、依赖 state 很简单,并且你明确只想隔离 UI 分支时,mock hook 才是合理取舍。
服务端阅读 02026年5月31日 11:08

Jest 如何配合 Vue Test Utils 测试 Vue 组件?

Vue 组件测试的核心不是把组件内部每一行都测一遍,而是验证它对外表现是否稳定:传入 props 后渲染什么,用户点击后发生什么,是否 emit 正确事件,依赖插件或异步更新时是否能按预期收尾。Jest 负责断言、Mock 和运行环境,@vue/test-utils 负责把 Vue 组件挂载成可操作的 wrapper。实际项目里,测试越贴近用户行为,后期重构越不容易被测试绑住。基础配置怎么写?Vue 3 项目常见组合是 Jest、@vue/test-utils 和 @vue/vue3-jest。如果还在 Vue 2,需要换成对应版本的 vue-jest,这是最容易踩的版本坑。npm i -D jest @vue/test-utils @vue/vue3-jest babel-jest jest-environment-jsdommodule.exports = { testEnvironment: 'jsdom', moduleFileExtensions: ['js', 'json', 'vue'], transform: { '^.+\\.vue$': '@vue/vue3-jest', '^.+\\.js$': 'babel-jest' }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }}这里的边界是:Jest 跑在 jsdom 中,不是真浏览器。它适合测组件逻辑和 DOM 结果,不适合验证真实布局、滚动位置、CSS 动画或浏览器兼容性;这些应交给 E2E 或视觉回归测试。一个组件应该测什么?以计数器为例,优先测用户能感知的结果,而不是直接断言 wrapper.vm.count。这样即使以后把 Options API 改成 Composition API,只要 UI 行为不变,测试仍然有效。import { mount } from '@vue/test-utils'import Counter from '@/components/Counter.vue'test('点击按钮后展示新的计数并派发事件', async () => { const wrapper = mount(Counter, { props: { initialCount: 1 } }) await wrapper.get('button').trigger('click') expect(wrapper.text()).toContain('Count: 2') expect(wrapper.emitted('change')?.[0]).toEqual([2])})trigger 和 setProps 后通常要 await,因为 Vue DOM 更新是异步的。漏掉 await 时,测试可能本地偶尔通过、CI 偶尔失败,这类 flaky case 很难排查。追问mount 和 shallowMount 应该怎么取舍?mount 会渲染子组件,适合验证父子组合后的真实行为,例如表单组件和校验提示是否一起工作。shallowMount 会把子组件替换成 stub,速度更快,也能让测试只关注当前组件。取舍点在于测试目标:如果失败原因应该定位到当前组件,用 shallowMount;如果用户路径依赖子组件交互,用 mount。踩坑是过度使用 shallowMount 会把插槽、provide/inject 或子组件事件遮掉,导致测试通过但页面真实不可用。为什么不建议大量断言 wrapper.vm?wrapper.vm 能拿到组件实例,测 computed 或方法很方便,但它会把测试绑定到实现细节。组件重构后,用户看到的页面没变,测试却因为内部变量名变化而失败,这就是维护成本。边界是复杂计算逻辑如果没有抽成纯函数,又确实需要覆盖,可以少量使用 wrapper.vm。更稳的做法是通过文本、属性、事件和可访问角色来断言外部行为。异步接口和插件依赖怎么测?接口请求通常用 jest.fn() 或 MSW Mock,不要让单元测试真的访问网络。Vue Router、Pinia、i18n 这类插件可以通过 global.plugins 注入,也可以只 Mock 当前组件用到的最小能力。取舍在于真实性和速度:插件全量接入更像真实页面,但配置重、失败链路长;局部 Mock 更快,但容易漏掉集成问题。常见坑是异步 Promise 已经 resolve,但 Vue 还没完成 DOM 更新,需要配合 flushPromises() 和 await nextTick()。测 props、slots 和 emitted 时重点是什么?props 要测边界值,例如空字符串、缺省值、禁用状态,而不只是正常值。slots 要确认组件是否把外部内容放在正确位置,尤其是弹窗、表格列和布局组件。emitted 不只看有没有触发,还要看 payload 是否稳定,因为父组件往往依赖这个契约。踩坑是事件名大小写和 Vue 版本差异,模板里写法和测试里读取的事件名要保持一致。什么时候该改用组件集成测试或 E2E?当测试需要覆盖路由跳转、真实接口拦截、浏览器焦点、拖拽、文件上传时,单靠 Jest 会越来越别扭。Jest 的优势是快、反馈短,适合把组件的输入输出守住。E2E 更慢但更真实,适合覆盖关键业务链路。好的边界是:组件内部状态交给 Jest,跨页面和浏览器能力交给 Playwright 或 Cypress。
前端阅读 05月28日 07:00

如何在 Jest 中测试 React 组件?常用的测试工具和查询方法有哪些?

在 Jest 中测试 React 组件,核心思路是:渲染组件 → 查询元素 → 断言行为。React 官方推荐的测试方案是 Jest + React Testing Library(RTL),本文聚焦面试中高频考察的知识点。React 组件测试的基本流程是什么?测试 React 组件通常分三步:渲染:使用 RTL 的 render 方法将组件挂载到虚拟 DOM查询:通过 screen 对象提供的方法定位页面元素断言:使用 Jest 的 expect 验证元素状态或行为import { render, screen } from '@testing-library/react';import Counter from './Counter';test('counter displays initial value', () => { render(<Counter initialCount={0} />); expect(screen.getByText('Count: 0')).toBeInTheDocument();});查询方法的优先级怎么选?RTL 的查询方法有三个前缀,区别在于元素不存在时的行为:| 前缀 | 元素存在 | 元素不存在 | 适用场景 ||------|---------|-----------|---------|| getBy* | 返回元素 | 抛出错误 | 断言元素一定存在 || queryBy* | 返回元素 | 返回 null | 断言元素不存在 || findBy* | 返回 Promise | Promise reject | 异步元素出现 |具体查询方法的推荐优先级:getByRole — 最优先,基于 ARIA 角色,如 button、textbox、headinggetByLabelText — 表单元素优先用,关联 label 文本getByPlaceholderText — 没有 label 时使用getByText — 非表单元素(按钮、链接、段落)常用getByTestId — 最后手段,需要手动添加 data-testid 属性// 推荐:通过角色查询screen.getByRole('button', { name: /submit/i });// 不推荐但有时必要:通过 testId 查询screen.getByTestId('submit-btn');面试关键点:优先使用 getByRole 是因为它验证了组件的可访问性,这与 RTL "测试用户视角" 的核心理念一致。如何测试用户交互?使用 fireEvent 或 userEvent 模拟用户操作。userEvent 更接近真实用户行为,推荐优先使用。import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';test('clicking button increments counter', async () => { const user = userEvent.setup(); render(<Counter initialCount={0} />); await user.click(screen.getByRole('button', { name: /increment/i })); expect(screen.getByText('Count: 1')).toBeInTheDocument();});fireEvent 与 userEvent 的区别:fireEvent.click() 只触发 click 事件userEvent.click() 会依次触发 mousedown → mouseup → focus → click,更贴近真实操作userEvent.type() 会逐字符触发键盘事件,而 fireEvent.change() 直接修改值异步组件怎么测试?异步场景(接口请求、定时器、状态延迟更新)使用 waitFor 或 findBy* 处理。import { render, screen, waitFor } from '@testing-library/react';test('displays user data after loading', async () => { render(<UserProfile userId={1} />); // 方式一:findBy(推荐,更简洁) expect(await screen.findByText('John')).toBeInTheDocument(); // 方式二:waitFor(更灵活,可组合多个断言) await waitFor(() => { expect(screen.getByText('John')).toBeInTheDocument(); expect(screen.getByText('john@example.com')).toBeInTheDocument(); });});常见坑:waitFor 中不要用 queryBy*,因为它不抛错,断言不会失败,导致测试误通过。应使用 getBy*。如何 Mock 模块和 API 请求?面试中常考的 Mock 手段分两种:Jest.fn() — Mock 函数test('calls onSubmit with form data', async () => { const onSubmit = jest.fn(); const user = userEvent.setup(); render(<LoginForm onSubmit={onSubmit} />); await user.type(screen.getByLabelText(/email/i), 'test@example.com'); await user.click(screen.getByRole('button', { name: /login/i })); expect(onSubmit).toHaveBeenCalledWith({ email: 'test@example.com' });});jest.mock — Mock 模块// Mock API 请求模块jest.mock('../api', () => ({ fetchUser: jest.fn().mockResolvedValue({ name: 'John' })}));test('renders fetched user name', async () => { render(<UserProfile />); expect(await screen.findByText('John')).toBeInTheDocument();});对于更复杂的 API Mock 场景,可以使用 Mock Service Worker(MSW),它在 Service Worker 层拦截请求,不需要修改业务代码。React Hooks 怎么测试?自定义 Hook 使用 renderHook 进行测试:import { renderHook, act } from '@testing-library/react';import { useCounter } from './useCounter';test('useCounter increments and decrements', () => { const { result } = renderHook(() => useCounter(0)); expect(result.current.count).toBe(0); act(() => { result.current.increment(); }); expect(result.current.count).toBe(1); act(() => { result.current.decrement(); }); expect(result.current.count).toBe(0);});注意:状态更新必须包裹在 act() 中,否则 Jest 会报警告。renderHook 已从 RTL v13 起内置,不再需要 @testing-library/react-hooks 包。快照测试怎么用?什么场景下用?import renderer from 'react-test-renderer';test('Button matches snapshot', () => { const tree = renderer.create(<Button>Click</Button>).toJSON(); expect(tree).toMatchSnapshot();});快照测试的适用与不适用:适合:配置型组件(Theme、Layout),结构稳定的纯展示组件不适合:频繁变动的业务组件,否则每次改动都要更新快照,失去测试价值面试加分点:快照测试只是确认结构没变,并不验证行为是否正确,所以不能替代行为测试。测试 React 组件有哪些最佳实践?测试行为,不测实现 — 不测内部 state 的值,测用户看到的结果避免过度 Mock — Mock 越多,测试离真实场景越远查询方法按优先级选 — getByRole > getByLabelText > getByText > getByTestId异步用 findBy 优于 waitFor + getBy — 更简洁,语义更清晰使用 screen 而非 render 返回值 — 避免反复解构,代码更干净一个测试只验证一个行为 — 方便定位失败原因面试追问方向:如何测试 Context Provider 包裹的组件?如何处理第三方库的渲染行为?如何在 CI 中提升测试执行速度?这些是区分中级与高级的关键问题。
前端阅读 05月28日 06:59

如何在 Jest 中 Mock fetch 和 Axios 测试 API 调用?

核心思路测试 API 调用的关键原则是隔离外部依赖——不发出真实网络请求,用 Mock 替代,验证的是"你的代码如何调用 API、如何处理响应",而非 API 本身的行为。Jest 提供了三种主要 Mock 手段:jest.mock() 模块级替换、jest.spyOn() 方法级监听、jest.fn() 手动创建假函数。理解三者的区别和适用场景,是这道题的答题主线。Mock Axios 的两种方式方式一:jest.mock() 替换整个模块jest.mock('axios') 会将 axios 模块中所有导出替换为 jest.fn(),适合需要完全控制模块行为的场景:import axios from 'axios';import { getUser } from './api';jest.mock('axios');test('getUser 应返回用户数据', async () => { const mockData = { id: 1, name: 'Tom' }; axios.get.mockResolvedValue({ data: mockData }); const result = await getUser(1); expect(result).toEqual(mockData); expect(axios.get).toHaveBeenCalledWith('/users/1');});mockResolvedValue 让 axios.get 返回一个 resolved Promise,模拟成功响应。toHaveBeenCalledWith 断言调用参数,确保请求地址正确。方式二:jest.spyOn() 监听原方法jest.spyOn 不替换模块,而是包装原方法,可以追踪调用并控制返回值,还能通过 mockRestore() 恢复原实现:import axios from 'axios';import { getUser } from './api';test('getUser 应返回用户数据', async () => { const spy = jest.spyOn(axios, 'get').mockResolvedValue({ data: { id: 1, name: 'Tom' } }); const result = await getUser(1); expect(result).toEqual({ id: 1, name: 'Tom' }); spy.mockRestore(); // 恢复 axios.get 原实现});何时选哪个? jest.mock() 适合整个测试文件都需要 mock 的场景;jest.spyOn() 适合只想在单个测试中临时 mock、其余测试保留真实行为的场景。Mock fetch 的两种方式方式一:jest.fn() 替换全局 fetchfetch 是全局对象上的方法,直接赋值即可替换:import { fetchPosts } from './api';beforeEach(() => { global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve([{ id: 1, title: 'Hello' }]), }) );});afterEach(() => { jest.restoreAllMocks();});test('fetchPosts 应返回帖子列表', async () => { const posts = await fetchPosts(); expect(posts).toEqual([{ id: 1, title: 'Hello' }]); expect(global.fetch).toHaveBeenCalledWith('/api/posts');});这里用 beforeEach / afterEach 管理 Mock 生命周期,避免测试间互相污染——这是面试中经常追问的考点。方式二:jest.spyOn() 监听全局 fetchtest('fetchPosts 处理响应数据', async () => { jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, json: () => Promise.resolve([{ id: 1 }]), }); const posts = await fetchPosts(); expect(posts).toEqual([{ id: 1 }]);});测试错误场景只测成功路径是不够的,面试官一定会问"网络请求失败了怎么办":test('getUser 应抛出网络错误', async () => { axios.get.mockRejectedValue(new Error('Network Error')); await expect(getUser(1)).rejects.toThrow('Network Error');});test('getUser 应处理 404 响应', async () => { axios.get.mockRejectedValue({ response: { status: 404, data: { message: 'Not Found' } }, }); await expect(getUser(999)).rejects.toMatchObject({ response: { status: 404 }, });});mockRejectedValue 模拟 Promise reject,覆盖网络异常和服务端错误两种情况。使用 MSW 做更真实的拦截当项目有大量 API 需要测试时,逐个 jest.mock 维护成本高。MSW(Mock Service Worker)在网络层拦截请求,不需要修改业务代码:import { rest } from 'msw';import { setupServer } from 'msw/node';const server = setupServer( rest.get('/api/users/:id', (req, res, ctx) => { return res(ctx.json({ id: req.params.id, name: 'Tom' })); }));beforeAll(() => server.listen());afterEach(() => server.resetHandlers());afterAll(() => server.close());test('getUser 通过 MSW 返回数据', async () => { const user = await getUser(1); expect(user).toEqual({ id: '1', name: 'Tom' });});MSW 的优势:可以在运行时动态修改响应(server.use()),测试超时、限流等边界场景;同一套 handler 可复用于单元测试和集成测试。关键差异速查| 场景 | 推荐方案 | 原因 ||------|---------|------|| Mock 整个第三方库 | jest.mock() | 一键替换所有导出 || 单个测试临时 Mock | jest.spyOn() | 可恢复,不影响其他测试 || Mock 全局 API(fetch) | jest.fn() / spyOn | fetch 是全局变量,需手动处理 || 大量 API 集成测试 | MSW | 网络层拦截,维护成本低 |面试追问方向jest.mock 和 jest.spyOn 的本质区别? mock 是替换,spyOn 是包装。mock 后原实现丢失,spyOn 可恢复。为什么要避免测试中发出真实请求? 网络不稳定、速度慢、可能产生脏数据、依赖外部服务可用性。Mock 污染怎么解决? beforeEach 重置、afterEach 调用 jest.restoreAllMocks()、每个测试独立设置数据。如何测试请求重试逻辑? 用 mockRejectedValueOnce 连续返回失败,最后一次返回成功,模拟重试后恢复。
服务端阅读 05月28日 05:27

如何配置 Jest?常用配置选项有哪些?

Jest 有三种配置方式:package.json 的 jest 字段、独立的 jest.config.js(或 .ts/.json/.mjs)文件、以及 CLI 参数 --config。实际项目中 90% 用 jest.config.js,因为可读性好、能写注释、支持条件逻辑。核心配置项按优先级说:testEnvironment — 决定测试运行环境。node 适合纯逻辑(工具函数、后端),jsdom 模拟浏览器 DOM(React 组件、DOM 操作)。选错会导致全局对象找不到或内存飙升。Next.js 项目用 @jest/globals 里的 customExportConditions 可以按组件区分环境。transform — 告诉 Jest 用什么转换器处理非 JS 文件。babel-jest 是默认值,TypeScript 项目换成 ts-jest 或用 @swc/jest 加速。配错了表现为 SyntaxError: Unexpected token。moduleNameMapper — 路径别名映射。配 Webpack/Vite 的 @/ 前缀、CSS/图片等静态资源的 mock 都靠它。最常见写法:'^@/(.*)$': '<rootDir>/src/$1',静态资源用 identity-obj-proxy。transformIgnorePatterns — 指定哪些文件不做转换。默认忽略整个 node_modules,但 ESM 包(如 lodash-es、axios)没编译成 CJS 就会报错。解法是用负向先行断言:'/node_modules/(?!(lodash-es|axios)/)'。setupFilesAfterEnv — 测试环境初始化后执行的脚本,用来引入 @testing-library/jest-dom 的扩展匹配器、全局 mock window.matchMedia 等。区别于 setupFiles(在测试框架加载前运行,一般用不到)。coverageThreshold — 覆盖率门禁。团队规范通常设 branches: 80, functions: 80, lines: 80,CI 中低于阈值直接失败。preset — 一键继承预置配置。ts-jest 提供 preset: 'ts-jest',React 项目用 react-app(CRA)或 @testing-library/react/jest-dom。preset 和手动配置重复时,手动配置优先。projects — monorepo 专属,每个子项目可以独立配置 testEnvironment、transform 等,Jest 并行跑所有项目。追问testEnvironment 选 node 还是 jsdom 怎么决定?跑纯函数、Node API 用 node;涉及 DOM 操作、React 渲染用 jsdom。jsdom 内存开销大,API 不完整(没有 canvas 布局、IntersectionObserver),需要额外 mock。同一个项目可以按目录分 projects 配不同环境。transformIgnorePatterns 配了但不生效怎么办?先跑 npx jest --showConfig 看实际合并后的配置,preset 可能覆盖了你的设置。常见坑:正则里的路径分隔符在 Windows 上不一致,或者忘了负向断言里的 /。清缓存 jest --clearCache 再试。Jest 跑 ESM 包一直报 SyntaxError 怎么排查?三步:1)确认 transform 配了对应转换器;2)检查 transformIgnorePatterns 是否把该包排除了忽略列表;3)如果包本身只导出 ESM,考虑用 moduleNameMapper 指向 CJS 入口或者直接 mock 掉。monorepo 里 packages 互相依赖怎么配 Jest?用 projects 配置,每个 package 指定自己的 rootDir 和 testMatch。packages 间依赖通过 moduleNameMapper 映射到源码目录而不是 dist,这样改了依赖包的代码测试立即生效。写段代码// jest.config.js — React + TS 项目典型配置module.exports = { testEnvironment: 'jsdom', transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest' }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1', '\\.(css|less)$': 'identity-obj-proxy', }, transformIgnorePatterns: [ '/node_modules/(?!(lodash-es)/)', ], setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80 }, },};