如何在 Go 语言中测试预期的格式化错误?

huangapple go评论89阅读模式
英文:

How to test an expected formatted error in idiomatic Go?

问题

这个问题与验证无关,只是一个示例,但可以是任何其他带有格式化错误的内容。

假设我有一个非常简单的Validate()方法,用于验证文件名中的扩展名,例如.vcf,如果它们不匹配,我希望返回一个错误,指示有效的扩展名和当前的扩展名。理想情况下,这个方法可以扩展到执行其他验证并返回其他验证错误。

代码如下:

// main.go
package main

import (
	"fmt"
	"os"
	"path/filepath"
)

type ValidationError struct {
	Msg string
}

func (v ValidationError) Error() string {
	return v.Msg
}

func Validate(fileName string) error {
	// 第一个验证
	wantExtension := ".vcf"
	gotExtension := filepath.Ext(fileName)
	if gotExtension != wantExtension {
		return ValidationError{Msg: fmt.Sprintf("Extension %q not accepted, please use a %s file.", gotExtension, wantExtension)}
	}
	// 可能还有其他验证
	return nil
}

func main() {
	fileName := os.Args[1]
	Validate(fileName)
}

我想以一种方式进行测试,不仅检查错误类型(在这种情况下是error),还要检查错误消息。代码如下:

// main_test.go
package main

import (
	"errors"
	"testing"
)

func TestValidate(t *testing.T) {
	testCases := []struct {
		name        string
		fileName    string
		expectedErr error
	}{
		{
			name:     "happy-path",
			fileName: "file.vcf",
		},
		{
			name:        "wrong-extension",
			fileName:    "file.md",
			expectedErr: ValidationError{Msg: "Extension .md not accepted, please use a .vcf file."},
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			err := Validate(tc.fileName)
			if !errors.Is(err, tc.expectedErr) {
				t.Errorf("want (%v) got (%v)", tc.expectedErr, err)
			}
		})
	}
}

这个测试将会失败,因为errors.Is()期望相同的内存对象。如果使用errors.As(),测试将会通过,但实际上并不会检查消息是否正确。

我还尝试定义一个函数,以消息作为参数返回一个错误,或者简单地使用errors.New()而不是自定义错误,但是我遇到了同样的问题,即测试端的对象在内存中是不同的。而且我不知道这三种方法中哪一种更符合惯用法。

你会如何实现这个功能?

更新:我更新了问题代码,使用自定义错误而不是errors.New,因为在这种情况下可能更适合,就像@kingkupps的答案中所示。

英文:

This question is NOT about validation. It is just an example, but could be any other thing with formatted errors.

Let's say I have a very simple Validate() method that validates an extension eg. .vcf from a filename and I want an error saying valid extension and current extension if they don't match. Ideally, this method would be extended to do other validations and return other validation errors.

Something like:

// main.go
package main

import (
	"fmt"
	"os"
	"path/filepath"
)

type ValidationError struct {
	Msg string
}

func (v ValidationError) Error() string {
	return v.Msg
}

func Validate(fileName string) error {
	// first validation
	wantExtension := ".vcf"
	gotExtension := filepath.Ext(fileName)
	if gotExtension != wantExtension {
		return ValidationError{Msg: fmt.Sprintf("Extension %q not accepted, please use a %s file.", gotExtension, wantExtension)}
	}
	// potentially other validations
	return nil
}

func main() {
	fileName := os.Args[1]
	Validate(fileName)
}

I want to test it in a way that I check not only the error type (in this case error, but also the message). Something like:

// main_test.go
package main

import (
	"errors"
	"testing"
)

func TestValidate(t *testing.T) {
	testCases := []struct {
		name        string
		fileName    string
		expectedErr error
	}{
		{
			name:     "happy-path",
			fileName: "file.vcf",
		},
		{
			name:        "wrong-extension",
			fileName:    "file.md",
			expectedErr: ValidationError{Msg: "Extension .md not accepted, please use a .vcf file."},
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			err := Validate(tc.fileName)
			if !errors.Is(err, tc.expectedErr) {
				t.Errorf("want (%v) got (%v)", tc.expectedErr, err)
			}
		})
	}
}

This test will get:

--- FAIL: TestValidate (0.00s)
    --- FAIL: TestValidate/wrong-extension (0.00s)
        main_test.go:28: want (Extension .md not accepted, please use a .vcf file.) got (Extension ".md" not accepted, please use a .vcf file.)

I does not work since errors.Is() expects same memory object. Also if I use errors.As() the test will pass but it will not really check if the message is correct.

I also tried defining a function to return an error with the message as parameter or simpler errors.New() instead of custom error, but I run into the same problem that the object in memory is different in the test side, also don't know which of the three approaches would be considered more idiomatic.

How would you implement that?

UPDATE: I updated the question code to use custom error instead of errors.New as it is probably more fit to the case as in the answer by @kingkupps.

答案1

得分: 2

我建议您定义自己的类型,并使该类型实现Error接口。在测试中,您可以使用errors.As来确定返回的错误是新类型的实例还是包装了新类型实例的错误。

package main

import (
	"errors"
	"fmt"
	"path/filepath"
)

type ValidationError struct {
	WantExtension string
	GotExtension  string
}

func (v ValidationError) Error() string {
	return fmt.Sprintf("extension %q not accepted, please use a %s file.", v.GotExtension, v.WantExtension)
}

func Validate(fileName string) error {
	fileExtension := filepath.Ext(fileName)
	if fileExtension != ".vcf" {
		return ValidationError{WantExtension: ".vcf", GotExtension: fileExtension}
	}
	return nil
}

func main() {
	fileName := "something.jpg"

	err := Validate(fileName)
	var vErr ValidationError
	if errors.As(err, &vErr) {
		// you can now use vErr.WantExtension and vErr.GotExtension
		fmt.Println(vErr)
		fmt.Println(vErr.WantExtension)
		fmt.Println(vErr.GotExtension)
	}
}

输出结果为:

extension ".jpg" not accepted, please use a .vcf file.
.vcf
.jpg

然后在您的测试中,您可以根据Error()的结果或它们的字段来比较错误:

func TestValidate(t *testing.T) {
	testCases := []struct {
		name        string
		fileName    string
		expectedErr error
	}{
		{
			name:     "happy-path",
			fileName: "file.vcf",
		},
		{
			name:        "wrong-extension",
			fileName:    "file.md",
			expectedErr: ValidationError{WantExtension: ".vcf", GotExtension: ".md"},
		},
	}
	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			err := Validate(tc.fileName)
			if err != nil {
				if tc.expectedErr == nil {
					t.Errorf("unexpected error: %v", err)
					return
				}

				var want ValidationError
				var got ValidationError
				if w, g := errors.As(tc.expectedErr, &want), errors.As(err, &got); w == g && w {
					// Both errors are ValidationErrors
					assertValidationErrorsEqual(t, want, got)
				} else if w != g {
					// The expected and actual error differ in type
					t.Errorf("wanted error of type %T, got error of type %T", tc.expectedErr, err)
				} else {
					// Neither error is a ValidationError so we just assert that they produce the same error message
					assertErrorMessagesEqual(t, tc.expectedErr, err)
				}
			}
		})
	}
}

func assertErrorMessagesEqual(t *testing.T, want error, got error) {
	if w, g := want.Error(), got.Error(); w != g {
		t.Errorf("want %q, got %q", w, g)
	}
}

func assertValidationErrorsEqual(t *testing.T, want ValidationError, got ValidationError) {
	if want.WantExtension != got.WantExtension || want.GotExtension != got.GotExtension {
		t.Errorf("want %v, got %v", want, got)
	}
}

编辑:感谢@Daniel Farrell建议使用errors.As而不是类型断言。使用errors.As将实现您想要的功能,即使返回的错误包装了ValidationError的实例,而类型断言则不会。

编辑:添加了使用建议方法的测试示例。

英文:

I would recommend defining your own type and having that type implement the Error interface. In your tests you can use errors.As to determine whether the returned error is an instance of the new type or is an error wrapping an instance of the new type.

package main
import (
"errors"
"fmt"
"path/filepath"
)
type ValidationError struct {
WantExtension string
GotExtension  string
}
func (v ValidationError) Error() string {
return fmt.Sprintf("extension %q not accepted, please use a %s file.", v.GotExtension, v.WantExtension)
}
func Validate(fileName string) error {
fileExtension := filepath.Ext(fileName)
if fileExtension != ".vcf" {
return ValidationError{WantExtension: ".vcf", GotExtension: fileExtension}
}
return nil
}
func main() {
fileName := "something.jpg"
err := Validate(fileName)
var vErr ValidationError
if errors.As(err, &vErr) {
// you can now use vErr.WantExtension and vErr.GotExtension
fmt.Println(vErr)
fmt.Println(vErr.WantExtension)
fmt.Println(vErr.GotExtension)
}
}

Which prints:

extension ".jpg" not accepted, please use a .vcf file.
.vcf
.jpg

Then in your test, you can compare errors based on either the result of Error() or their fields:

func TestValidate(t *testing.T) {
testCases := []struct {
name        string
fileName    string
expectedErr error
}{
{
name:     "happy-path",
fileName: "file.vcf",
},
{
name:        "wrong-extension",
fileName:    "file.md",
expectedErr: ValidationError{WantExtension: ".vcf", GotExtension: ".md"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := Validate(tc.fileName)
if err != nil {
if tc.expectedErr == nil {
t.Errorf("unexpected error: %v", err)
return
}
var want ValidationError
var got ValidationError
if w, g := errors.As(tc.expectedErr, &want), errors.As(err, &got); w == g && w {
// Both errors are ValidationErrors
assertValidationErrorsEqual(t, want, got)
} else if w != g {
// The expected and actual error differ in type
t.Errorf("wanted error of type %T, got error of type %T", tc.expectedErr, err)
} else {
// Neither error is a ValidationError so we just assert that they produce the same error message
assertErrorMessagesEqual(t, tc.expectedErr, err)
}
}
})
}
}
func assertErrorMessagesEqual(t *testing.T, want error, got error) {
if w, g := want.Error(), got.Error(); w != g {
t.Errorf("want %q, got %q", w, g)
}
}
func assertValidationErrorsEqual(t *testing.T, want ValidationError, got ValidationError) {
if want.WantExtension != got.WantExtension || want.GotExtension != got.GotExtension {
t.Errorf("want %v, got %v", want, got)
}
}

EDIT: Thanks to @Daniel Farrell for the suggestion on using errors.As over a type assertion. Using errors.As will do what you want even if the returned error wraps an instance of ValidationError whereas a type assertion will not.

EDIT: Added what the test might look like in using the suggested approach.

答案2

得分: 1

在使用errors.Is时没有问题。代码在使用它时没有失败。

问题出在测试代码没有正确设置。

在程序中,错误消息被定义为Extension ".md" not accepted, please use a .vcf file.,例如return ValidationError{Msg: fmt.Sprintf("Extension %q not accepted, please use a %s file.", gotExtension, wantExtension)}

在测试中,错误消息被定义为expectedErr: ValidationError{Msg: "Extension .md not accepted, please use a .vcf file."},

因此,当尝试比较这两个错误值时,状态类似于

	fmt.Println(
		errors.Is(
			ValidationError{Msg: `Extension ".md" not accepted, please use a .vcf file.`},
			ValidationError{Msg: `Extension .md not accepted, please use a .vcf file.`},
		),
	)

根据该API的行为,预期返回false,因为它执行可比较值的相等性检查。

https://cs.opensource.google/go/go/+/refs/tags/go1.17.6:src/errors/wrap.go;l=46

作为参考,它大致等同于

	fmt.Println(
		ValidationError{Msg: `Extension ".md" not accepted, please use a .vcf file.`} ==
			ValidationError{Msg: `Extension .md not accepted, please use a .vcf file.`},
	)

https://go.dev/ref/spec#Comparison_operators

如果结构体的所有字段都是可比较的,则结构体值是可比较的。如果两个结构体值的对应非空字段相等,则它们相等。

当然,在使用errors时,这个也应该提到

接口值是可比较的。如果它们具有相同的动态类型和相等的动态值,或者两者都为nil值,则两个接口值相等。

英文:

there is no problem in using errors.Is. The code did not fail at using it.

It is the test code that is not setup correctly.

In the program, the error message is defined as Extension ".md" not accepted, please use a .vcf file., like in, return ValidationError{Msg: fmt.Sprintf("Extension %q not accepted, please use a %s file.", gotExtension, wantExtension)}

In the test, the error message is defined as expectedErr: ValidationError{Msg: "Extension .md not accepted, please use a .vcf file."},

Hence, when trying to compare the two error values, the state is similar to

	fmt.Println(
errors.Is(
ValidationError{Msg: `Extension ".md" not accepted, please use a .vcf file.`},
ValidationError{Msg: `Extension .md not accepted, please use a .vcf file.`},
),
)

Which is expected to return false using that api as it performs equality check of comparable values.

https://cs.opensource.google/go/go/+/refs/tags/go1.17.6:src/errors/wrap.go;l=46

For, reference, it is roughly equivalent to

	fmt.Println(
ValidationError{Msg: `Extension ".md" not accepted, please use a .vcf file.`} ==
ValidationError{Msg: `Extension .md not accepted, please use a .vcf file.`},
)

https://go.dev/ref/spec#Comparison_operators

> Struct values are comparable if all their fields are comparable. Two struct values are equal if their corresponding non-blank fields are equal.

Most certainly using errors, this one should be mentioned too

> Interface values are comparable. Two interface values are equal if they have identical dynamic types and equal dynamic values or if both have value nil.

huangapple
  • 本文由 发表于 2022年2月6日 00:19:23
  • 转载请务必保留本文链接:https://go.coder-hub.com/70999808.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定