Beyond unit tests

Integration and e2e tests in Go

Repository with example app and tests

What are we talking about today?

  • When unit tests are not enough?
  • Good patterns for different scenarios:
    • Testing with database
    • Testing APIs
    • Testing entire app/service
  • How to keep tests easy to read, but not dreadful to write?

When unit tests are not enough?

When we want to:

  • Test how components interact with each other.
  • Make sure that setup is correct.
  • Ensure that crucial paths of our app/service works under happy conditions.

Testing with database

  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:
        - "5432"
    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
func StartDB(t *testing.T, ctx context.Context) DBContainer {
	// ...
	compose, err := tc.NewDockerCompose(path.Join(path.Dir(filename), "docker-compose-db-only.yaml"))
	// ...
	err = compose.WaitForService("migrate", wait.ForExit()).Up(ctx)
	// ...
	dbPort, err := dbContainer.MappedPort(ctx, "5432")
	// ...
	db, err := sql.Open("pgx", dsn)
	return DBContainer{ DB: db, DSN: dsn }
}
func TestExpenseRepo_Add(t *testing.T) {
	db := StartDB(t, ctx).DB
	expenseRepo := api.NewExpenseRepo(db)

	t.Run("successfully adds expense", func(t *testing.T) {
		expense := api.Expense{
			ID:       "c811c5d4-c38a-4f61-932d-d656c203b5f6",
			Amount:   123_50,
			Category: "food",
			Date:     time.Date(2020, 9, 5, 0, 0, 0, 0, time.UTC),
			Notes:    "some notes",
		}

		err := expenseRepo.Add(expense)

		require.NoError(t, err, "could not add expense")
		result := getAllExpenses(t, db).FindByID(expense.ID)
		if assert.NotNil(t, result, "expense not added") {
			assert.Equal(t, expense, *result, "added expense is different")
		}
	})
}

Testing APIs

func TestAPI(t *testing.T) {
	server := startServer(t, ctx)

	t.Run("summarize expenses", func(t *testing.T) {
		response, responseBody := call(t, server, http.MethodGet, "/expenses/summarize", "")

		assert.Equal(t, http.StatusOK, response.StatusCode, "status code")
		expected := getExpectedResponse(t)
		// Or use https://github.com/kinbiko/jsonassert
		assert.JSONEq(t, expected, responseBody, "response body")
	})
	t.Run("add expense fails", func(t *testing.T) {
		response, responseBody := call(t, server, http.MethodPost, "/expenses/add", getRequest(t))

		assert.Equal(t, http.StatusBadRequest, response.StatusCode, "status code")
		expected := getExpectedResponse(t)
		assert.JSONEq(t, expected, responseBody, "response body")
        
		result := getAllExpenses(t, db).FindByID("...")
		assert.Nil(t, result, "expense added")
	})
}

func startServer(t *testing.T, ctx context.Context) *httptest.Server {
	dbContainer := test_repos.StartDB(t, ctx)
	err := os.Setenv("DB_URL", dbContainer.DSN)
	// ...
	setup, err := api.NewSetup()
	// ...
	return httptest.NewServer(setup.APIMux)
}
func startServer(t *testing.T, ctx context.Context) *httptest.Server {
	// ...
}

func call(
	t *testing.T,
	srv *httptest.Server,
	method, path, body string,
) (*http.Response, string) {
	// ...
}

func getRequest(t *testing.T) string {
	// ...
}

func getExpectedResponse(t *testing.T) string {
	// ...
}

Go testing goodies

How do we get here?

func getRequest(t *testing.T, requestPath string) (string, error) {
	t.Helper()

	file, err := os.ReadFile(requestPath)
	if err != nil {
		return "", err
	}

	return string(file), nil
}

t.Name()

func getRequest(t *testing.T) (string, error) {
	t.Helper()

	path := fmt.Sprintf("./testdata/%s/request.json", t.Name())
	file, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}

	return string(file), nil
}
func getRequest(t *testing.T) (string, error) {
	t.Helper()

	path := fmt.Sprintf("./testdata/%s/request.json", t.Name())
	file, err := os.ReadFile(path)
	if err != nil {
		return "", err
	}

	return string(file), nil
}

t.FailNow()

func getRequest(t *testing.T) string {
	t.Helper()

	path := fmt.Sprintf("./testdata/%s/request.json", t.Name())
	file, err := os.ReadFile(path)
	require.NoError(t, err, "read file")

	return string(file)
}
func startServer(t *testing.T, ctx context.Context) (*httptest.Server, func()) {
	t.Helper()

	// ...

	server := httptest.NewServer(setup.APIMux)

	return server, server.Close
}

t.Cleanup()

func startServer(t *testing.T, ctx context.Context) *httptest.Server {
	t.Helper()

	// ...

	server := httptest.NewServer(setup.APIMux)
	t.Cleanup(func() {
		server.Close()
	})

	return server
}
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))
})

t.Failed()

func TestAPI(t *testing.T) {
	server := startServer(t, ctx)
    
	t.Run("add expense fails", func(t *testing.T) {
		response, responseBody := call(
			t, server, http.MethodPost, "/expenses/add", getRequest(t),
		)

		assert.Equal(t, http.StatusBadRequest, response.StatusCode, "status code")
		expected := getExpectedResponse(t)
		assert.JSONEq(t, expected, responseBody, "response body")
	})
}

Crispy clear tests, where helpers just disappear

Testing entire app/service

The original idea came from service like this. It was super annoying to test.

Let's see how our example app works

And this is a diagram of how test works

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
    # Thanks to this container can call our mock HTTP server on host machine.
    extra_hosts:
      - "host.docker.internal:host-gateway"
    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
const expenseToSyncID = "677df0c4-d829-42eb-a0c9-29d5b0a2bbe4"

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

	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")

		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 mockBankAPI(t *testing.T) (address string) {
	t.Helper()

	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)
	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")
	server.Listener = listener
	server.Start()
	t.Cleanup(func() {
		server.Close()
	})

	return fmt.Sprintf("http://host.docker.internal:%d", addr.Port)
}
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())
}

Thanks!

Question for you: Should you start containers within TestMain?

Any questions for me?