让 AI 自己把前端改到对:用 Store TDD 驱动 AI 闭环

7 阅读10分钟

TL;DR

  • AI 写前端的瓶颈不是模型能力,是没有反馈回路
  • 前端复杂性在数据/状态层。分离状态逻辑,AI 就能自我验证
  • Store TDD 两阶段:AI 写行为契约 + 场景测试 → AI 实现 Store,人只审契约
  • 不是技术创新,是前端测试动机的转折点:从"应该做"变成"必须做"

AI 写前端 = 盲写

AI 生成了一个漂亮的组件,JSX 没毛病,样式也对——但一跑起来,提交没校验、loading 不同步、错误后重试发两次请求。你让它改,它改出新 bug。再改,另一个地方又坏了。

问题不在 AI 的能力,在于没有反馈回路。 它不知道自己写对了没有,因为没有东西能告诉它。

这篇文章不讲更好的 prompt,讲更好的架构——让 AI 能自我验证前端代码。


这些场景你一定经历过

  • "看起来对但逻辑全错"——JSX 结构没问题,但校验没做、状态不同步、重试发两次请求、切 tab 状态丢了
  • "改一个 bug,出三个新 bug"——逻辑散落在 useState + useEffect + handler 各处,AI 改一个地方不知道会影响哪里
  • "500 行组件 review 不过来"——状态、业务、UI 混在一起,得在脑子里模拟整个渲染流程
  • "AI 没有浏览器,怎么知道写对了"——没有页面、没有点击、没有交互,本质上就是盲写
  • "换个对话窗口全忘了"——又开始写 useState,又在组件里混逻辑,之前的模式全丢
  • "复杂功能一口气写完,质量惨不忍睹"——多步表单、权限审批、实时协作,一口气出来就是一团乱码

根因就一个:AI 没有自我纠错能力。


前端的复杂性在数据层,不在 UI 层

真正复杂的前端功能——多步表单、实时同步、权限联动、乐观更新——全是数据层问题

动画、布局、手势?要么纯 CSS 不涉及状态,要么最终落到状态变更上。UI 迭代改进从来都是最简单的部分。

把状态逻辑从 UI 中分离出来,AI 就能在无 DOM 环境下完成"写 → 测 → 改"的闭环。

状态逻辑(Store)→ AI 写 + AI 测 + AI 修正  ← 闭环,质量可保证
UI 层(组件)    → AI/人写,人看效果          ← 开环,但只需要看视觉

人的注意力从"检查所有东西"缩小到"只看视觉和交互体验"。

但"分离状态逻辑"不是拆成一堆零散的 atom 和 slice。React 社区这几年越来越倾向原子化——状态碎片化到各个独立的 store,计算散落在 useMemo 和 selector 里,行为散落在 useEffect 和 handler 里。对人来说,这只是"跳几个文件"的事;对 AI 来说,碎片化就是致命的——它不知道这些碎片之间的关系,不知道改一个 slice 会不会影响另一个 hook 里的派生逻辑。

我们需要的是领域 Store——一个功能的 State、Computed、Action 收敛到一个地方

领域 Store = 数据(State)+ 派生(Computed)+ 行为(Action)

一个领域 Store 就是一个完整的业务单元。AI 读一个文件就能理解整个功能的数据流,什么是 action、什么是 observable、什么是内部实现——全在一个地方写清楚了。new Store() + 调 action + 断言 getter,不依赖 React,不需要 mock DOM。

领域 Store 不只是代码组织方式,它是 AI 能理解的最小业务单元。 下面的 Store TDD 之所以能交给 AI,正是因为领域 Store 把"一个功能该做什么"表达成了 AI 可以读懂、可以测试、可以实现的形式。


Store TDD:两阶段行为优先开发

经过迭代,最终定型为行为优先 + 场景测试,两个阶段都交给 AI:

阶段 1:行为契约 + 场景测试(AI 写)

这一步整个交给 AI。输入可以是 PRD、需求文档、甚至口头描述。AI 做两件事:

  1. 提炼行为契约——梳理出用户操作(Action)和可观察输出(Observable)
  2. 按用户旅程写场景测试——每个 it() 是一条完整操作路径

关键原则:不提前定义 State 结构。 State 是实现细节,测试断言 observable(getter),不断言 store.state.xxx。Action 是用户操作,不是 setter:

// ❌ 暴露裸 setter(测试它没意义)
setSelectedId(id) {}
setLoading(loading) {}

// ✅ 暴露用户操作(测试完整后果链)
selectGame(appCode) {}   // 切换选中 + 拉取 checklist + 重算派生值
loadPage() {}            // 拉取列表 + 初始化状态
submitStep(id, data) {}  // 校验 + 调 API + 处理结果

AI 生成的测试按用户旅程组织,不按方法名:

// ❌ 按接口测试:你在测 Immer
describe('setSelectedId', () => {
  it('should update selectedId', () => {
    store.setSelectedId('abc')
    expect(store.state.selectedId).toBe('abc')
  })
})

// ✅ 按场景测试:你在测功能
describe('用户选择游戏', () => {
  beforeEach(async () => {
    await store.loadPage()  // 前置条件通过 action 建立
  })

  it('选中后应更新选中状态并拉取 checklist', async () => {
    await store.selectGame('game-a')
    expect(store.selectedGame?.name).toBe('Game A')
    expect(store.checklist).toHaveLength(3)
    expect(store.canSubmit).toBe(false)
  })

  it('切换游戏时应重置已填写的步骤', async () => {
    await store.selectGame('game-a')
    await store.submitStep('step-1', { data: 'done' })
    await store.selectGame('game-b')
    expect(store.completedSteps).toHaveLength(0)
  })
})

测试的"单元"是功能,不是类。用户不关心你有几个 Store,只关心"我点了切换游戏,checklist 和消息都刷新了"。

注意看这些测试用例:loadPage()selectGame('game-a')submitStep(...)selectGame('game-b')——它就是用户的操作路径。每一个 it() 都是一条完整的用户旅程,每一个 expect() 都是用户能观察到的结果。

这意味着场景测试天然就是最精确的需求文档。给 AI 一份 PRD,它可能理解偏了;给它测试用例,操作路径和预期结果写得明明白白,没有歧义空间。测试用例既是验证工具,也是最好的 prompt。

阶段 2:实现 Store(AI 写)

测试有了,State 结构自然涌现——AI 读完测试里的 getter 断言(store.selectedGamestore.canSubmitstore.completedSteps),反推出需要哪些字段、什么类型。State 不是设计出来的,是被测试逼出来的:

// State 在实现时才定义,测试不关心它长什么样
interface GameFlowState {
  games: Game[]
  selectedAppCode: string | null
  checklist: ChecklistItem[]
  completedSteps: Record<string, StepData>
  loading: boolean
}

class GameFlowStore extends ZenithStore<GameFlowState> {
  constructor(private api: GameAPI) {
    super({
      games: [],
      selectedAppCode: null,
      checklist: [],
      completedSteps: {},
      loading: false,
    })
  }

  // Observable:测试断言这些 getter,不关心内部结构
  @memo((s) => [s.state.games, s.state.selectedAppCode])
  get selectedGame() {
    return this.state.games.find(g => g.appCode === this.state.selectedAppCode) ?? null
  }

  @memo((s) => [s.state.checklist, s.state.completedSteps])
  get canSubmit() {
    return this.state.checklist.length > 0
      && this.state.checklist.every(item => item.id in this.state.completedSteps)
  }

  // Action:用户操作,测试验证完整后果链
  async selectGame(appCode: string) {
    this.produce(draft => {
      draft.selectedAppCode = appCode
      draft.completedSteps = {}
    })
    const checklist = await this.api.fetchChecklist(appCode)
    this.produce(draft => { draft.checklist = checklist })
  }

  async loadPage() {
    this.produce(draft => { draft.loading = true })
    const games = await this.api.fetchGames()
    this.produce(draft => {
      draft.games = games
      draft.loading = false
    })
  }

  async submitStep(id: string, data: StepData) {
    this.produce(draft => { draft.completedSteps[id] = data })
    await this.api.submitStep(id, data)
  }
}

红了就改,绿了就对。


角色分工

步骤谁做输入输出
行为契约 + 场景测试AIPRD / 需求描述 / 已有测试用例store.spec.ts
实现 StoreAI测试store.ts(测试全绿)
Review测试 + Store确认契约合理、测试覆盖完整
UI 接线人/AIStore API组件代码

人的工作从"写契约"变成了"审契约"。 AI 根据 PRD 生成行为契约和测试,人审一遍就行。审什么?几个要点:

  • action 列表是否覆盖了所有用户操作?有没有漏掉的入口?
  • 场景是否包含异常路径?加载失败、并发冲突、空数据、权限不足
  • getter 断言的是用户可感知的结果(selectedGame.name)还是内部字段(state.selectedAppCode)?
  • 前置条件是否通过 action 建立,而不是直接塞 state?

通过了,AI 就对着测试写实现——第一次有了自我纠错能力


自由发挥 vs Store TDD

没有约束时,AI 优化的是"看起来完成了"的速度:

  • 状态管理走捷径——useState + useEffect 搞定,"看起来完成了"
  • 异步只写 happy path——不处理失败、重试、并发取消
  • 边界情况直接跳过——"先跳过后面再补"(然后就不补了)
  • 计算属性偷懒内联——该抽 getter 的派生逻辑,直接在 JSX 里 .filter().map()

AI 不是不知道该写错误处理,是没有东西逼它写。测试就是那个东西。 场景测试里有"加载失败"、"重试恢复",不实现就不绿。

自由发挥优化"看起来完成了"的速度。Store TDD 优化"实际正确"的概率。


为什么不用 React Testing Library

组件测试能做,但成本高:DOM 模拟要配置维护,act() / waitFor() 仪式感重,改个 DOM 结构测试就挂,AI 写 RTL 测试经常和实现细节耦合。

Store 测试?new Store() + 调 action + 断言 getter。没有 React,没有 DOM,没有渲染。 跑完不到一秒。


适用边界

这套模式不是万能的:

场景适合吗原因
多步表单、权限联动、乐观更新✅ 非常适合复杂状态逻辑,Store TDD 收益最大
异步数据流、实时同步✅ 适合异步和并发正是测试最能保护的地方
纯展示页面、静态内容❌ 不需要没有复杂状态,直接写组件更快
几个字段的简单表单❌ 不值得Store 化成本高于直接写组件
复杂手势/动画交互⚠️ 部分适用状态变更部分适用,视觉效果仍需人看

两个前提:

  1. 需求要清晰——行为契约质量决定一切,模糊需求产出的契约会跑偏
  2. Store 设计仍需人参与——哪些是 action、粒度多大、状态怎么分,这是设计判断

重构韧性

这套模式还有一个隐性收益:测试不绑定内部实现,重构随便来。

把内部变量名从 msgList 改成 messages,只要 getter 没变,测试一个不挂。把两个 Store 合并成一个,只要 action 和 observable 没变,测试一个不挂。

这在 AI 辅助开发里尤其重要——你可以放心让 AI 重构内部实现,测试会替你兜底。

如果你熟悉测试理论,会发现这套模式并不新鲜:Store 是可测试对象,React 组件是只做传声筒的 Humble Object;测试断言状态结果而非交互过程,是典型的底特律派 TDD;从外部行为契约出发再推导内部实现,是 Outside-In 设计。每个元素都有几十年的积累,只是在 AI 辅助开发的语境下,它们恰好组合成了一个闭环。


不是里程碑,是转折点

TDD 几十年了,MVC/MVVM 也几十年了。每一个元素都不新。

新的是测试的目的。

传统测试:帮人捉 bug。这套模式:给 AI 一个反馈回路——AI 写代码,AI 跑测试,AI 自己知道对不对。


结语

前端测试推了这么多年推不动,根本原因是成本收益不划算——写测试的人和受益的人是同一个人,投入产出算不过来。

AI 改变了这个等式。写测试的成本没变,但受益方多了一个:AI 本身。有测试,AI 就能自我纠错;没有测试,AI 每写一行你都得盯着看。测试从"应该做"变成了"必须做"。

越来越多用 AI 写前端的团队会发现同一件事:必须把状态逻辑从 UI 中分离出来。不是架构洁癖,是不分离就没法让 AI 高效工作。

AI 会倒逼前端架构向更可测试的方向演进。 这不是预测,是正在发生的事。


代码示例基于 Zenith。核心思路只有一个前提:状态逻辑能脱离 UI 独立测试。