From 4d355a5a851e5f454e23ea98c2df2ab4b6a2433a Mon Sep 17 00:00:00 2001 From: Daniel Antos Date: Sun, 10 Sep 2023 22:10:23 +0200 Subject: [PATCH] chore: add e2e tests --- README.md | 7 ++ docker-compose.yaml | 3 + test_e2e/docker-compose-for-e2e.yaml | 44 +++++++++ test_e2e/e2e_test.go | 131 +++++++++++++++++++++++++ test_repos/docker-compose-db-only.yaml | 1 + 5 files changed, 186 insertions(+) create mode 100644 test_e2e/docker-compose-for-e2e.yaml create mode 100644 test_e2e/e2e_test.go diff --git a/README.md b/README.md index 2d1ce77..0432446 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ 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). +Final one is [test_e2e](test_e2e). We are spinning up whole application, and +running tests against that. To make things even more interesting, we are stubing +external API for better reliability. This checks that our application starts correctly, +and that all the components work together. It's certainly more complex, not as +quick to write, nor run. Use them sparingly, to make sure that crucial parts +of your application work as expected. These could also be named smoke tests. + ## Requirements - Go 1.21 diff --git a/docker-compose.yaml b/docker-compose.yaml index 323cafc..1399b6d 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,6 +1,7 @@ version: "3.9" services: server: + image: "expense_tracker/server:latest" build: dockerfile: app_to_test/server/Dockerfile environment: @@ -32,6 +33,7 @@ services: - "5432:5432" migrate: + image: "expense_tracker/migrate:latest" build: context: app_to_test/db environment: @@ -41,6 +43,7 @@ services: condition: service_healthy bank-api: + image: "expense_tracker/bank_api:latest" build: context: ./bank_api networks: diff --git a/test_e2e/docker-compose-for-e2e.yaml b/test_e2e/docker-compose-for-e2e.yaml new file mode 100644 index 0000000..1fe7153 --- /dev/null +++ b/test_e2e/docker-compose-for-e2e.yaml @@ -0,0 +1,44 @@ +version: "3.9" +services: + server: + image: "expense_tracker/server:latest" + build: + context: ./.. + dockerfile: app_to_test/server/Dockerfile + environment: + DB_URL: "postgres://postgres:secret123@db:5432/expense_tracker?sslmode=disable" + BANK_API_URL: "${BANK_API_URL}" + depends_on: + db: + condition: service_healthy + networks: + - default + ports: + # Once again, we use a random port to avoid conflicts. + - "8000" + + 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 + + migrate: + image: "expense_tracker/migrate:latest" + build: + context: ./../app_to_test/db + environment: + DB_URL: "postgres://postgres:secret123@db:5432/expense_tracker?sslmode=disable" + depends_on: + db: + condition: service_healthy + +networks: + default: \ No newline at end of file diff --git a/test_e2e/e2e_test.go b/test_e2e/e2e_test.go new file mode 100644 index 0000000..2ea3aff --- /dev/null +++ b/test_e2e/e2e_test.go @@ -0,0 +1,131 @@ +//go:build e2e_tests + +package test_e2e + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + tc "github.com/testcontainers/testcontainers-go/modules/compose" + "github.com/testcontainers/testcontainers-go/wait" +) + +const expenseToSyncID = "677df0c4-d829-42eb-a0c9-29d5b0a2bbe4" + +func TestE2E(t *testing.T) { + ctx := context.Background() + + // At first, spin up mocked bank API, and get its address. + bankAPIAddress := mockBankAPI(t) + address := startApp(t, ctx, bankAPIAddress) + + t.Run("app is starting properly", func(t *testing.T) { + response, err := http.Get(fmt.Sprintf("%s/expenses/all", address)) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.StatusCode, "status code") + }) + t.Run("sync expenses", func(t *testing.T) { + response, err := http.Get(fmt.Sprintf("%s/expenses/sync", address)) + + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode, "status code") + + // After sync, our app should now store expense from mocked API. + response, err = http.Get(fmt.Sprintf("%s/expenses/all", address)) + require.NoError(t, err) + responseBody, err := io.ReadAll(response.Body) + require.NoError(t, err) + assert.Contains(t, string(responseBody), expenseToSyncID) + }) +} + +func startApp(t *testing.T, ctx context.Context, bankAPIAddress string) (address string) { + t.Helper() + + compose, err := tc.NewDockerComposeWith( + tc.WithStackFiles("./docker-compose-for-e2e.yaml"), + // Giving unique name to each docker compose stack allows us to run tests in parallel. + tc.StackIdentifier(uuid.New().String()), + ) + require.NoError(t, err, "docker compose setup") + + t.Cleanup(func() { + // When test fail, printing logs is usually helpful :) + if t.Failed() { + reader, _ := getServerContainer(t, ctx, compose).Logs(ctx) + bytes, _ := io.ReadAll(reader) + fmt.Println(`\nLogs from "server" container:\n`, string(bytes)) + } + assert.NoError(t, compose.Down(ctx, tc.RemoveOrphans(true), tc.RemoveImagesLocal)) + }) + + err = compose. + WithEnv(map[string]string{ + "BANK_API_URL": bankAPIAddress, + }). + WaitForService("server", wait.ForLog("running...")). + Up(ctx) + require.NoError(t, err, "docker compose up") + + // Port is randomly assigned by docker. We need to get it. + apiPort, err := getServerContainer(t, ctx, compose).MappedPort(ctx, "8000") + require.NoError(t, err, "docker compose server port") + + return fmt.Sprintf("http://localhost:%s", apiPort.Port()) +} + +func getServerContainer(t *testing.T, ctx context.Context, compose tc.ComposeStack) testcontainers.Container { + t.Helper() + + serverContainer, err := compose.ServiceContainer(ctx, "server") + require.NoError(t, err, "docker compose server container") + + return serverContainer +} + +func mockBankAPI(t *testing.T) (address string) { + t.Helper() + + // We are getting random available port. + // Using 0.0.0.0 address if preferable over localhost, as the former will usually not work in CI. + // Oh, and Desktop Docker doesn't support IPv6 yet, so it's better to specify "tcp4" network. + listener, err := net.Listen("tcp4", "0.0.0.0:0") + require.NoError(t, err, "could not start listener") + addr, err := net.ResolveTCPAddr(listener.Addr().Network(), listener.Addr().String()) + require.NoError(t, err, "could not resolve tcp addr") + + // Setup mocked API. + mux := http.NewServeMux() + mux.Handle("/get-transactions", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(fmt.Sprintf(` + [ + { + "id": "%s", + "amount": 500.00, + "category": "food", + "created_at": "2020-01-01T00:00:00Z" + } + ]`, + expenseToSyncID))) + })) + + server := httptest.NewUnstartedServer(mux) + server.Listener = listener + server.Start() + t.Cleanup(func() { + server.Close() + }) + + // "host.docker.internal" resolves to host network. Thanks to this we can access our HTTP mocks from container. + return fmt.Sprintf("http://host.docker.internal:%d", addr.Port) +} diff --git a/test_repos/docker-compose-db-only.yaml b/test_repos/docker-compose-db-only.yaml index fe8b77c..bf7bdbe 100644 --- a/test_repos/docker-compose-db-only.yaml +++ b/test_repos/docker-compose-db-only.yaml @@ -17,6 +17,7 @@ services: - "5432" migrate: + image: "expense_tracker/migrate:latest" build: context: ../app_to_test/db environment: