错误验证

在第 4 章《构建高效的测试套件》中,我们简要讨论了 Go 的显式错误处理方法。我们了解到,错误通常会作为多个返回值中的最后一个返回。到目前为止,我们一直在使用 Go 内建的 error 类型和代表性的错误消息来指示用户出现了问题。现在,让我们更详细地探讨一下 Go 中如何进行错误验证。

到目前为止,我们已经通过两种方式创建了错误。最简单的一种方式是使用 errors.New 函数,它创建一个带有给定消息的错误:

err := errors.New("Something is wrong!")

该函数接受一个错误消息作为参数,返回一个 error 接口类型。为了获取错误消息,我们调用 Error 方法:

msg := err.Error()

该方法将返回一个字符串类型的消息。

编写一个测试来比较输入和输出的错误消息对这个例子来说非常简单:

func TestErrorsVerification(t *testing.T){
    t.Run("simple custom error", func(t *testing.T) {
        wantMsg := "Something went wrong!"
        err := errors.New(wantMsg)
        gotMsg := err.Error()
        assert.Equal(t, wantMsg, gotMsg)
    })
}

由于我们完全控制整个错误消息,因此可以轻松地断言两个值相等。然而,如果错误消息的构建是函数的一部分呢?在 Go 中,通常会构建包括输入和其他调用参数的代表性错误消息。这些类型的错误通常是通过 fmt.Errorf 函数构建的:

func checkOdd(input int) error {
    if input%2 == 0 {
        return fmt.Errorf("Input %d cannot be even.", input)
    }
    return nil
}

fmt.Errorf 函数的格式化方式与 fmt 包中的其他格式化函数相同,但它返回一个带有格式化消息的错误类型。对这个错误消息进行断言稍微复杂一些。

第一种选择是在测试代码中重新构建错误消息:

func TestErrorsVerification(t *testing.T) {
    t.Run("formatted custom error", func(t *testing.T) {
        input := 4
        wantMsg := fmt.Sprintf("Input %d cannot be even.", input)
        err := checkOdd(input)
        gotMsg := err.Error()
        assert.Equal(t, wantMsg, gotMsg)
    })
}

在这个测试代码中,我们使用 fmt.Sprintf 函数根据与 checkOdd 实现中调用 fmt.Errorf 函数相同的格式,格式化期望的错误消息。

这种第一种方法有三个缺点:

  • 测试代码不得不为验证目的重复实现代码。如果错误消息需要较为复杂的设置,情况可能会变得更加复杂。

  • 测试代码现在与实现代码紧密耦合。如果实现代码中的错误格式化逻辑发生变化,那么测试代码也需要做出相应的更改。

  • 无法确保在不同的测试场景中错误消息的格式始终如一。这在由大型工程团队维护的大型代码库中可能会成为一个问题。

第二种选择是放宽错误验证的要求,这样我们不再需要完全重新创建错误消息:

func TestErrorsVerification(t *testing.T) {
    t.Run("formatted custom error", func(t *testing.T) {
        input := 4
        err := checkOdd(input)
        gotMsg := err.Error()
        assert.Contains(t, gotMsg, fmt.Sprint(input))
        assert.Contains(t, gotMsg, "even")
    })
}

assert.Contains 函数用来验证错误消息是否包含某些子字符串,这些子字符串我们相对确信在实现代码中不会发生变化。这种方法简化了测试代码,消除了对完整错误消息格式化的需求。

然而,第二种方法也有一些缺点:

  • 错误消息的断言没有完全验证。例如,实现代码可能会生成完全无意义的消息,只要这些测试中的子字符串存在,测试就会通过。这种方法无法对实现代码的全部功能进行断言。其他类型的测试(如集成测试或端到端测试)可能会验证这一点。然而,我们将探索如何在单元测试中包含错误断言。

  • 尽管已经有所减少,测试代码仍然包含了部分实现知识和硬编码的错误消息。因此,测试仍然容易出错,并且与实现代码紧密耦合。

  • 仍然无法确保在不同的测试中进行一致的断言。实际上,由于期望的字符串不再在测试的 “安排” 部分构建出来,它可能变得更难发现硬编码的字符串,直到测试套件指出失败。

如前所述,我们的两种直接选择都存在显著的缺点。然而,正如我们在第 4 章《构建高效的测试套件》中所记得的那样,错误类型是一个简单的接口,只有一个方法:

type error interface {
    Error() string
}

我们可以通过实现这个简单的函数,轻松地创建自己的自定义错误类型。正如我们在多个场合看到的那样,Go 中的接口在许多方面都非常强大,错误处理也是其中之一。

自定义错误类型

错误处理的第三种选择是创建我们自己的自定义错误类型,这将使我们能够为错误类型添加更多的信息,而不仅仅是多次格式化一个字符串。这将在实现和测试代码中提供更大的灵活性。

首先,我们将定义一个简单的 evenNumberError 类型:

type evenNumberError struct {
    input int
}

这个类型有一个字段,用于存储我们 checkOdd 函数的输入值。这将允许测试代码访问输入值,而不需要检查返回的错误消息,这是前面两种方法中需要做的事情。

接下来,我们需要为这个新类型添加一个方法,确保它满足 error 接口的要求:

func (e *evenNumberError) Error() string {
    return fmt.Sprintf("Input %d cannot be even.", e.input)
}

这个方法的接收者是 evenNumberError 类型,它的签名与 error 接口相同。在方法内部,我们使用与前面相同的格式和 fmt.Sprintf 函数,结合接收者的 input 字段。

接着,我们可以将实现函数修改为使用这个新定义的错误类型:

func checkOdd(input int) error {
    if input%2 == 0 {
        return &evenNumberError{
            input: input,
        }
    }
    return nil
}

通过将错误格式化封装在 evenNumberError 类型内部,函数的返回语句仅需创建该类型的新实例并返回其指针。我们将 checkOdd 函数的参数传递给它进行初始化。

始终返回 error 接口

最后需要注意的一点是,checkOdd 函数仍然返回 error 接口。因此,调用代码不需要了解在此包中创建的自定义错误类型。当使用自定义错误类型时,应该始终遵循这种模式。

有了这个新的自定义错误类型,测试代码大大简化:

func TestErrorsVerification(t *testing.T) {
    t.Run("custom error type", func(t *testing.T) {
        input := 4
        wantErr := &evenNumberError{
            input: input,
        }
        err := checkOdd(input)
        var gotErr *evenNumberError
        require.True(t, errors.As(err, &gotErr))
        assert.Equal(t, wantErr, gotErr)
    })
}

在这个错误验证的实现中,我们展示了如何对自定义错误类型进行验证:

  1. 我们创建一个 evenNumberError 类型的实例,包含输入字段。这比创建一个期望的错误消息要简单得多。

  2. 调用 checkOdd 函数后,我们需要将内建的 error 值转换为自定义错误类型。这是通过使用 errors.As 函数完成的,它在转换成功时返回 true

  3. 我们使用 require.True 函数确保如果转换失败,测试会失败。

  4. 最后,我们使用 assert.Equal 函数确保实际的错误与期望的错误相等。

这种测试实现更加简单,且不再与函数内部的错误格式化逻辑紧密耦合。这种方法确实有一个小的缺点,那就是它创建了一个新的自定义类型,但通过使用自定义错误类型,能够通过提供统一的错误格式化方式来简化实现代码。

我们像往常一样运行测试,看看错误验证的效果:

$ go test -run TestErrorsVerification ./chapter07/errors -v
=== RUN TestErrorsVerification
=== RUN TestErrorsVerification/simple_custom_error
=== RUN TestErrorsVerification/formatted_custom_error
=== RUN TestErrorsVerification/contains_custom_error
=== RUN TestErrorsVerification/custom_error_type
--- PASS: TestErrorsVerification (0.00s)
--- PASS: TestErrorsVerification/simple_custom_error (0.00s)
--- PASS: TestErrorsVerification/formatted_custom_error (0.00s)
--- PASS: TestErrorsVerification/contains_custom_error (0.00s)
--- PASS: TestErrorsVerification/custom_error_type (0.00s)
PASS
ok  github.com/PacktPublishing/Test-Driven-Development-inGo/chapter07/errors 0.122s

每个测试用例都在其子测试中运行,如输出中所示。

使用自定义错误类型的另一个优点是,它们允许一个函数返回多种类型的错误,这可以为调用者提供更多的上下文。目前,我们应当记住,自定义错误类型能够简化我们的测试代码,同时提供精确的错误验证功能。