Files
zenfeed/docs/tech/testing-zh.md
2025-05-17 23:03:59 +08:00

8.7 KiB
Raw Blame History

Zenfeed 最新测试策略与风格

适用版本v0.2.2

1. 引言

Zenfeed 的测试策略核心目标是:

  • 清晰性 (Clarity):测试本身应如文档般易于理解,清晰地表达被测功能的行为和预期。
  • 可信性 (Reliability):测试结果应准确反映代码的健康状况,确保每次提交的信心。
  • 可维护性 (Maintainability):测试代码应易于修改和扩展,以适应项目的持续演进。

本指南旨在详细介绍 Zenfeed 项目所遵循的测试理念、风格和具体实践。

2. 核心测试理念与风格

Zenfeed 的测试方法论深受行为驱动开发 (BDD) 的影响,并结合了表驱动测试等高效实践。

2.1 行为驱动开发

我们选择 BDD 作为核心的测试描述框架,主要基于以下原因(其理念也体现在 pkg/test/test.goCase 结构设计中):

  • 提升可读性 (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 测试结构约定

为了保持项目范围内测试代码的一致性和可读性,我们约定在测试文件中遵循以下组织结构:

  1. 定义辅助类型 (Define Helper Types):在测试函数的开头部分,通常会为 GivenDetail, WhenDetail, ThenExpected 定义具体的结构体类型,以增强类型安全和表达力。
  2. 定义测试用例表 (Define Test Case Table):将所有测试用例集中定义在一个 []test.Case 类型的切片中。
  3. 循环执行测试 (Loop Through Test Cases):使用 for 循环遍历测试用例表,并为每个用例运行 t.Run(tt.Scenario, func(t *testing.T) { ... })
  4. 清晰的 G/W/T 逻辑块 (Clear G/W/T Blocks):在每个 t.Run 的匿名函数内部,根据需要组织代码块,以对应 Given(准备初始状态,通常基于 tt.GivenDetailWhen(执行被测操作,通常使用 tt.WhenDetail),和 Then(断言结果,通常对比 tt.ThenExpected)。
  5. 描述性变量名 (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.goComponent 接口的组件,我们提供了一种标准的 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))