Error types

    Creating your own types for errors can be an elegant way of tidying up your code, making your code easier to use and test.

    Pedro on the Gopher Slack asks

    Let’s make up a function to help explore this idea.

    It’s not uncommon to write a function that might fail for different reasons and we want to make sure we handle each scenario correctly.

    As Pedro says, we could write a test for the status error like so.

    1. t.Run("when you dont get a 200 you get a status error", func(t *testing.T) {
    2. svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
    3. res.WriteHeader(http.StatusTeapot)
    4. }))
    5. defer svr.Close()
    6. _, err := DumbGetter(svr.URL)
    7. if err == nil {
    8. t.Fatal("expected an error")
    9. }
    10. want := fmt.Sprintf("did not get 200 from %s, got %d", svr.URL, http.StatusTeapot)
    11. if got != want {
    12. t.Errorf(`got "%v", want "%v"`, got, want)
    13. }

    This test creates a server which always returns StatusTeapot and then we use its URL as the argument to DumbGetter so we can see it handles non 200 responses correctly.

    • We’re constructing the same string as production code does to test it
    • It’s annoying to read and write
    • Is the exact error message string what we’re actually concerned with ?

    What does this tell us? The ergonomics of our test would be reflected on another bit of code trying to use our code.

    How does a user of our code react to the specific kind of errors we return? The best they can do is look at the error string which is extremely error prone and horrible to write.

    With TDD we have the benefit of getting into the mindset of:

    How would I want to use this code?

    What we could do for DumbGetter is provide a way for users to use the type system to understand what kind of error has happened.

    What if DumbGetter could return us something like

    Rather than a magical string, we have actual data to work with.

    1. t.Run("when you dont get a 200 you get a status error", func(t *testing.T) {
    2. svr := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
    3. res.WriteHeader(http.StatusTeapot)
    4. }))
    5. defer svr.Close()
    6. _, err := DumbGetter(svr.URL)
    7. if err == nil {
    8. }
    9. got, isStatusErr := err.(BadStatusError)
    10. if !isStatusErr {
    11. t.Fatalf("was not a BadStatusError, got %T", err)
    12. }
    13. want := BadStatusError{URL:svr.URL, Status:http.StatusTeapot}
    14. if got != want {
    15. t.Errorf("got %v, want %v", got, want)
    16. }
    17. })

    We’ll have to make BadStatusError implement the error interface.

    Instead of checking the exact string of the error, we are doing a type assertion on the error to see if it is a BadStatusError. This reflects our desire for the kind of error clearer. Assuming the assertion passes we can then check the properties of the error are correct.

    When we run the test, it tells us we didn’t return the right kind of error

    1. --- FAIL: TestDumbGetter (0.00s)
    2. --- FAIL: TestDumbGetter/when_you_dont_get_a_200_you_get_a_status_error (0.00s)
    3. error-types_test.go:56: was not a BadStatusError, got *errors.errorString

    Let’s fix DumbGetter by updating our error handling code to use our type

    This change has had some real positive effects

    • Our DumbGetter function has become simper, it’s no longer concerned with the intricacies of an error string, it just creates a BadStatusError.
    • It is still “just” an error, so if they choose to they can pass it up the call stack or log it like any other .

    If you find yourself testing for multiple error conditions dont fall in to the trap of comparing the error messages.

    This leads to flaky and difficult to read/write tests and it reflects the difficulties the users of your code will have if they also need to start doing things differently depending on the kind of errors that have occurred.