---
title: "9.3 自动化工作流"
description: "Git Hooks、GitHub Actions CI、TDD 入门——让机器帮你记住每次都要检查"
chapter: "第九章"
---

# 9.3 自动化工作流

> **本节目标**：配置 Git Hooks 和 GitHub Actions，让测试自动运行；了解 TDD 的思路和适用场景。

---

## 有了测试，但忘了跑

小明花了一个下午给核心接口写了测试，跑了一遍全绿，很有成就感。然后他继续开发新功能——加了电影短评、改了搜索排序、优化了详情页加载。每次改完代码，他都想着"等会儿跑一下测试"，但每次都被下一个功能吸引过去，忘了。

三天后他把代码推到 GitHub，朋友拉下来一跑——两个测试挂了。小明回头查，发现是第一天改搜索排序的时候引入的 bug。如果他当天就跑了测试，当场就能发现；拖了三天，中间又改了一堆代码，现在要从三天的改动里找出是哪次提交引入的问题，排查成本翻了好几倍。

这个故事揭示了一个规律：**bug 发现得越晚，修复成本越高**。改完代码当场发现，你还记得刚才改了什么，修起来几分钟的事。拖到第二天，你得回忆昨天的改动。拖到一周后，你可能已经完全忘了当时的上下文，得重新理解代码才能修。自动化测试的核心价值不是"帮你找 bug"——你手动测也能找到 bug——而是**帮你尽早找到 bug**，在修复成本最低的时候。

老师傅说："人会忘，机器不会。让机器帮你记住。"

## Git Hooks——提交前自动检查

Git 有一个内置机制叫 **Hooks**（钩子）——你可以配置一些脚本，让 Git 在特定操作时自动执行。比如 `pre-commit` hook 会在每次 `git commit` 之前自动运行，如果脚本执行失败（返回非零退出码），Git 就会阻止这次提交。

<GitHooksWorkflow />

这就像门禁系统：你刷卡进门，门禁自动检查你有没有权限，不用你自己记住"要检查"。小明每次 `git commit`，Git 自动跑 `pnpm test`——测试全过，提交成功；有测试失败，提交被阻止，他必须先修好再提交。

这个机制的心理效果比技术效果更重要。小明以前总想着"等会儿跑测试"，但"等会儿"永远不会来。有了 pre-commit hook，他不需要"记住"跑测试——提交的动作本身就触发了测试。就像你不需要"记住"锁门，因为门禁系统会自动锁。把"应该做但容易忘"的事情变成"自动发生"的事情，是自动化的核心价值。

配置 Git Hooks 最常用的工具是 **Husky**。它帮你管理 hooks 脚本，不用手动去 `.git/hooks/` 目录里折腾。告诉 Claude Code 你的需求，它会帮你配好：

> 跟 AI 说："帮我配置 Git pre-commit hook，在每次 commit 前自动运行 `pnpm test`。测试失败就阻止提交。用 Husky。"

<details>
<summary>好奇的话展开看看：Husky 配置长什么样</summary>

```bash
# 安装 Husky
pnpm add -D husky

# 初始化
npx husky init

# .husky/pre-commit 文件内容
pnpm test
```

就这么简单。以后每次 `git commit`，Husky 会自动在提交前运行 `pnpm test`。测试失败，提交被阻止；测试通过，正常提交。

如果你只想跑和本次修改相关的测试（而不是全部测试），可以配合 `lint-staged` 使用——但对于小明这个规模的项目，跑全部测试也就几秒钟，没必要优化。

</details>

但 pre-commit hook 有一个局限：**它只在本地运行**。如果有人用 `git commit --no-verify` 跳过了 hook（比如赶时间想先提交再说），坏代码还是能提交上去。而且不同人的本地环境可能不一样——你的电脑上测试全过，换一台电脑可能就挂了（比如依赖了本地才有的环境变量、缓存了旧的构建产物）。所以 Git Hooks 是第一道防线，但不是唯一的防线。

小明可能会问："那 `--no-verify` 什么时候该用？"实际开发中有几个合理的场景：WIP 提交（代码还没写完，只是保存中间状态）、纯文档或注释的改动（跑测试没意义）、hook 检查的东西跟本次改动明显无关的时候。关键是**有意识地跳过**，而不是养成"每次都跳过"的习惯——如果你发现自己经常跳过 hook，那可能是 hook 配置得太重了（比如每次提交都跑全量测试，其实只需要跑相关的），应该优化 hook 本身而不是绕过它。

## GitHub Actions——推送后自动验证

代码推到 GitHub 后，**GitHub Actions** 会在一个全新的、干净的虚拟机上自动运行你的测试。这是第二道防线——即使本地跳过了 hook，CI 会拦住。

这就像工厂的质检流水线：每个产品下线前都要过检测仪，不合格的不允许出厂。工人可能偷懒跳过自检（本地 hook），但流水线上的检测仪不会放过任何一个（CI）。

GitHub Actions 的核心价值不只是"再跑一遍测试"。它在一个**干净的环境**里从零开始——安装依赖、构建项目、运行测试。这意味着它能发现"在我电脑上能跑"但实际有问题的情况：你本地有一个环境变量但忘了加到 `.env.example` 里，你本地缓存了旧的构建产物导致测试意外通过，你本地安装了某个全局工具但项目的 `package.json` 里没声明依赖。这些问题在本地发现不了，但 CI 的干净环境会暴露它们。

> 跟 AI 说："帮我写一个 GitHub Actions workflow，在每次 push 和 PR 时自动运行测试。"

<details>
<summary>好奇的话展开看看：GitHub Actions workflow 文件</summary>

```yaml
# .github/workflows/test.yml
name: Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Install dependencies
        run: pnpm install

      - name: Run tests
        run: pnpm test
```

每次你 push 代码或创建 PR，GitHub 会自动启动一个虚拟机，按照这个流程跑一遍。结果会显示在 PR 页面上——绿色勾表示通过，红色叉表示失败。

</details>

两道防线配合使用：Git Hooks 在本地拦截明显的问题（快速反馈，几秒钟），GitHub Actions 在远端做最终验证（全面检查，可能几分钟）。大部分问题在本地就被拦住了，只有少数"环境相关"的问题需要 CI 来发现。

小明第一次看到 GitHub Actions 的绿色勾出现在自己的 PR 页面上时，有一种踏实感——不是"我觉得代码没问题"，而是"一个干净环境从零验证过代码没问题"。这种确定性是本地测试给不了的，因为本地环境总有各种"只在我电脑上"的特殊条件。

还有一个实用的技巧：如果 CI 上的测试失败了但本地通过，不要急着在本地反复跑。先看 CI 的日志——它会告诉你具体哪个测试失败了、错误信息是什么。常见的原因是环境变量没配（CI 上没有 `.env` 文件，需要在 GitHub 仓库的 Settings → Secrets 里配置）、数据库连接失败（CI 上没有本地的数据库实例）、或者时区差异（CI 默认 UTC 时区，你本地可能是 UTC+8）。这些都是"环境问题"而不是"代码问题"，解决方法是让 CI 环境和本地环境保持一致。

<CIWorkflow />

## TDD——先想清楚再动手

小明要给"个人豆瓣"加一个新功能：电影短评。用户可以给电影写一段文字评论，最多 500 字。老师傅建议："这次试试先写测试。"

小明困惑："代码都没写，怎么测？"

老师傅说："你先想清楚这个接口应该接收什么、返回什么——这就是测试。"

小明想了想：`POST /api/movies/42/reviews` 接收 `{ content: "很好看，特效震撼" }`，返回 `201` 和评论对象。未登录返回 `401`。内容为空返回 `400`。内容超过 500 字返回 `400`。

他把这些写成测试代码。测试当然全部失败了——接口还不存在。然后他告诉 Claude Code："这是我写好的测试文件，帮我实现对应的 API，让所有测试通过。"

写完他发现一个有趣的事：因为先想清楚了接口设计（接收什么参数、返回什么状态码、拒绝什么输入），代码写起来特别顺畅。不用边写边改接口，不用写到一半发现"这个参数应该叫别的名字"。测试文件本身就是一份**接口规格说明**——它精确地描述了接口的行为，比口头描述或文档更准确，因为它是可执行的。如果接口的行为和测试描述的不一致，测试会立刻报红。

这就是 **TDD（测试驱动开发）** 的核心思路：先写测试（定义"什么是正确的"），再写代码（让测试通过），最后重构（优化代码，保持测试通过）。就像先画图纸再盖房子——不会先砌墙再想门开在哪。

TDD 的三个步骤有专门的名字：**Red**（写测试，测试失败，因为代码还不存在）→ **Green**（写最少的代码让测试通过）→ **Refactor**（优化代码结构，保持测试通过）。"最少的代码"这个要求很重要——它防止你过度设计。你不需要一开始就考虑"如果以后要支持半星评分怎么办"，先让当前的测试通过就行。以后需求变了，先改测试（定义新的"正确"），再改代码。

但 TDD 不是教条。它最适合的场景是：**核心业务逻辑**（评分计算、权限判断——算错了后果严重）、**需要稳定接口的模块**（API 端点——接口设计确定了，后面不容易改）、**复杂的条件分支**（多种输入对应多种输出——先把所有情况列出来再写代码，不容易漏）。

不适合 TDD 的场景也很明确：**UI 样式调整**（按钮颜色、间距这些，写测试没意义）、**探索性原型**（需求还没定，今天写的测试明天可能全废）、**一次性脚本**（跑一次就扔的数据迁移脚本，不值得写测试）。

小明不需要所有代码都用 TDD。但对于核心接口，"先写测试再写代码"这个习惯值得培养——它迫使你在动手之前先想清楚"这个东西应该怎么工作"。

而且 TDD 和 AI 编程配合得特别好。传统 TDD 的痛点是"写测试本身很费时间"，但现在你可以用自然语言描述接口行为，让 AI 帮你生成测试代码。你的工作从"写测试代码"变成"描述接口应该怎么工作"——这其实就是设计。然后把测试文件交给 AI，让它实现代码。测试文件成了你和 AI 之间的"契约"——你定义行为，AI 实现行为，测试验证行为。这个工作流比"先写代码再补测试"高效得多，因为测试在前面就定义好了验收标准，AI 不需要猜你想要什么。

<details>
<summary>好奇的话展开看看：TDD 的完整流程</summary>

```typescript
// 第一步：先写测试（RED——测试失败，因为接口还不存在）
describe('POST /api/movies/:id/reviews', () => {
  test('已登录用户提交短评', async () => {
    const res = await request(app)
      .post('/api/movies/42/reviews')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ content: '很好看，特效震撼' })

    expect(res.status).toBe(201)
    expect(res.body.content).toBe('很好看，特效震撼')
    expect(res.body.movieId).toBe(42)
  })

  test('内容为空返回 400', async () => {
    const res = await request(app)
      .post('/api/movies/42/reviews')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ content: '' })

    expect(res.status).toBe(400)
  })

  test('内容超过 500 字返回 400', async () => {
    const res = await request(app)
      .post('/api/movies/42/reviews')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ content: 'a'.repeat(501) })

    expect(res.status).toBe(400)
  })

  test('未登录返回 401', async () => {
    const res = await request(app)
      .post('/api/movies/42/reviews')
      .send({ content: '很好看' })

    expect(res.status).toBe(401)
  })
})

// 第二步：写代码让测试通过（GREEN）
// 告诉 Claude Code："这是我的测试文件，帮我实现对应的 API。"

// 第三步：重构（REFACTOR）
// 测试全绿后，优化代码结构，每次改完跑测试确认没破坏功能。
```

</details>

## 从"打地鼠"到"安全网"

回到小明的故事。一个月前，他改代码心惊胆战——每次提交都担心"会不会又坏了什么"。朋友的 bug 报告是他唯一的反馈渠道，而且总是迟到的。

现在不一样了。他的核心接口有 API 测试，关键流程有 E2E 测试。每次 `git commit`，Husky 自动跑测试——全绿，放心提交。代码推到 GitHub，Actions 再跑一遍——全绿，放心合并。某个测试红了？当场就知道是哪次改动引入的问题，不用等朋友来报 bug。

测试不是负担，是让你**敢改代码的底气**。没有测试的时候，你不敢重构——万一改坏了呢？有了测试，你可以大胆重构——改完跑一遍，绿了就没问题。测试覆盖越全，你改代码的信心越足。这种信心的转变是渐进的：第一次看到测试帮你拦住一个 bug（"幸好有测试，不然这个 bug 就上线了"），你就会理解测试的价值不是理论上的，而是实实在在的。

这不是一夜之间建成的。小明的测试是逐步积累的：先给最容易出问题的接口写测试，再给核心流程加 E2E，然后配上 Git Hooks 和 CI。每次遇到一个 bug，修完之后顺手加一个测试——确保同样的 bug 不会再出现。这个策略叫"bug 驱动测试"：不是提前预测所有可能的 bug，而是每次被咬一口就补一个防护。测试套件就这样慢慢长大，安全网越织越密。

不需要追求完美的测试覆盖率。80% 的风险可以用 20% 的测试覆盖。先把最关键的部分保护起来，剩下的慢慢补。重要的是**开始**——哪怕只有一个测试，也比零个强。从今天开始，下次你修完一个 bug，顺手写一个测试确保它不会再出现。这就是你的第一步。

---

::: tip 本节核心要点
- Git Hooks（Husky）：提交前自动跑测试，测试失败阻止提交——第一道防线
- GitHub Actions：推送后在干净环境里跑测试，发现"在我电脑上能跑"的问题——第二道防线
- TDD 适合核心业务逻辑和稳定接口：先写测试定义行为，再写代码实现——不是教条，是工具
- 测试是逐步积累的：先保护最关键的部分，每次修 bug 顺手加测试，安全网越织越密
:::

::: info 下一步
第九章到这里就结束了。你已经有了测试的基本功和自动化工作流。接下来去 [第十章：Localhost 与公网访问](../10-localhost-public-access/index.md)——把你的应用从本地搬到互联网上。
:::
