DEV Community

Cover image for Testing your routes
Andres Court
Andres Court

Posted on

Testing your routes

If you've been following this tutorial, you'd have the following file structure:

Unit testing

With unit testing we just want to verify that the tested functions work as expected in isolation, so we will mock database behavior. I like having the tests I write next to the files where the functions reside.

file: internal/router/health_test.go

package router_test

import "testing"

func TestHealthRoute(t *testing.T) {
    a := true
    if a {
        t.Log("a is true")
        t.Fail()
    }
}
Enter fullscreen mode Exit fullscreen mode

To run the tests, you have to execute the following command:

go test ./...
Enter fullscreen mode Exit fullscreen mode

With the test we wrote we will expect the following:

Lets fix the previous test so it is a successful test

file: internal/router/health_test.go

package router_test

import "testing"

func TestHealthRoute(t *testing.T) {
    a := false
    if a {
        t.Log("a is true")
        t.Fail()
    }
}
Enter fullscreen mode Exit fullscreen mode

After running the test, this is what we've got

Now that we've seen what a failing and successful test look like lets continue with the process.

The testing library

The method to perform checks in our test works, but it is to verbose in my opinion, luckily there is a library that helps us with that

go get github.com/stretchr/testify
Enter fullscreen mode Exit fullscreen mode

Now that we have the testing library, the code we had before will look like the following:

package router_test

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestHealthRoute(t *testing.T) {
    a := true
    assert.Equal(t, a, false)
}
Enter fullscreen mode Exit fullscreen mode

And its output will be:

With this library we gain two main things:

  • An easier to read code
  • A more understandable test results

A successful running test will look the same as before.

Mocking the Database Connection

With what we've got at the moment, we don't have all the tools to test the router, unless we have a database for testing purposes only. There is an application in Go called mockery that we will use to simultate the connection.

To install we need to run:

go install github.com/vektra/mockery/v2@2.53.6
Enter fullscreen mode Exit fullscreen mode

This will install mockery to our system, to check if it is working properly

mockery --version
Enter fullscreen mode Exit fullscreen mode

Setting up Mockery

To setup mockery we need to create a configuration file called .mockery.yaml that should be in the root of our project

all: True
with-expecter: True
output: "internal/mocks"
Enter fullscreen mode Exit fullscreen mode

This configuration file will create mocks for all of our interfaces inside the internal/mocks directory. To generate the mocks we need to run

mockery
Enter fullscreen mode Exit fullscreen mode

Remember do not modify this generated files since they will be modified again on each generation. On finish this will be our tree file structure

Writing our first test

In the health_test.go file we need to do the following changes:

file: internal/router/health_test.go

package router_test

import (
    "testing"

    "github.com/alcb1310/bookstore/internal/mocks"
    "github.com/alcb1310/bookstore/internal/router"
    "github.com/stretchr/testify/assert"
)

func TestHealthRoute(t *testing.T) {
    db := mocks.NewService(t)
    s := router.New(8080, db)
    s.Router()
    assert.NotNil(t, s)
}
Enter fullscreen mode Exit fullscreen mode

If we run this test, we will find that the test will be waiting for requests, and will not continue executing, so we will need to refactor our code so that the listening for connection occurs in the main function

file: internal/router/router.go

package router

import (
    "time"

    "github.com/alcb1310/bookstore/internal/database"
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/httprate"
)
Enter fullscreen mode Exit fullscreen mode

func (s *service) Router() *chi.Mux {
r := chi.NewRouter()

r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.CleanPath)
r.Use(middleware.Recoverer)
r.Use(httprate.LimitByIP(100, 1*time.Minute))

r.Get("/", HandleErrors(HomeRoute))
r.Get("/health", HandleErrors(s.HealthRoute))

return r
Enter fullscreen mode Exit fullscreen mode

}

So now in our router function, we just return the mux structure which will enable us to listen for connections on the main function

file: cmd/api/main.go

package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "os"
    ...
)

func main() {
    ...

    s := router.New(port, db)
    slog.Info("Starting server", "port", port)
    h := s.Router()
    if err := http.ListenAndServe(fmt.Sprintf(":%d", port), h); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

If we now run our test, we will have a successful test

Testing the happy path

Now lets write a test where we can verify we get the expected result when the database is working correctly

file: internal/router/health_test.go

package router_test

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/alcb1310/bookstore/internal/mocks"
    "github.com/alcb1310/bookstore/internal/router"
    "github.com/stretchr/testify/assert"
)

func TestHealthRoute(t *testing.T) {
    db := mocks.NewService(t)
    s := router.New(8080, db)
    s.Router()
    assert.NotNil(t, s)

    testURL := "/health"

    db.EXPECT().HealthCheck().Return(nil).Times(1)

    req, err := http.NewRequest(http.MethodGet, testURL, nil)
    assert.NoError(t, err)

    rec := httptest.NewRecorder()
    s.Router().ServeHTTP(rec, req)

    responseBody := map[string]any{}
    err = json.Unmarshal(rec.Body.Bytes(), &responseBody)
    assert.NoError(t, err)

    assert.Equal(t, http.StatusOK, rec.Code)
    assert.Equal(t, "ok", responseBody["status"])
}
Enter fullscreen mode Exit fullscreen mode

Testing the Error reponses

Right know we've mocked the health database response, but we need to test also the error responses, but with the approach we currently have, we need to create a new function for each case, so let's refactor our test to be able to test multiple cases in the same function

file: internal/router/health_test.go

...

func TestHealthRoute(t *testing.T) {
    db := mocks.NewService(t)
    s := router.New(8080, db)
    s.Router()
    assert.NotNil(t, s)

    testURL := "/health"

    testCases := []struct {
        name     string
        status   int
        response map[string]any
        check    *mocks.Service_HealthCheck_Call
    }{
        {
            name:   "should return ok",
            status: http.StatusOK,
            response: map[string]any{
                "status": "ok",
            },
            check: db.EXPECT().HealthCheck().Return(nil),
        },
    }

    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            if tc.check != nil {
                tc.check.Times(1)
            }

            req, err := http.NewRequest(http.MethodGet, testURL, nil)
            assert.NoError(t, err)

            rec := httptest.NewRecorder()
            s.Router().ServeHTTP(rec, req)

            responseBody := map[string]any{}
            err = json.Unmarshal(rec.Body.Bytes(), &responseBody)
            assert.NoError(t, err)

            assert.Equal(t, tc.status, rec.Code)
            assert.Equal(t, tc.response, responseBody)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

With this refactor, we have the same test as we had before, now lets create another test case, where we mock an error response from the database

file: internal/router/health_test.go

package router_test

import (
    "encoding/json"
    "fmt"
    ...

    "github.com/alcb1310/bookstore/internal/interfaces"
    ...
)

func TestHealthRoute(t *testing.T) {
    ...

    testCases := []struct {
        name     string
        status   int
        response map[string]any
        check    *mocks.Service_HealthCheck_Call
    }{
        ...
        {
            name:   "database is not available",
            status: http.StatusGatewayTimeout,
            response: map[string]any{
                "error": "Database is not available",
            },
            check: db.EXPECT().HealthCheck().Return(&interfaces.APIError{
                Code:          http.StatusGatewayTimeout,
                Msg:           "Database is not available",
                OriginalError: fmt.Errorf("database is not available"),
            }),
        },
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

As you can see, with this way of creating tests, it is very easy to add new test cases. We have just one final test case we can add by mocking and that is what will happen when we get an unknown error

file: internal/router/health_test.go

...

func TestHealthRoute(t *testing.T) {
    ...

    testCases := []struct {
        name     string
        status   int
        response map[string]any
        check    *mocks.Service_HealthCheck_Call
    }{
        ...
        {
            name:   "unknown error",
            status: http.StatusInternalServerError,
            response: map[string]any{
                "error": "Unknown database error",
            },
            check: db.EXPECT().HealthCheck().Return(fmt.Errorf("Unknown database error")),
        },
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

Integration Tests

At the moment we've learned how to create unit tests, which are very fast to execute, but we need to mock the database connection, so, in order to do that we have integration tests. This kind of tests are much slower but you can simulate the real environment and test around it.

First we need to install the test containers package

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres
Enter fullscreen mode Exit fullscreen mode

Since we need to create a testing database to use, we need to move the database connection string out to the main function

file internal/database/database.go

...

func New(url string) (Service, error) {
    if url == "" {
        return nil, fmt.Errorf("DATABASE_URL is not set")
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

file cmd/api/main.go

...

func main() {
    ...

    url := os.Getenv("DATABASE_URL")
    db, err := database.New(url)
    if err != nil {
        slog.Error("Error connecting to database", "error", err)
        panic(err)
    }

    ...
}
Enter fullscreen mode Exit fullscreen mode

First integration test

Now that we have our configuration ready, lets write our first integration test

file tests/health_test.go

package tests

import (
    "context"
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestHealthEndPoint(t *testing.T) {
    ctx := context.Background()

    pgContainer, err := postgres.Run(ctx,
        "postgres:18-alpine",
        postgres.WithDatabase("bookstore"),
        postgres.WithUsername("postgres"),
        postgres.WithPassword("postgres"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2).
                WithStartupTimeout(15*time.Second)),
    )

    assert.NotNil(t, pgContainer)
    assert.NoError(t, err)

    t.Cleanup(func() {
        if pgContainer != nil {
            err = pgContainer.Terminate(ctx)
            assert.NoError(t, err)
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

This test will create a Docker container with an instance of a PostgreSQL 18 database named bookstore, validates that the container is started correctly and when the test ends, it will terminate the container

Lets test that it can connect to a database

file tests/common.go

package tests

import (
    "context"
    "fmt"
    "testing"

    "github.com/alcb1310/bookstore/internal/database"
    "github.com/alcb1310/bookstore/internal/router"
    "github.com/stretchr/testify/assert"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
)

func createServer(t *testing.T, ctx context.Context, pgContainer *postgres.PostgresContainer) (*router.Router, error) {
    connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
    assert.NoError(t, err)
    db, _ := database.New(connStr)
    assert.NotNil(t, db)
    if db == nil {
        return nil, fmt.Errorf("db is nil")
    }

    s := router.New(0, db)
    return s, err
}
Enter fullscreen mode Exit fullscreen mode

file: tests/health_test.go

package tests

import (
    ...
    "encoding/json"
    "net/http"
    "net/http/httptest"
    ...

    "github.com/stretchr/testify/assert"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func TestHealthEndPoint(t *testing.T) {
    ...

    testURL := "/health"
    s, err := createServer(t, ctx, pgContainer)
    assert.NoError(t, err)
    assert.NotNil(t, s)

    t.Run("Integration - should return ok", func(t *testing.T) {
        expected := map[string]any{"status": "ok"}
        req, err := http.NewRequest("GET", testURL, nil)
        assert.NoError(t, err)
        res := httptest.NewRecorder()
        s.Router().ServeHTTP(res, req)

        assert.Equal(t, http.StatusOK, res.Code)
        responseBody := map[string]any{}
        err = json.Unmarshal(res.Body.Bytes(), &responseBody)
        assert.NoError(t, err)
        assert.Equal(t, expected, responseBody)
    })
}
Enter fullscreen mode Exit fullscreen mode

Error simulation

We've currently tested what will happen if we can connect properly to the database, but, what will happen if our application looses connection for whatever reason to the database, in that scenario, we expect an error to occur, so lets test that

file tests/health_test.go

...

func TestHealthEndPoint(t *testing.T) {
    ...

    t.Run("Integration - database is not available", func(t *testing.T) {
        err := pgContainer.Terminate(ctx)
        assert.NoError(t, err)
        pgContainer = nil
        expected := map[string]any{"error": "Database is not available"}
        req, err := http.NewRequest("GET", testURL, nil)
        assert.NoError(t, err)
        res := httptest.NewRecorder()
        s.Router().ServeHTTP(res, req)

        assert.Equal(t, http.StatusServiceUnavailable, res.Code)
        responseBody := map[string]any{}
        err = json.Unmarshal(res.Body.Bytes(), &responseBody)
        assert.NoError(t, err)
        assert.Equal(t, expected, responseBody)
    })
}
Enter fullscreen mode Exit fullscreen mode

Adding GitHub actions

Finally after we are done setting up our tests, we need to ensure we are only able to merge our PRs only if all of our tests complete successfully, to do so, we need to create GitHub Actions. Even though you can run both integration and unit tests at once, I prefer to run them separately, that way I can better understand where did something went wrong.

file: .github/workflows/unit-test.yml

name: Unit Tests
on: pull_request

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: 1.25
      - name: Run tests
        run: go test ./internal/...
Enter fullscreen mode Exit fullscreen mode

file: .github/workflows/integration-test.yml

name: IntegrationTests

on: pull_request

jobs:
  integration-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Set up Go
        uses: actions/setup-go@v4
        with:
          go-version: 1.25
      - name: Run tests
        run: go test ./tests/...
Enter fullscreen mode Exit fullscreen mode

Now that we have all our workflows created, we need to enforce them in our code, so in your project's GitHub, lets go to the settings page, then select the ruleset page inside the rules category. In that page select New ruleset button.

  • As the rule name, I like to use the target branch name
  • As the target, since we are targeting the master branch, then lets select the *include default branch" option
  • Select the Require status checks to pass and add there both of the workflows we've created

Finally we can accept the changes and we are done, if we create a test that is unsuccessfull, we wont be able to merge the pull request inside of GitHub

Summary

In this article we created unit and integration tests for our application and wrote some GitHub workflows to enforce that the tests are successfull before we are allowed to merge our changes

Top comments (0)