From 9d65d39f9d5b9836aa3e1343bae391e3649c0e54 Mon Sep 17 00:00:00 2001 From: Daniel Antos Date: Tue, 5 Sep 2023 22:58:57 +0200 Subject: [PATCH] chore: add walthrough for repo and api test suites --- README.md | 28 +++++++++++ {test_rest_api => test_http_api}/api_test.go | 17 +++++-- .../TestAPI/add_expense_fails/request.json | 0 .../TestAPI/add_expense_fails/response.json | 0 .../add_expense_successfully/request.json | 0 .../TestAPI/summarize_expenses/response.json | 0 .../docker-compose-db-only.yaml | 50 +++++++++---------- test_repos/repo_test.go | 3 +- test_repos/start_db.go | 16 +++++- 9 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 README.md rename {test_rest_api => test_http_api}/api_test.go (76%) rename {test_rest_api => test_http_api}/testdata/TestAPI/add_expense_fails/request.json (100%) rename {test_rest_api => test_http_api}/testdata/TestAPI/add_expense_fails/response.json (100%) rename {test_rest_api => test_http_api}/testdata/TestAPI/add_expense_successfully/request.json (100%) rename {test_rest_api => test_http_api}/testdata/TestAPI/summarize_expenses/response.json (100%) rename docker-compose-db-only.yaml => test_repos/docker-compose-db-only.yaml (91%) diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d1ce77 --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/test_rest_api/api_test.go b/test_http_api/api_test.go similarity index 76% rename from test_rest_api/api_test.go rename to test_http_api/api_test.go index 8b8ea4b..6562be5 100644 --- a/test_rest_api/api_test.go +++ b/test_http_api/api_test.go @@ -1,4 +1,4 @@ -package test_rest_api +package test_http_api import ( "bytes" @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "testing" "time" @@ -22,8 +23,14 @@ 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. @@ -31,6 +38,7 @@ func TestAPI(t *testing.T) { 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) { @@ -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") @@ -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) diff --git a/test_rest_api/testdata/TestAPI/add_expense_fails/request.json b/test_http_api/testdata/TestAPI/add_expense_fails/request.json similarity index 100% rename from test_rest_api/testdata/TestAPI/add_expense_fails/request.json rename to test_http_api/testdata/TestAPI/add_expense_fails/request.json diff --git a/test_rest_api/testdata/TestAPI/add_expense_fails/response.json b/test_http_api/testdata/TestAPI/add_expense_fails/response.json similarity index 100% rename from test_rest_api/testdata/TestAPI/add_expense_fails/response.json rename to test_http_api/testdata/TestAPI/add_expense_fails/response.json diff --git a/test_rest_api/testdata/TestAPI/add_expense_successfully/request.json b/test_http_api/testdata/TestAPI/add_expense_successfully/request.json similarity index 100% rename from test_rest_api/testdata/TestAPI/add_expense_successfully/request.json rename to test_http_api/testdata/TestAPI/add_expense_successfully/request.json diff --git a/test_rest_api/testdata/TestAPI/summarize_expenses/response.json b/test_http_api/testdata/TestAPI/summarize_expenses/response.json similarity index 100% rename from test_rest_api/testdata/TestAPI/summarize_expenses/response.json rename to test_http_api/testdata/TestAPI/summarize_expenses/response.json diff --git a/docker-compose-db-only.yaml b/test_repos/docker-compose-db-only.yaml similarity index 91% rename from docker-compose-db-only.yaml rename to test_repos/docker-compose-db-only.yaml index 10da0d7..fe8b77c 100644 --- a/docker-compose-db-only.yaml +++ b/test_repos/docker-compose-db-only.yaml @@ -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 \ No newline at end of file diff --git a/test_repos/repo_test.go b/test_repos/repo_test.go index 3fcddd1..16fde82 100644 --- a/test_repos/repo_test.go +++ b/test_repos/repo_test.go @@ -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) diff --git a/test_repos/start_db.go b/test_repos/start_db.go index 264de40..eabadf5 100644 --- a/test_repos/start_db.go +++ b/test_repos/start_db.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "fmt" + "path" + "runtime" "testing" "github.com/stretchr/testify/require" @@ -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")