8.7 KiB
Zenfeed 最新测试策略与风格
适用版本:v0.2.2
1. 引言
Zenfeed 的测试策略核心目标是:
- 清晰性 (Clarity):测试本身应如文档般易于理解,清晰地表达被测功能的行为和预期。
- 可信性 (Reliability):测试结果应准确反映代码的健康状况,确保每次提交的信心。
- 可维护性 (Maintainability):测试代码应易于修改和扩展,以适应项目的持续演进。
本指南旨在详细介绍 Zenfeed 项目所遵循的测试理念、风格和具体实践。
2. 核心测试理念与风格
Zenfeed 的测试方法论深受行为驱动开发 (BDD) 的影响,并结合了表驱动测试等高效实践。
2.1 行为驱动开发
我们选择 BDD 作为核心的测试描述框架,主要基于以下原因(其理念也体现在 pkg/test/test.go 的 Case 结构设计中):
- 提升可读性 (Enhanced Readability):BDD 强调使用自然语言描述软件的行为。每个测试用例读起来都像一个用户故事或一个功能说明,这使得测试本身就成为了一种精确的"活文档"。
- 关注行为 (Focus on Behavior):测试不再仅仅是验证代码片段的输入输出,而是从模块、组件或用户交互的层面描述其应有的行为。这有助于确保我们构建的功能符合预期。
- 需求驱动 (Requirement-Driven):测试直接对应需求描述,而非实现细节。这种自顶向下的方法确保了测试的稳定性,即使内部实现重构,只要行为不变,测试依然有效。
BDD 通常使用 Scenario, Given, When, Then 的结构来组织测试:
Scenario(场景):描述测试用例所针对的特性或功能点。- 例如:
"Query hot block with label filters"(查询带标签过滤的热数据块)
- 例如:
Given(给定):描述场景开始前的初始上下文或状态(注意:这不是指方法的输入参数)。- 例如:
"a hot block with indexed feeds"(一个已索引了 Feed 的热数据块)
- 例如:
When(当):描述触发场景的事件或操作(这部分通常包含被测方法的输入参数)。- 例如:
"querying with label filters"(当使用标签过滤器进行查询时)
- 例如:
Then(那么):描述场景结束后预期的结果或状态变化。- 例如:
"should return matching feeds"(那么应该返回匹配的 Feed)
- 例如:
为了更好地在代码中实践 BDD,我们定义了 pkg/test/test.go 中的 Case[GivenDetail, WhenDetail, ThenExpected] 泛型结构。其中:
GivenDetail: 存储Given子句描述的初始状态的具体数据。WhenDetail: 存储When子句描述的事件或方法调用的具体参数。ThenExpected: 存储Then子句描述的预期结果。
这种结构化不仅增强了测试数据的类型安全,也使得测试用例的意图更加明确。对于需要模拟依赖项的组件,GivenDetail 通常会包含用于配置这些模拟行为的 component.MockOption,我们将在后续 Mocking 章节详细讨论。
2.2 表驱动测试
当一个功能或方法需要针对多种不同的输入组合、边界条件或状态进行测试时,表驱动测试是一种非常高效和整洁的组织方式。
- 简洁性 (Conciseness):将所有测试用例的数据(输入、参数、预期输出)集中定义在一个表格(通常是切片)中,避免了为每个 case编写大量重复的测试逻辑。
- 易扩展性 (Extensibility):添加新的测试场景变得非常简单,只需在表格中增加一条新记录即可。
- 清晰性 (Clarity):所有相关的测试用例一目了然,便于快速理解被测功能的覆盖范围。
实践约定: 在 Zenfeed 中,当存在多个测试用例时,必须使用表驱动测试。
2.3 测试结构约定
为了保持项目范围内测试代码的一致性和可读性,我们约定在测试文件中遵循以下组织结构:
- 定义辅助类型 (Define Helper Types):在测试函数的开头部分,通常会为
GivenDetail,WhenDetail,ThenExpected定义具体的结构体类型,以增强类型安全和表达力。 - 定义测试用例表 (Define Test Case Table):将所有测试用例集中定义在一个
[]test.Case类型的切片中。 - 循环执行测试 (Loop Through Test Cases):使用
for循环遍历测试用例表,并为每个用例运行t.Run(tt.Scenario, func(t *testing.T) { ... })。 - 清晰的 G/W/T 逻辑块 (Clear G/W/T Blocks):在每个
t.Run的匿名函数内部,根据需要组织代码块,以对应Given(准备初始状态,通常基于tt.GivenDetail),When(执行被测操作,通常使用tt.WhenDetail),和Then(断言结果,通常对比tt.ThenExpected)。 - 描述性变量名 (Descriptive Variable Names):使用与 BDD 术语(如
given,when,then,expected,actual)相匹配或能清晰表达意图的变量名。
3. 依赖隔离:Mocking (Dependency Isolation: Mocking)
单元测试的核心原则之一是隔离性 (Isolation),即被测试的代码单元(如一个函数或一个方法)应该与其依赖项隔离开来。Mocking (模拟) 是实现这种隔离的关键技术。
我们主要使用 github.com/stretchr/testify/mock 库来实现 Mocking。特别是对于实现了 pkg/component/component.go 中 Component 接口的组件,我们提供了一种标准的 Mocking 方式。
type givenDetail struct {
// Example of another initial state field for the component being tested
initialProcessingPrefix string
// MockOption to set up the behavior of dependencyA
dependencyAMockSetup component.MockOption
// ...
}
type whenDetail struct {
processDataInput string
// ...
}
type thenExpected struct {
expectedOutput string
expectedError error
// ...
}
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
{
Scenario: "Component processes data successfully with mocked dependency",
Given: "YourComponent with an initial prefix and dependencyA mocked to return 'related_data_value' for 'input_key'",
When: "ProcessData is called with 'input_key'",
Then: "Should return 'prefix:input_key:related_data_value' and no error",
GivenDetail: givenDetail{
initialProcessingPrefix: "prefix1",
dependencyAMockSetup: func(m *mock.Mock) {
// We expect DependencyA's FetchRelatedData to be called with "input_key"
// and it should return "related_data_value" and no error.
m.On("FetchRelatedData", "input_key").
Return("related_data_value", nil).
Once() // Expect it to be called exactly once.
},
},
WhenDetail: whenDetail{
processDataInput: "input_key",
},
ThenExpected: thenExpected{
expectedOutput: "prefix1:input_key:related_data_value",
expectedError: nil,
},
},
// ...更多测试用例...
}
// 在 for _, tt := range tests { t.Run(tt.Scenario, func(t *testing.T) { ... }) } 循环内部
// Given 阶段: Setup mocks and the component under test
var mockHelperForDepA *mock.Mock
defer func() { // 确保在每个子测试结束时断言
if mockHelperForDepA != nil {
mockHelperForDepA.AssertExpectations(t)
}
}()
// 创建并配置 mockDependencyA
// dependency_a_pkg.NewFactory 应该是一个返回 DependencyA 接口和 error 的工厂函数
// 它接受 component.MockOption 来配置其内部的 mock.Mock 对象
mockDependencyA, err := dependency_a_pkg.NewFactory(
component.MockOption(func(m *mock.Mock) {
mockHelperForDepA = m // 保存 mock.Mock 实例以供 AssertExpectations 使用
if tt.GivenDetail.dependencyAMockSetup != nil {
// 应用测试用例中定义的 specific mock setup
tt.GivenDetail.dependencyAMockSetup(m)
}
}),
).New("mocked_dep_a_instance", nil /* config for dep A */, dependency_a_pkg.Dependencies{})
Expect(err).NotTo(HaveOccurred())
Expect(mockDependencyA).NotTo(BeNil())
// 假设 YourComponent 的构造函数如下:
componentUnderTest := NewYourComponent(tt.GivenDetail.initialProcessingPrefix, mockDependencyA)
// When 阶段: Execute the action being tested
actualOutput, actualErr := componentUnderTest.ProcessData(context.Background(), tt.WhenDetail.processDataInput)
// Then 阶段: Assert the outcomes
if tt.ThenExpected.expectedError != nil {
Expect(actualErr).To(HaveOccurred())
Expect(actualErr.Error()).To(Equal(tt.ThenExpected.expectedError.Error()))
} else {
Expect(actualErr).NotTo(HaveOccurred())
}
Expect(actualOutput).To(Equal(tt.ThenExpected.expectedOutput))