前端 TDD 驱动复杂表单开发:从红绿重构到领域模型的渐进式设计

4 阅读1分钟

前端 TDD 驱动复杂表单开发:从红绿重构到领域模型的渐进式设计

你试过给一个有 30 个字段、5 种联动规则、3 层嵌套的表单写单测吗?

我试过。上个季度接了个保险投保表单的需求,字段之间的联动关系复杂到画流程图都要用 A3 纸。一开始想着"先写完再补测试",结果写到第三天,改一个险种联动逻辑,另外三个字段的校验全崩了。改了一下午,心态也崩了。

后来硬着头皮用 TDD 重来了一遍。说实话,前两天写得比之前还慢,但到第四天开始,速度反超了。而且每次改联动规则,跑一遍测试就知道哪炸了。这篇就聊聊这个过程里我摸出来的一些路子。

先搞清楚一件事:表单的 TDD 到底测什么

很多人一提 TDD 就想到单元测试,然后就开始测 utils/validate.ts 里的纯函数。这没错,但表单的复杂度根本不在校验函数上。

表单真正难的是状态流转:字段 A 选了某个值,字段 B 要显示,字段 C 的校验规则要变,字段 D 的选项列表要重新拉接口。这些东西散落在组件各处,靠人肉追踪迟早翻车。

所以表单 TDD 的测试重心应该是:

// ❌ 只测校验函数 → 覆盖不到联动逻辑
test('手机号格式校验', () => {
  expect(validatePhone('13800138000')).toBe(true)
  expect(validatePhone('abc')).toBe(false)
})

// ✅ 测用户行为引起的状态变化 → 这才是表单的核心复杂度
test('选择企业用户时,应显示营业执照上传字段', async () => {
  const { getByLabelText, queryByLabelText } = render(<InsuranceForm />)

  // 初始状态:没有营业执照字段
  expect(queryByLabelText('营业执照')).toBeNull()

  // 用户操作:切换为企业用户
  await userEvent.selectOptions(getByLabelText('用户类型'), '企业')

  // 断言:营业执照字段出现了
  expect(getByLabelText('营业执照')).toBeInTheDocument()
})

Testing Library 的哲学在这刚好对路——它逼你从用户视角写测试,而不是从实现细节。

红绿重构循环,在表单场景里怎么转

经典 TDD 三步:红(写一个失败的测试)→ 绿(用最简单的方式让它过)→ 重构(优化代码但测试仍然通过)。

道理谁都懂,实际操作时容易犯一个错:一次写太大的测试

拿一个收货地址表单举例,需求是"省市区三级联动"。别一上来就写"选了浙江省,市下拉框里出现杭州、宁波……"这种测试,粒度太大了,第一个红灯你可能要写 50 行代码才能让它绿。

拆小一点:

// 第一轮:先让省份下拉框能渲染出来
test('渲染省份选择器', () => {
  const { getByLabelText } = render(<AddressForm />)
  expect(getByLabelText('省份')).toBeInTheDocument()
})

这时候 AddressForm 组件可能就是个空壳:

// 🔴 → 🟢 最小实现
function AddressForm() {
  return (
    <form>
      <label>
        省份
        <select aria-label="省份">
          <option value="">请选择</option>
        </select>
      </label>
    </form>
  )
}

丑吗?丑。但它绿了。接着写下一个测试:

// 第二轮:省份下拉框要有真实数据
test('省份选择器包含省份数据', async () => {
  const { getByLabelText } = render(<AddressForm />)
  const select = getByLabelText('省份')

  expect(select).toHaveDisplayValue('请选择')
  // 至少有几个省份选项
  expect(select.querySelectorAll('option').length).toBeGreaterThan(5)
})

红了。去加载省份数据,让它绿。然后第三轮再写联动:

// 第三轮:选省之后,城市下拉框出现且包含对应城市
test('选择省份后显示对应城市', async () => {
  const { getByLabelText, queryByLabelText } = render(<AddressForm />)

  // 城市选择器初始不可见(或 disabled)
  expect(queryByLabelText('城市')).toBeNull()

  await userEvent.selectOptions(getByLabelText('省份'), '浙江')

  const citySelect = getByLabelText('城市')
  expect(citySelect).toBeInTheDocument()

  const options = Array.from(citySelect.querySelectorAll('option'))
    .map(o => o.textContent)
  expect(options).toContain('杭州')
  expect(options).toContain('宁波')
})

每一轮红绿循环只推进一小步。这个节奏很重要——它让你永远知道"下一步该干啥",不会陷在"这个表单好复杂我从哪开始"的焦虑里。

重构阶段才是 TDD 真正出活的时候

很多人把 TDD 理解成"先写测试再写代码",然后就没了。但 TDD 三步里最有价值的其实是第三步——重构。

前面几轮红绿循环下来,你的组件代码大概率是一坨意面。以那个保险表单为例,我的组件里堆了一堆 if...else

// 几轮红绿循环后,代码大概长这样 —— 能跑,但看着难受
function InsuranceForm() {
  const [userType, setUserType] = useState('personal')
  const [insuranceType, setInsuranceType] = useState('')
  const [showLicense, setShowLicense] = useState(false)
  const [showHealthDecl, setShowHealthDecl] = useState(false)
  const [amountOptions, setAmountOptions] = useState([])

  useEffect(() => {
    if (userType === 'enterprise') {
      setShowLicense(true)
    } else {
      setShowLicense(false)
    }
  }, [userType])

  useEffect(() => {
    if (insuranceType === 'health') {
      setShowHealthDecl(true)
      setAmountOptions([10, 30, 50])
    } else if (insuranceType === 'property') {
      setShowHealthDecl(false)
      setAmountOptions([50, 100, 200])
    }
    // ... 后面还有一堆
  }, [insuranceType])

  // 渲染代码省略,但你能想象这里有多少 {showXxx && <Field />}
}

测试全是绿的,功能没问题。但这代码再加几个联动规则就没法维护了。

这时候该重构了。关键是——测试在手,你可以放心大胆地改结构

领域模型自然就长出来了

重构的方向是什么?把散落在组件里的业务规则抽出来,形成一个表单领域模型

这个词听着唬人,其实就是一个纯 JS 对象/类,描述"表单有哪些字段、字段之间的关系是什么、什么条件下哪些字段可见、校验规则怎么跟着变"。

// formModel.ts —— 表单的领域模型,纯逻辑,不依赖任何框架

interface FieldConfig {
  visible: boolean
  required: boolean
  options?: string[] | number[]
  validate?: (value: any) => string | null
}

type FormFields = Record<string, FieldConfig>

// 根据当前表单值,计算所有字段的配置
export function resolveFormFields(values: Record<string, any>): FormFields {
  const { userType, insuranceType } = values

  return {
    userType: { visible: true, required: true },
    insuranceType: { visible: true, required: true },

    // 营业执照:企业用户才需要
    businessLicense: {
      visible: userType === 'enterprise',
      required: userType === 'enterprise',
    },

    // 健康告知:健康险才需要
    healthDeclaration: {
      visible: insuranceType === 'health',
      required: insuranceType === 'health',
    },

    // 保额选项:跟着险种走
    amount: {
      visible: !!insuranceType,
      required: true,
      options: insuranceType === 'health' ? [10, 30, 50]
             : insuranceType === 'property' ? [50, 100, 200]
             : [],
    },
  }
}

注意看,这就是个纯函数。输入是表单当前值,输出是所有字段的配置。没有 useState,没有 useEffect,没有任何框架依赖。

这意味着你可以用最简单的单测来覆盖所有联动规则:

// formModel.test.ts
import { resolveFormFields } from './formModel'

test('企业用户需要上传营业执照', () => {
  const fields = resolveFormFields({ userType: 'enterprise', insuranceType: '' })
  expect(fields.businessLicense.visible).toBe(true)
  expect(fields.businessLicense.required).toBe(true)
})

test('个人用户不需要营业执照', () => {
  const fields = resolveFormFields({ userType: 'personal', insuranceType: '' })
  expect(fields.businessLicense.visible).toBe(false)
})

test('健康险保额选项是 10/30/50 万', () => {
  const fields = resolveFormFields({ userType: 'personal', insuranceType: 'health' })
  expect(fields.amount.options).toEqual([10, 30, 50])
})

跑起来飞快,毫秒级。而且你加新规则的时候,先写测试,再改 resolveFormFields,标准的红绿循环。

组件层就变得特别薄:

function InsuranceForm() {
  const [values, setValues] = useState({ userType: 'personal', insuranceType: '' })
  const fields = resolveFormFields(values) // 一行搞定所有联动

  return (
    <form>
      <UserTypeSelect value={values.userType} onChange={/*...*/} />

      {fields.businessLicense.visible && (
        <FileUpload label="营业执照" required={fields.businessLicense.required} />
      )}

      {fields.healthDeclaration.visible && (
        <HealthDeclForm required={fields.healthDeclaration.required} />
      )}

      {fields.amount.visible && (
        <AmountSelect options={fields.amount.options} />
      )}
    </form>
  )
}

组件只管渲染,业务逻辑全在领域模型里。之前那些 useEffect 全干掉了。

这不是我提前设计出来的架构,是重构阶段自然长出来的。这也是 TDD 的一个好处——你不需要提前做过度设计,写着写着代码会告诉你该怎么组织。

校验也塞进领域模型

字段的校验规则往往也跟联动状态有关。比如保额字段,健康险要求最低 10 万,财产险最低 50 万。

// 在 resolveFormFields 里直接带上校验函数
amount: {
  visible: !!insuranceType,
  required: true,
  options: insuranceType === 'health' ? [10, 30, 50] : [50, 100, 200],
  validate: (value: number) => {
    if (!value) return '请选择保额'
    const min = insuranceType === 'health' ? 10 : 50
    return value < min ? `最低保额 ${min} 万` : null // null 表示校验通过
  },
},

测试校验规则也是纯函数测试,不需要渲染组件:

test('健康险保额不能低于 10 万', () => {
  const fields = resolveFormFields({ userType: 'personal', insuranceType: 'health' })
  expect(fields.amount.validate!(5)).toBe('最低保额 10 万')
  expect(fields.amount.validate!(10)).toBeNull()
})

这比在组件层面用 Testing Library 去触发 blur 事件再检查错误消息快多了。当然,组件层面的集成测试还是要有几个 happy path 的,但不需要把每个边界条件都在组件层测一遍。

测试策略:金字塔在表单里长什么样

搞了一圈下来,表单场景的测试分布大概是这样:

底层(多而快) —— 领域模型的纯函数测试。联动规则、校验规则、字段配置推导,全在这层。每个跑起来就几毫秒,你加一百个也不心疼。

中间层(适量) —— 用 Testing Library 的组件测试。验证组件正确地消费了领域模型的输出,用户操作能正确触发状态变化。不需要覆盖所有分支,挑关键路径就行。

顶层(少) —— E2E 或者比较重的集成测试。整个表单填完提交,验证提交的数据结构对不对。这个跑得慢,两三个 case 够了。

之前那个保险表单,最后大概是 40 个领域模型测试 + 12 个组件测试 + 3 个提交流程测试。全跑完不到 2 秒。

Vitest 的一些实际配置

聊点具体的。Vitest 跑 Testing Library 的时候有几个坑:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'jsdom', // 别忘了装 jsdom
    setupFiles: ['./test/setup.ts'],
    globals: true, // 省得每个文件都 import { describe, test, expect }
  },
})
// test/setup.ts
import '@testing-library/jest-dom' // 提供 toBeInTheDocument 等匹配器
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup() // 每个测试后清理 DOM,不加这个偶尔会有灵异问题
})

还有一个容易踩的:userEvent 在 v14 之后需要先 setup()

import userEvent from '@testing-library/user-event'

test('填写表单', async () => {
  const user = userEvent.setup() // v14+ 必须先 setup
  const { getByLabelText } = render(<MyForm />)

  await user.type(getByLabelText('姓名'), '张三')
  await user.click(getByLabelText('提交'))
  // ...
})

之前我直接用 userEvent.click() 没 setup,一直报奇怪的 act warning,排查了一个多小时。

这套路子的边界

坦白说,TDD 在表单开发上不是银弹。

小表单别用。五六个字段、没什么联动的表单,直接写完手动测一下就行了。上 TDD 纯粹是给自己找事。

UI 细节别 TDD。按钮颜色、间距、动画这些,写测试的成本远大于收益。这些靠 Storybook 或者肉眼看更靠谱。

团队要买账。我在前公司推过一次,另外两个同事觉得写测试太慢,最后变成只有我一个人在 TDD,代码风格割裂得厉害。这东西需要团队层面达成共识。

还有一点我也没完全想清楚:领域模型这层要做到多厚?像上面的 resolveFormFields 是个纯函数,但如果表单有异步联动(比如选了省份要调接口拿城市列表),这个异步逻辑放领域模型里就没那么优雅了。目前我的做法是在模型和组件之间加一层 hook 来处理异步,但总觉得还有更好的方式。

聊到这

回过头看,TDD 在复杂表单上给我最大的收益不是"代码质量更高"这种抽象的东西,而是一个特别具体的体验:改联动规则的时候不慌了

以前改一个规则要人肉回归半天,现在跑一遍测试,哪炸了一目了然。领域模型的分离也是红绿循环逼出来的——当你发现组件层的测试写起来越来越别扭,你自然就想把逻辑抽出去。

TDD 不是目的,是个手段。它刚好能帮你在复杂表单这种"规则多、变化快、容易改出 bug"的场景里站稳。如果你手上正好有个复杂表单要做,可以拿一个模块试试红绿循环的节奏,不用一上来就全量 TDD。