发布日期

第 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=1HEADLESS=trueIS_WEBPACK_TEST=1 等环境变量,以及它们各自的"陷阱"。

1. 第一次构建:bootstrap

新拉下仓库或切换分支后,必须先跑一次全量构建

pnpm install        # 安装 workspace 依赖
pnpm build-all      # JS + Rust 全量构建(Turborepo 会智能 dedupe)

build-all 同时跑 turbo run build build-native-auto,对应:

  • JS / TSpackages/next/dist/ 与其它各包的产物。
  • Rustcrates/turbopack/ 编出的 .node binary,由 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 的测试覆盖了两个维度

维度可选值
Modedev / start / deploy
Bundlerturbopack / webpack / rspack

组合成的常用命令(见根 package.json):

命令modebundler何时用
pnpm test-dev-turbodevturbopack默认,最常用
pnpm test-dev-webpackdevwebpack需要复现 webpack 特有问题
pnpm test-start-turbostartturbopack验证 build 后行为(生产模式)
pnpm test-start-webpackstartwebpack同上,但 webpack
pnpm test-unit纯单元测试,不起浏览器
pnpm testheadless沿用当前 NEXT_TEST_MODE沿用不重新 build,直接跑

这些命令实际是脚本封装(见 package.json 第 22–55 行):

30:package.json
"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

159:test/lib/e2e-utils/index.ts
/**
 * 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 测试用这个:

351:test/lib/e2e-utils/index.ts
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-server
  • next:router-server:main
  • next: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-all1–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-turbopnpm test-start-turbo 都跑。CI 也是这样组织的。

9.3 测试日志在哪儿看

  • 测试 stdout/stderr:Jest 默认会折叠通过的测试,加 --verbose 显示。
  • Next.js 服务器日志:通过 next.cliOutput 在测试里读。
  • Browser console:通过 webdriver 的 browser.log() 读。

10. 检验问题

  1. 你刚改完 src/server/app-render/app-render.tsx 的一行代码,最快的验证方式是什么?为什么不需要 pnpm build-all
  2. pnpm test-dev-turbopnpm test-start-turbo 跑同一个测试文件,可能哪些断言会通过其一却失败另一?
  3. NEXT_SKIP_ISOLATE=1 加速了什么?牺牲了什么?什么时候绝不能加它?
  4. 写一个测试要 dev 跑、prod 跳过,怎么写?(提示:isNextStart + describe.skip
  5. 测试 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/导航类测试的高级技巧。

阶段一小结

到这里你应当掌握了:

  1. Next.js 为何而生、解决了哪些问题(第 01 讲)。
  2. Monorepo 结构,知道改哪里(第 02 讲)。
  3. 三条 CLI 命令的源码调用栈与一次请求的全链路(第 03 讲)。
  4. 本地"修改 → 验证"的最快闭环、测试体系全貌(第 04 讲)。

阶段一是地图。阶段二开始我们就要进入这张地图的具体区域了。

下一讲预告

第 05 讲|文件系统约定与 Segment 树:把 app/ 目录的 page.tsx / layout.tsx / template.tsx / route group / parallel route / intercepting route 全部拆开,看它们如何被解析成 segment tree。