Skip to content

Commit

Permalink
chore: add walthrough for repo and api test suites
Browse files Browse the repository at this point in the history
  • Loading branch information
antosdaniel committed Sep 5, 2023
1 parent 2fe50e1 commit 9d65d39
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 31 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Go presentation: Beyond unit tests

A dive into integration and end-to-end testing in Go.

## Walkthrough

[app_to_test](./app_to_test) contains very simple web application. It's expense
tracker, with small HTTP API. Don't take too many of these ideas to production 🙃
You can play with it through [expenses.http](app_to_test%2Fexpenses.http).

First suite of test to check out is [test_repos](./test_repos). It uses test
containers to spin up Postgres database and checks if repository works as promised.
It's a good tool for more complex SQL queries, or testing against concurrency issues.

Next one is [test_http_api](test_http_api). It includes database trick from
previous suite, but also spins up API. Explored idea here is how to keep tests
like these easy to read, and not a chore to write (at least, after the first one).

## Requirements

- Go 1.21
- Docker Desktop 24+

## Start application

```shell
docker compose up
```
17 changes: 14 additions & 3 deletions test_rest_api/api_test.go → test_http_api/api_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package test_rest_api
package test_http_api

import (
"bytes"
Expand All @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
Expand All @@ -22,15 +23,22 @@ func TestAPI(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

// Thanks to the usage of `t.Cleanup` and `require.NoError`, our setup is short, but doesn't hide any errors.
server := startServer(t, ctx)

// Take a look at `getRequest()`, `call()` and `getExpectedResponse()` functions. They also do not require any error
// handling, nor cleanup. Additionally, we are using test name to load the correct file. This way we can ensure
// that test name and file name do not drift apart.
// You could move these helper to separate package, and share them among tests.

t.Run("summarize expenses", func(t *testing.T) {
// Notice that if we moved this test after add expense test, this test would fail.
// Usually it's better to reset the database before each test.
response, responseBody := call(t, server, http.MethodGet, "/expenses/summarize", "")

assert.Equal(t, http.StatusOK, response.StatusCode, "status code")
expected := getExpectedResponse(t)
// If your response contains unpredictable fields, you can use https://github.com/kinbiko/jsonassert instead.
assert.JSONEq(t, expected, responseBody, "response body")
})
t.Run("add expense successfully", func(t *testing.T) {
Expand All @@ -52,7 +60,8 @@ func startServer(t *testing.T, ctx context.Context) *httptest.Server {
t.Helper()

dbContainer := test_repos.StartDB(t, ctx)
require.NoError(t, os.Setenv("DB_URL", dbContainer.DSN), "set DB_URL env var")
err := os.Setenv("DB_URL", dbContainer.DSN)
require.NoError(t, err, "set DB_URL env var")

setup, err := api.NewSetup()
require.NoError(t, err, "new setup")
Expand All @@ -72,7 +81,9 @@ func call(t *testing.T, server *httptest.Server, method, path, body string) (*ht
if body != "" {
b = bytes.NewBuffer([]byte(body))
}
request, err := http.NewRequest(method, server.URL+path, b)
u, err := url.JoinPath(server.URL, path)
require.NoError(t, err, "join url path")
request, err := http.NewRequest(method, u, b)
require.NoError(t, err, "new request")

response, err := server.Client().Do(request)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
version: "3.9"
services:
db:
image: "postgres:15.2-alpine"
environment:
POSTGRES_DB: expense_tracker
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret123
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 3s
timeout: 60s
retries: 10
start_period: 5s
ports:
# Random port to avoid collision. Helpful in CI!
- "5432"

migrate:
build:
context: app_to_test/db
environment:
DB_URL: "postgres://postgres:secret123@db:5432/expense_tracker?sslmode=disable"
depends_on:
db:
version: "3.9"
services:
db:
image: "postgres:15.2-alpine"
environment:
POSTGRES_DB: expense_tracker
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret123
healthcheck:
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}" ]
interval: 3s
timeout: 60s
retries: 10
start_period: 5s
ports:
# Random port to avoid collision. Helpful in CI!
- "5432"

migrate:
build:
context: ../app_to_test/db
environment:
DB_URL: "postgres://postgres:secret123@db:5432/expense_tracker?sslmode=disable"
depends_on:
db:
condition: service_healthy
3 changes: 2 additions & 1 deletion test_repos/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ import (
"github.com/stretchr/testify/require"
)

// This would usually live next to repo it is testing
// These tests would usually live next to repo it is testing. It's in a separate package to suit the presentation.

func TestExpenseRepo_Add(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

// Magic happens inside `StartDB` 🪄
db := StartDB(t, ctx).DB
expenseRepo := api.NewExpenseRepo(db)

Expand Down
16 changes: 14 additions & 2 deletions test_repos/start_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"database/sql"
"fmt"
"path"
"runtime"
"testing"

"github.com/stretchr/testify/require"
Expand All @@ -16,22 +18,32 @@ type DBContainer struct {
DSN string
}

// You will most likely have multiple setups for different test scenarios, depending on your stack: SQL database, redis,
// kafka etc. It's a good idea to share this setup across all tests that need them.

func StartDB(t *testing.T, ctx context.Context) DBContainer {
t.Helper()

compose, err := tc.NewDockerCompose("./../docker-compose-db-only.yaml")
_, filename, _, ok := runtime.Caller(0)
if !ok {
// Using `require.NoError()`, or `t.Fatalf()`, keeps test setup cleaner - no ugly error returning.
t.Fatalf("could not get current file")
}
compose, err := tc.NewDockerCompose(path.Join(path.Dir(filename), "docker-compose-db-only.yaml"))
require.NoError(t, err, "docker compose setup")

t.Cleanup(func() {
compose.Down(context.Background(), tc.RemoveOrphans(true), tc.RemoveImagesLocal)
compose.Down(ctx, tc.RemoveOrphans(true), tc.RemoveImagesLocal)
})

// We want to wait for the migrations to finish. In our case, this means exiting of the migrate container.
err = compose.WaitForService("migrate", wait.ForExit()).Up(ctx)
require.NoError(t, err, "docker compose up")

dbContainer, err := compose.ServiceContainer(ctx, "db")
require.NoError(t, err, "docker compose db container")

// Port is randomly assigned by docker. We need to get it.
dbPort, err := dbContainer.MappedPort(ctx, "5432")
require.NoError(t, err, "docker compose db port")

Expand Down

0 comments on commit 9d65d39

Please sign in to comment.