Let's Go Testing › Mocking dependencies
Previous · Contents · Next
Chapter 13.5.

Mocking dependencies

Now that we’ve explained some general patterns for testing your web application, in this chapter we’re going to get a bit more serious and write some tests for our GET /snippet/view/{id} route.

But first, let’s talk about dependencies.

Throughout this project we’ve injected dependencies into our handlers via the application struct, which currently looks like this:

type application struct {
    logger        *slog.Logger
    snippets       *models.SnippetModel
    users          *models.UserModel
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}

When testing, it sometimes makes sense to mock these dependencies instead of using exactly the same ones that you do in your production application.

For example, in the previous chapter we mocked the logger dependency with a logger that write messages to io.Discard, instead of the os.Stdout and stream like we do in our production application:

func newTestApplication(t *testing.T) *application {
    return &application{
        logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
    }
}

The reason for mocking this and writing to io.Discard is to avoid clogging up our test output with unnecessary log messages when we run go test -v (with verbose mode enabled).

The other two dependencies that it makes sense for us to mock are the models.SnippetModel and models.UserModel database models. By creating mocks of these it’s possible for us to test the behavior of our handlers without needing to setup an entire test instance of the MySQL database.

Mocking the database models

If you’re following along, create a new internal/models/mocks package containing snippets.go and user.go files to hold the database model mocks, like so:

$ mkdir internal/models/mocks
$ touch internal/models/mocks/snippets.go
$ touch internal/models/mocks/users.go

Let’s begin by creating a mock of our models.SnippetModel. To do this, we’re going to create a simple struct which implements the same methods as our production models.SnippetModel, but have the methods return some fixed dummy data instead.

File: internal/models/mocks/snippets.go
package mocks

import (
    "time"

    "snippetbox.alexedwards.net/internal/models"
)

var mockSnippet = models.Snippet{
    ID:      1,
    Title:   "An old silent pond",
    Content: "An old silent pond...",
    Created: time.Now(),
    Expires: time.Now(),
}

type SnippetModel struct{}

func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
    return 2, nil
}

func (m *SnippetModel) Get(id int) (models.Snippet, error) {
    switch id {
    case 1:
        return mockSnippet, nil
    default:
        return models.Snippet{}, models.ErrNoRecord
    }
}

func (m *SnippetModel) Latest() ([]models.Snippet, error) {
    return []models.Snippet{mockSnippet}, nil
}

And let’s do the same for our models.UserModel, like so:

File: internal/models/mocks/users.go
package mocks

import (
    "snippetbox.alexedwards.net/internal/models"
)

type UserModel struct{}

func (m *UserModel) Insert(name, email, password string) error {
    switch email {
    case "dupe@example.com":
        return models.ErrDuplicateEmail
    default:
        return nil
    }
}

func (m *UserModel) Authenticate(email, password string) (int, error) {
    if email == "alice@example.com" && password == "pa$$word" {
        return 1, nil
    }

    return 0, models.ErrInvalidCredentials
}

func (m *UserModel) Exists(id int) (bool, error) {
    switch id {
    case 1:
        return true, nil
    default:
        return false, nil
    }
}

Initializing the mocks

For the next step in our build, let’s head back to the testutils_test.go file and update the newTestApplication() function so that it creates an application struct with all the necessary dependencies for testing.

File: cmd/web/testutils_test.go
package main

import (
    "bytes"
    "io"
    "log/slog"
    "net/http"
    "net/http/cookiejar"
    "net/http/httptest"
    "testing"
    "time" // New import

    "snippetbox.alexedwards.net/internal/models/mocks" // New import

    "github.com/alexedwards/scs/v2"    // New import
    "github.com/go-playground/form/v4" // New import
)

func newTestApplication(t *testing.T) *application {
    // Create an instance of the template cache.
    templateCache, err := newTemplateCache()
    if err != nil {
        t.Fatal(err)
    }

    // And a form decoder.
    formDecoder := form.NewDecoder()

    // And a session manager instance. Note that we use the same settings as
    // production, except that we *don't* set a Store for the session manager.
    // If no store is set, the SCS package will default to using a transient
    // in-memory store, which is ideal for testing purposes.
    sessionManager := scs.New()
    sessionManager.Lifetime = 12 * time.Hour
    sessionManager.Cookie.Secure = true

    return &application{
        logger:         slog.New(slog.NewTextHandler(io.Discard, nil)),
        snippets:       &mocks.SnippetModel{}, // Use the mock.
        users:          &mocks.UserModel{},    // Use the mock.
        templateCache:  templateCache,
        formDecoder:    formDecoder,
        sessionManager: sessionManager,
    }
}

...

If you go ahead and try to run the tests now, it will fail to compile with the following errors:

$ go test ./cmd/web
# snippetbox.alexedwards.net/cmd/web [snippetbox.alexedwards.net/cmd/web.test]
cmd/web/testutils_test.go:40:19: cannot use &mocks.SnippetModel{} (value of type *mocks.SnippetModel) as type *models.SnippetModel in struct literal
cmd/web/testutils_test.go:41:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type *models.UserModel in struct literal
FAIL    snippetbox.alexedwards.net/cmd/web [build failed]
FAIL

This is happening because our application struct is expecting pointers to models.SnippetModel and models.UserModel instances, but we are trying to use pointers to mocks.SnippetModel and mocks.UserModel instances instead.

The idiomatic fix for this is to change our application struct so that it uses interfaces which are satisfied by both our mock and production database models.

To do this, let’s head back to our internal/models/snippets.go file and create a new SnippetModelInterface interface type that describes the methods that our actual SnippetModel struct has.

File: internal/models/snippets.go
package models

import (
    "database/sql"
    "errors"
    "time"
)

type SnippetModelInterface interface {
    Insert(title string, content string, expires int) (int, error)
    Get(id int) (Snippet, error)
    Latest() ([]Snippet, error)
}

...

And let’s also do the same thing for our UserModel struct too:

File: internal/models/users.go
package models

import (
    "database/sql"
    "errors"
    "strings"
    "time"

    "github.com/go-sql-driver/mysql"
    "golang.org/x/crypto/bcrypt"
)

type UserModelInterface interface {
    Insert(name, email, password string) error
    Authenticate(email, password string) (int, error)
    Exists(id int) (bool, error)
}

...

Now that we’ve defined those interface types, let’s update our application struct to use them instead of the concrete SnippetModel and UserModel types. Like so:

File: cmd/web/main.go
package main

import (
    "crypto/tls"
    "database/sql"
    "flag"
    "html/template"
    "log/slog"
    "net/http"
    "os"
    "time"

    "snippetbox.alexedwards.net/internal/models"

    "github.com/alexedwards/scs/mysqlstore"
    "github.com/alexedwards/scs/v2"
    "github.com/go-playground/form/v4"
    _ "github.com/go-sql-driver/mysql"
)

type application struct {
    logger        *slog.Logger
    snippets       models.SnippetModelInterface // Use our new interface type.
    users          models.UserModelInterface    // Use our new interface type.
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}

...

And if you try running the tests again now, everything should work correctly.

$ go test ./cmd/web/
ok      snippetbox.alexedwards.net/cmd/web      0.008s

Let’s take a moment to pause and reflect on what we’ve just done.

We’ve updated the application struct so that instead of the snippets and users fields having the concrete types *models.SnippetModel and *models.UserModel they are interfaces instead.

So long as an type has the necessary methods to satisfy the interface, we can use them in our application struct. Both our ‘real’ database models (like models.SnippetModel) and mock database models (like mocks.SnippetModel) satisfy the interfaces, so we can now use them interchangeably.

Testing the snippetView handler

With that all now set up, let’s get stuck into writing an end-to-end test for our snippetView handler which uses these mocked dependencies.

As part of this test, the code in our snippetView handler will call the mock.SnippetModel.Get() method. Just to remind you, this mocked model method returns a models.ErrNoRecord unless the snippet ID is 1 — when it will return the following mock snippet:

var mockSnippet = models.Snippet{
    ID:      1,
    Title:   "An old silent pond",
    Content: "An old silent pond...",
    Created: time.Now(),
    Expires: time.Now(),
}

So specifically, we want to test that:

  1. For the request GET /snippet/view/1 we receive a 200 OK response with the relevant mocked snippet contained in the HTML response body.
  2. For all other requests to GET /snippet/view/* we should receive a 404 Not Found response.

For the first part here, we want to check that the request body contains some specific content, rather than being exactly equal to it. Let’s quickly add a new StringContains() function to our assert package to help with that:

File: internal/assert/assert.go
package assert

import (
    "strings" // New import
    "testing"
)

...

func StringContains(t *testing.T, actual, expectedSubstring string) {
    t.Helper()

    if !strings.Contains(actual, expectedSubstring) {
        t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring)
    }
}

And then open up the cmd/web/handlers_test.go file and create a new TestSnippetView test like so:

File: cmd/web/handlers_test.go
package main

...

func TestSnippetView(t *testing.T) {
    // Create a new instance of our application struct which uses the mocked
    // dependencies.
    app := newTestApplication(t)

    // Establish a new test server for running end-to-end tests.
    ts := newTestServer(t, app.routes())
    defer ts.Close()

    // Set up some table-driven tests to check the responses sent by our
    // application for different URLs.
    tests := []struct {
        name     string
        urlPath  string
        wantCode int
        wantBody string
    }{
        {
            name:     "Valid ID",
            urlPath:  "/snippet/view/1",
            wantCode: http.StatusOK,
            wantBody: "An old silent pond...",
        },
        {
            name:     "Non-existent ID",
            urlPath:  "/snippet/view/2",
            wantCode: http.StatusNotFound,
        },
        {
            name:     "Negative ID",
            urlPath:  "/snippet/view/-1",
            wantCode: http.StatusNotFound,
        },
        {
            name:     "Decimal ID",
            urlPath:  "/snippet/view/1.23",
            wantCode: http.StatusNotFound,
        },
        {
            name:     "String ID",
            urlPath:  "/snippet/view/foo",
            wantCode: http.StatusNotFound,
        },
        {
            name:     "Empty ID",
            urlPath:  "/snippet/view/",
            wantCode: http.StatusNotFound,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            code, _, body := ts.get(t, tt.urlPath)

            assert.Equal(t, code, tt.wantCode)

            if tt.wantBody != "" {
                assert.StringContains(t, body, tt.wantBody)
            }
        })
    }
}

If you run the tests again with the -v flag enabled, you should now see the new, passing, TestSnippetView sub-tests in the output:

$ go test -v ./cmd/web/
=== RUN   TestPing
--- PASS: TestPing (0.00s)
=== RUN   TestSnippetView
=== RUN   TestSnippetView/Valid_ID
=== RUN   TestSnippetView/Non-existent_ID
=== RUN   TestSnippetView/Negative_ID
=== RUN   TestSnippetView/Decimal_ID
=== RUN   TestSnippetView/String_ID
=== RUN   TestSnippetView/Empty_ID
--- PASS: TestSnippetView (0.01s)
    --- PASS: TestSnippetView/Valid_ID (0.00s)
    --- PASS: TestSnippetView/Non-existent_ID (0.00s)
    --- PASS: TestSnippetView/Negative_ID (0.00s)
    --- PASS: TestSnippetView/Decimal_ID (0.00s)
    --- PASS: TestSnippetView/String_ID (0.00s)
    --- PASS: TestSnippetView/Empty_ID (0.00s)
=== RUN   TestCommonHeaders
--- PASS: TestCommonHeaders (0.00s)
=== RUN   TestHumanDate
=== RUN   TestHumanDate/UTC
=== RUN   TestHumanDate/Empty
=== RUN   TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
    --- PASS: TestHumanDate/UTC (0.00s)
    --- PASS: TestHumanDate/Empty (0.00s)
    --- PASS: TestHumanDate/CET (0.00s)
PASS
ok      snippetbox.alexedwards.net/cmd/web      0.015s

As an aside, notice how the names of the sub-tests have been canonicalized? Go automatically replaces any spaces in the sub-test name with an underscore (and any non-printable characters will also be escaped) in the test output.