- 发布日期
第 04 讲|本地开发环境与测试体系
搭建本地开发调试环境,掌握 Next.js 测试体系(jest / playwright / e2e)
阶段一:框架认知与源码导航 · 第 4 / 40 讲 难度:⭐⭐ · 预计耗时:1.5 小时
学习目标
- 搭好"修改源码 → 跑测试 → 看效果"的 30 秒内迭代环路。
- 理解 4 种测试矩阵(
test-dev-turbo/test-dev-webpack/test-start-turbo/test-start-webpack)何时用哪一种。 - 掌握
nextTestSetup/isNextDev/isNextStart等测试工具的语义。 - 知道何时使用
NEXT_SKIP_ISOLATE=1、HEADLESS=true、IS_WEBPACK_TEST=1等环境变量,以及它们各自的"陷阱"。
1. 第一次构建:bootstrap
新拉下仓库或切换分支后,必须先跑一次全量构建:
pnpm install # 安装 workspace 依赖
pnpm build-all # JS + Rust 全量构建(Turborepo 会智能 dedupe)
build-all 同时跑 turbo run build build-native-auto,对应:
- JS / TS →
packages/next/dist/与其它各包的产物。 - Rust →
crates/与turbopack/编出的.nodebinary,由build-native-auto任务负责(在 macOS/Linux/Windows 上调用cargo build后复制到packages/next-swc/native/)。
切换分支后跳过这一步是最常见的"我本地没问题"陷阱。AGENTS.md 「Rebuilding Before Running Tests」明确要求:分支切换或不确定时一律
pnpm build-all。
2. 快速迭代:watch mode
如果你只改 packages/next/src/ 下的 TypeScript:
pnpm --filter=next dev
这条命令在 packages/next/ 内启动 taskr 的 watch(taskr 是 Next.js 自己 vendor 的 task runner,配置在 packages/next/taskfile.js),文件改动后 1–2 秒就能重新编译到 dist/。
重要:这只重编 TS,不会重编 Rust。如果你改了
crates/或turbopack/,必须重新跑pnpm build-all。
2.1 watch + 直接跑 dev server 的最小实验
# Terminal 1:watch 构建(持续运行)
pnpm --filter=next dev
# Terminal 2:跑一个 fixture
cd test/e2e/lecture-01 # 上一讲生成的,没有就重新 pnpm new-test
node ../../../packages/next/dist/bin/next.js dev --port 3000
改 packages/next/src/... 的任何文件,1–2 秒后 dist 更新;浏览器刷新(或对 dev server 用 curl 重新发请求)就能立刻看到变更。这是源码学习阶段最快的迭代方式。
不会触发 Next.js dev server 自重启——
next dev只监听用户项目代码(app/、pages/、next.config.js),不监听node_modules/next/dist/。所以要么手动重启,要么touch next.config.js触发。
3. 测试体系:4 种 mode × bundler 矩阵
Next.js 的测试覆盖了两个维度:
| 维度 | 可选值 |
|---|---|
| Mode | dev / start / deploy |
| Bundler | turbopack / webpack / rspack |
组合成的常用命令(见根 package.json):
| 命令 | mode | bundler | 何时用 |
|---|---|---|---|
pnpm test-dev-turbo | dev | turbopack | 默认,最常用 |
pnpm test-dev-webpack | dev | webpack | 需要复现 webpack 特有问题 |
pnpm test-start-turbo | start | turbopack | 验证 build 后行为(生产模式) |
pnpm test-start-webpack | start | webpack | 同上,但 webpack |
pnpm test-unit | — | — | 纯单元测试,不起浏览器 |
pnpm testheadless | 沿用当前 NEXT_TEST_MODE | 沿用 | 不重新 build,直接跑 |
这些命令实际是脚本封装(见 package.json 第 22–55 行):
"test-dev": "scripts/run-jest.sh --mode=dev --bundler=webpack --headless --",
"test-dev-webpack": "scripts/run-jest.sh --mode=dev --bundler=webpack --headless --",
"test-dev-experimental": "scripts/run-jest.sh --mode=dev --bundler=webpack --experimental --headless --",
"test-dev-experimental-webpack": "scripts/run-jest.sh --mode=dev --bundler=webpack --experimental --headless --",
"test-dev-rspack": "scripts/run-jest.sh --mode=dev --bundler=rspack --headless --",
"test-dev-experimental-rspack": "scripts/run-jest.sh --mode=dev --bundler=rspack --experimental --headless --",
"test-dev-turbo": "scripts/run-jest.sh --mode=dev --bundler=turbo --headless --",
"test-dev-experimental-turbo": "scripts/run-jest.sh --mode=dev --bundler=turbo --experimental --headless --",
"test-start": "scripts/run-jest.sh --mode=start --bundler=webpack --headless --",
它们最终都进入 scripts/run-jest.sh,设置好 NEXT_TEST_MODE / IS_WEBPACK_TEST / TURBOPACK 等 env 后启动 jest。
3.1 dev 和 start 测的是不同代码路径
isNextDev / isNextStart 来自 process.env.NEXT_TEST_MODE:
/**
* Whether the test is running in development mode.
* Based on `process.env.NEXT_TEST_MODE` and the test directory.
*/
export const isNextDev = testMode === 'dev'
/**
* Whether the test is running in deploy mode.
* Based on `process.env.NEXT_TEST_MODE`.
*/
export const isNextDeploy = testMode === 'deploy'
/**
* Whether the test is running in start mode.
* Default mode. `true` when both `isNextDev` and `isNextDeploy` are false.
*/
export const isNextStart = !isNextDev && !isNextDeploy
测试代码可以用它来 skip:
import { nextTestSetup, isNextStart } from "e2e-utils";
const { next } = nextTestSetup({ files: __dirname });
(isNextStart ? describe : describe.skip)("only-prod-feature", () => {
it("...", async () => {});
});
3.2 webpack 还是 turbopack?
Turbopack 是 dev / build 的默认 bundler,对应 test-dev-turbo / test-start-turbo。出现以下情况时切到 webpack:
- 复现 CI 上
IS_WEBPACK_TEST=1的失败(见pr-status.js给的 job env)。 - 测试 webpack 特有插件(如 SubresourceIntegrityPlugin、ProfilingPlugin)。
- 模块解析、
node:*import 行为差异(webpack 与 turbopack 走不同的 resolver)。
4. 测试工具核心 API
4.1 nextTestSetup
90% 的 e2e 测试用这个:
export function nextTestSetup(
options: Parameters<typeof createNext>[0] & {
skipDeployment?: boolean
dir?: string
}
): {
isNextDev: boolean
isNextDeploy: boolean
isNextStart: boolean
isTurbopack: boolean
isRspack: boolean
next: NextInstance
skipped: boolean
} {
let skipped = false
if (options.skipDeployment) {
// When the environment is running for deployment tests.
if (isNextDeploy) {
// eslint-disable-next-line jest/no-focused-tests
it.only('should skip next deploy', () => {})
// No tests are run.
skipped = true
}
}
let next: NextInstance | undefined
if (!skipped) {
beforeAll(async () => {
next = await createNext(options)
})
afterAll(async () => {
// Gracefully destroy the instance if `createNext` success.
// If next instance is not available, it's likely beforeAll hook failed and unnecessarily throws another error
// by attempting to destroy on undefined.
await next?.destroy()
})
}
用法:
import { nextTestSetup } from "e2e-utils";
import { retry } from "next-test-utils";
describe("my feature", () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname, // 指向当前测试文件所在目录(必须包含 app/ 或 pages/)
});
it("should render", async () => {
const $ = await next.render$("/");
expect($("h1").text()).toBe("Hello");
});
});
next 是一个 NextInstance(test/lib/next-modes/base.ts 派生),提供:
next.render(path)/next.render$(path)/next.fetch(path)next.start()/next.stop()/next.destroy()next.patchFile()、next.deleteFile()(dev 模式下热修改文件)next.cliOutput(累积的服务端日志)
4.2 retry 而非 setTimeout
测试涉及异步(hydration / streaming / HMR)时绝不要 await new Promise(r => setTimeout(r, 1000))。用 retry:
import { retry } from "next-test-utils";
await retry(async () => {
const text = await browser.elementByCss("p").text();
expect(text).toBe("expected value");
});
retry 会按指数退避重试 ~30 秒,失败时报最后一次的错。这是仓库内强制约定——AGENTS.md 「Writing Tests」一节有强调。
4.3 fixture 目录 vs inline files
首选:用 fixture 目录。
const { next } = nextTestSetup({ files: __dirname });
测试文件同级放 app/page.tsx 等真实文件。优点:编辑器有语法高亮、可以单独跑、其他人易维护。
慎用:inline files 对象。
const { next } = nextTestSetup({
files: {
"app/page.tsx": `export default function Page() { return <h1>Hi</h1> }`,
},
});
只在测试逻辑非常简单、不需要类型检查时用。
4.4 生成新测试:pnpm new-test
仓库强制要求用脚手架生成新测试(保证目录结构、文件命名规范一致):
# 交互式
pnpm new-test
# 非交互式(AI agent 友好):appDir / name / type
pnpm new-test -- --args true my-feature e2e
type 取值:e2e / production / development / unit。AGENTS.md 明确说:「Generating tests using pnpm new-test is mandatory.」
5. 关键环境变量与陷阱
5.1 NEXT_SKIP_ISOLATE=1
"skip packing Next.js for each test (~100s faster)"
正常 nextTestSetup 会 把 packages/next pack 成 tarball 装到 fixture 里——这模拟真实 npm 用户体验,能暴露模块解析问题。NEXT_SKIP_ISOLATE=1 跳过这步,直接 symlink 本地 dist/,快但会隐藏 require 路径 / package.json exports / nft trace 类问题。
# 快速迭代时
NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=dev pnpm testheadless test/e2e/foo.test.ts
# 验证模块解析 / build trace 问题时(必须)
pnpm test-dev-turbo test/e2e/foo.test.ts
5.2 HEADLESS=true
控制 Playwright 是否启动 GUI。CI 上一律 HEADLESS=true;本地复现 UI bug 时去掉它能看到浏览器。
5.3 IS_WEBPACK_TEST=1
强制走 webpack。复现 CI 上某些 webpack-only 失败必须设置。
5.4 __NEXT_SHOW_IGNORE_LISTED=true
默认 dev overlay / 错误日志会把"Next.js 内部栈帧"折叠为 at ignore-listed frames。设置这个变量能看到完整调用栈——调试 framework 内部 bug 必备。
__NEXT_SHOW_IGNORE_LISTED=true node packages/next/dist/bin/next.js dev
5.5 DEBUG=next:*
启用 debug log。常用子项:
next:start-servernext:router-server:mainnext:cache*next:hot-reloader*
6. 一次"读 → 改 → 测"的完整闭环
把前面所有工具组装成一个工作流:
步骤 1:在源码加观察
# Terminal 1
pnpm --filter=next dev
在 packages/next/src/server/app-render/app-render.tsx 加:
console.log("[lecture-04] enter app-render at", new Date().toISOString());
保存。
步骤 2:捕获测试输出到文件
# Terminal 2
HEADLESS=true pnpm test-dev-turbo test/e2e/app-dir/app/index.test.ts \
> /tmp/test-output.log 2>&1
注意 AGENTS.md 的"Analyzing test output efficiently"原则:只跑一次、保存到文件、之后用 grep 分析,不要反复 re-run。
步骤 3:从日志读出我们的注入
grep '[lecture-04]' /tmp/test-output.log | head -20
如果看到日志,说明源码生效了;如果没看到,回去检查 watch 是否还在跑。
步骤 4:清理观察
git diff packages/next/src/server/app-render/app-render.tsx
git checkout packages/next/src/server/app-render/app-render.tsx
7. 类型检查 vs 完整构建
| 命令 | 时间 | 用途 |
|---|---|---|
pnpm --filter=next types | ~10 秒 | 只跑 tsc,验证类型 |
pnpm --filter=next build | ~60 秒 | 编译 + 复制 + 生成 d.ts |
pnpm build-all | 1–5 分钟(首次) | JS + Rust 全量 |
pnpm --filter=next dev (watch) | 持续 | 增量重编(仅 TS) |
调试规则:
- 只改了类型 →
pnpm --filter=next types。 - 改了 TS 实现 →
pnpm --filter=next build或保持 watch。 - 改了 Rust 或不确定 →
pnpm build-all。
8. 测试目录结构
test/
├── unit/ ← 纯单元测试,没有 next instance
├── development/ ← 一定在 dev 模式下跑
├── production/ ← 一定在 prod 模式下跑(需要 build)
├── e2e/ ← dev / start 都跑(最常见)
└── examples/ ← 跑 examples/ 下的真实应用
- e2e 是最通用的层;development / production 是为了强制某一个 mode。
- 写测试时优先选 e2e,除非你的测试逻辑只在某一种 mode 下有意义。
9. 重难点
9.1 测试速度 vs 真实性的权衡
- 「我只想快速验证逻辑」→
NEXT_SKIP_ISOLATE=1 pnpm testheadless ... - 「我要验证 require / nft trace」→ 必须用标准
pnpm test-dev-turbo(不带 SKIP_ISOLATE) - 「我要复现 CI 失败」→ 完全照搬 CI env(见 PR-status 输出)
9.2 跨 mode 行为不一致
某些 API 在 dev 与 start 下行为不同(比如 React DevTools、Server HMR cache、source map),所以一个 feature 经常要同时 pnpm test-dev-turbo 和 pnpm test-start-turbo 都跑。CI 也是这样组织的。
9.3 测试日志在哪儿看
- 测试 stdout/stderr:Jest 默认会折叠通过的测试,加
--verbose显示。 - Next.js 服务器日志:通过
next.cliOutput在测试里读。 - Browser console:通过 webdriver 的
browser.log()读。
10. 检验问题
- 你刚改完
src/server/app-render/app-render.tsx的一行代码,最快的验证方式是什么?为什么不需要pnpm build-all? pnpm test-dev-turbo与pnpm test-start-turbo跑同一个测试文件,可能哪些断言会通过其一却失败另一?NEXT_SKIP_ISOLATE=1加速了什么?牺牲了什么?什么时候绝不能加它?- 写一个测试要 dev 跑、prod 跳过,怎么写?(提示:
isNextStart+describe.skip) - 测试 stale 时为什么用
retry()不用setTimeout()?哪个 API 不该用?(check())
11. 延伸阅读
- 仓库根
AGENTS.md的「Fast Local Development」「Testing」「Writing Tests」「Test Gotchas」整段。 .claude/skills/pr-status-triage/SKILL.md:复现 CI 失败时如何镜像 env。.claude/skills/router-act/SKILL.md:写 prefetch/导航类测试的高级技巧。
阶段一小结
到这里你应当掌握了:
- Next.js 为何而生、解决了哪些问题(第 01 讲)。
- Monorepo 结构,知道改哪里(第 02 讲)。
- 三条 CLI 命令的源码调用栈与一次请求的全链路(第 03 讲)。
- 本地"修改 → 验证"的最快闭环、测试体系全貌(第 04 讲)。
阶段一是地图。阶段二开始我们就要进入这张地图的具体区域了。
下一讲预告
第 05 讲|文件系统约定与 Segment 树:把 app/ 目录的 page.tsx / layout.tsx / template.tsx / route group / parallel route / intercepting route 全部拆开,看它们如何被解析成 segment tree。