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()
}
}
To run the tests, you have to execute the following command:
go test ./...
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()
}
}
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
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)
}
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
This will install mockery to our system, to check if it is working properly
mockery --version
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"
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
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)
}
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"
)
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
}
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)
}
}
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"])
}
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)
})
}
}
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"),
}),
},
}
...
}
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")),
},
}
...
}
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
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")
}
...
}
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)
}
...
}
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)
}
})
}
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
}
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)
})
}
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)
})
}
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/...
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/...
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
masterbranch, 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

















