Skip to content

proposal: cmpopts: add convenience functions for errors #89

@dsnet

Description

@dsnet

I've been frustrated lately with the number of tests that do error checking by performing string equality on the error message. As it stands, cmp is better than reflect.DeepEqual in that it usually fails when comparing errors since many errors have an unexported field somewhere in it forcing users to think about how to properly compare them. I believe cmp (or rather cmpopts) can go one further and assist users with comparing errors.

Consider the following:

// EquateErrors returns a Comparer option that determines errors to be equal
// if both are nil or both are non-nil. When both errors are non-nil,
// the Comparer checks whether either error is a CheckError and if so,
// calls it on the other error to determine equality.
// If neither error are a CheckError, then they are compared as if they
// are sentinel errors (i.e., using the == operator).
func EquateErrors() cmp.Option {
	return cmp.Comparer(func(e1, e2 error) bool {
		if e1 == nil || e2 == nil {
			return e1 == nil && e2 == nil
		}
		f1, _ := e1.(CheckError)
		f2, _ := e2.(CheckError)
		switch {
		case f1 == nil && f2 == nil:
			return e1 == e2
		case f1 != nil && f2 == nil:
			return f1(e2)
		case f1 == nil && f2 != nil:
			return f2(e1)
		default:
			return false
		}
	})
}

// CheckError is an error used by EquateErrors for finer comparisons on errors.
type CheckError func(error) bool

func (c CheckError) Error() string {
	return fmt.Sprintf("%#v", c)
}

It seem odd at first that CheckError is both a function and also an error, but it gives flexibility in being able to control how comparisons with errors works, by configuring the comparison as data, rather than through an option.

Consider the following example usage:

func Test(t *testing.T) {
	// nonNilError equates any non-nil error.
	// This is usual for just checking that the result failed, but not how it failed.
	nonNilError := CheckError(func(error) bool {
		return true
	})

	// notExistError equates any error based on a function predicate.
	notExistError := CheckError(os.IsNotExist)

	// timeoutError equates any error that is a timeout error.
	timeoutError := CheckError(func(e error) bool {
		ne, ok := e.(net.Error)
		return ok && ne.Timeout()
	})

	// containsEOF equates any error with EOF in the string.
	// NOTE: string matching on error messages is heavily frowned upon.
	containsEOF := CheckError(func(e error) bool {
		return strings.Contains(e.Error(), "EOF")
	})

	tests := []struct {
		err1 error
		err2 error
		want bool
	}{
		{io.EOF, io.EOF, true},
		{io.EOF, errors.New("EOF"), false},
		{io.EOF, io.ErrUnexpectedEOF, false},

		{io.EOF, nonNilError, true},
		{nonNilError, io.EOF, true},
		{nil, nonNilError, false},

		{io.EOF, notExistError, false},
		{os.ErrNotExist, notExistError, true},
		{os.ErrPermission, notExistError, false},

		{timeoutError, io.EOF, false},
		{timeoutError, &net.AddrError{}, false},
		{timeoutError, syscall.ETIMEDOUT, true},

		{io.EOF, containsEOF, true},
		{io.ErrUnexpectedEOF, containsEOF, true},
		{&net.ParseError{}, containsEOF, false},
	}

	for _, tt := range tests {
		got := cmp.Equal(&tt.err1, &tt.err2, EquateErrors())
		if got != tt.want {
			t.Errorf("Equal(%v, %v) = %v, want %v", tt.err1, tt.err2, got, tt.want)
		}
	}
}

Thus, EquateErrors has the following properties:

  • It is still symmetric, which is one of the required properties of cmp.Comparer. That is, the result the same regardless of whether CheckError is on the left or right side.
  • By default, it compares errors as if they are sentinel error.
  • By default, it does not perform string comparisons on error messages, but does not prevent it either.
  • It is extensible to handle all other error types through the use of a predicate function, which handles the other two common ways to distinguish errors (which are type assertions to an interface or type, or by using a predicate function like os.IsNotExist).

Related to #24

\cc @neild

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions