Arrange-Act-Assert 测试结构
AAA 是跨语言通用的测试组织模式:先准备上下文,再执行唯一关键行为,最后验证可观察结果,使测试意图清晰且容易定位失败。
[!info] related notes
- 所属 MOC: testing-moc
- 前置概念:
- 并列概念:
- 易混淆概念:
- 关系笔记:
Arrange-Act-Assert 测试结构
一句话定义
Arrange-Act-Assert(AAA)是一种组织单个测试案例的通用思维模板:
Arrange 准备测试世界
↓
Act 执行这条测试唯一关心的行为
↓
Assert 验证外部可观察结果
它不是 Vitest、pytest、JUnit 或 Go testing 的专属语法,而是跨语言、跨框架的测试设计模式。
为什么 AAA 有用
测试最常见的问题不是“不会写断言”,而是一个测试里混杂了太多准备、行为和验证,读者无法快速回答:
- 这个场景的前提是什么?
- 真正发生了什么动作?
- 最后什么结果必须成立?
AAA 把这三个问题显式分开,让测试既是自动验证,也是行为文档。
Arrange:准备测试世界
Arrange 负责建立测试执行前所需的上下文,例如:
- 构造输入数据
- 创建对象或组件
- 准备 fixture
- 创建测试数据库记录
- 创建 React Provider / Test Harness
- Stub 或 Mock 外部依赖
- 设置 spy
- 准备用户交互对象
例如 React:
const review = vi.spyOn(workspaceApi, "reviewFact").mockResolvedValue({
fact: {} as never,
});
const user = userEvent.setup();
render(<BodyStateWorkbench ... />);
例如 Python:
user = User(balance=100)
service = PaymentService(fake_gateway)
Arrange 的目标不是“把所有内部状态都手工设置好”,而是建立足以触发目标行为的最小世界。
Act:执行唯一关键行为
Act 是这条测试真正要验证的动作,通常应尽量只有一个主要行为。
React:
await user.click(screen.getByRole("button", { name: "确认记录" }));
Python:
result = service.pay(user, 30)
HTTP:
response = client.post("/api/orders", json=payload)
如果一条测试有很多彼此独立的 Act,通常意味着:
- 测试覆盖了多个行为
- 场景过大
- 更适合拆成多个测试
但“一个用户行为本身由多个步骤组成”并不一定需要拆。例如登录可能天然包含输入用户名、输入密码、点击提交,这些步骤共同构成一个业务动作。
Assert:验证可观察结果
Assert 负责回答:如果这个行为正确,外部能够观察到什么?
常见断言对象:
- 返回值
- DOM 中用户可见的文本和控件
- 状态变化
- 数据库持久化结果
- HTTP status / body
- 事件是否发布
- 外部边界是否以正确参数被调用
React:
expect(screen.getByText("疼痛")).toBeInTheDocument();
Mock interaction:
expect(review).toHaveBeenCalledWith("candidate-1", 9, "confirmed");
Assert 应优先验证业务行为,而不是内部实现细节。
AAA 与 Given-When-Then
BDD 常使用 Given-When-Then:
Given 已知某个前提
When 当发生某个行为
Then 那么应该得到某个结果
它与 AAA 基本对应:
| AAA | BDD | 含义 |
|---|---|---|
| Arrange | Given | 建立上下文 |
| Act | When | 执行行为 |
| Assert | Then | 验证结果 |
区别主要是表达风格:
- AAA 更常用于代码结构
- Given-When-Then 更强调业务场景语言
底层思维一致。
AAA 与不同语言
Vitest / Jest
it("confirms a fact", async () => {
// Arrange
const review = vi.spyOn(api, "reviewFact").mockResolvedValue({});
const user = userEvent.setup();
render(<Component />);
// Act
await user.click(screen.getByRole("button", { name: "确认" }));
// Assert
expect(review).toHaveBeenCalledOnce();
});
pytest
def test_withdraw():
# Arrange
account = Account(balance=100)
# Act
account.withdraw(30)
# Assert
assert account.balance == 70
Go
func TestWithdraw(t *testing.T) {
// Arrange
account := Account{Balance: 100}
// Act
account.Withdraw(30)
// Assert
if account.Balance != 70 {
t.Fatalf("got %d", account.Balance)
}
}
JUnit
@Test
void withdrawsMoney() {
// Arrange
var account = new Account(100);
// Act
account.withdraw(30);
// Assert
assertEquals(70, account.getBalance());
}
框架 API 完全不同,但测试结构没有变化。
一个高价值测试通常还有第四个隐含步骤:Cleanup
严格来说 AAA 只有三段,但真实测试还可能需要清理:
- 恢复 mock
- 清空数据库
- 删除临时文件
- 关闭 server
- 清除 QueryClient cache
很多框架通过 afterEach、fixture teardown、defer 等机制承担 Cleanup。
Cleanup 是测试隔离的一部分,不改变 AAA 的主体结构。
Positive Test 与 Negative Test
AAA 描述的是单个测试内部结构;测试集合还要考虑边界两侧。
例如 BodyState:
Positive
symptom fact
→ 应该显示“后来改善 / 后来加重 / 后来恢复”
Negative
lifestyle fact
→ 不应该显示这些 symptom actions
两个测试一起定义真正的业务边界。
只写 Negative Test 时,开发者可能直接删掉所有按钮也让测试通过;Positive Test 会阻止这种错误修复。
与 Regression Test 的关系
AAA 回答“一个测试怎么组织”,Regression Test 回答“为什么写这个测试”。
例如:
历史 bug:lifestyle 也显示 symptom actions
↓
写 regression test
↓
内部仍然使用 AAA 组织
所以一条测试可以同时是:
- 层级:Component Test
- 目的:Regression Test
- 结构:AAA
- 手段:Mock API boundary
这些概念不在同一个维度。
与 TDD 的关系
TDD 的:
Red → Green → Refactor
描述开发循环。
AAA 描述每个测试案例内部结构。
它们经常一起使用:
- 用 AAA 写出一个清晰测试
- 运行得到 Red
- 写最小实现变 Green
- Refactor 生产代码和测试辅助代码
常见误区
Arrange 太庞大
如果一个测试需要几百行数据准备,优先考虑:
- Test Data Builder
- Fixture
- Factory
- Test Harness
- 缩小测试边界
不要因为追求 DRY 把所有业务关键输入都隐藏到 helper 里。
Act 混入断言
await expect(service.run()).resolves.toEqual(...)
语法上没有错,但复杂场景中分开 Act 与 Assert 往往更易读。
Assert 盯内部实现
例如:
expect(component.internalState.mode).toBe("open")
如果真正需求是用户看到弹窗,更稳的断言是:
expect(screen.getByRole("dialog")).toBeInTheDocument()
为形式而强行写注释
简单测试不一定必须显式写 // Arrange、// Act、// Assert。
真正重要的是结构清楚,而不是拥有三个注释。
最短记忆方式
Arrange 建世界,Act 做一件事,Assert 看结果。
再进一步:
测试对外部行为负责,不对内部实现负责。