Testcontainers + Golang: Melhorando seus testes com Docker
Tags: docker, go, testing, coding
Published: 2024-07-17 00:53:47

No desenvolvimento de software, testar aplicativos que dependem de serviços externos, como bancos de dados, pode ser desafiador. Garantir que o ambiente de teste esteja configurado corretamente e que os testes sejam isolados e reproduzíveis é crucial para a qualidade do software. 

Neste artigo, vamos explorar como usar [Testcontainers](https://testcontainers.com/) com Golang para melhorar a produtividade e a qualidade dos testes de integração, garantindo um ambiente de teste consistente e isolado.

## O que é Testcontainers?
Testcontainers é uma biblioteca que facilita a criação e o gerenciamento de contêineres Docker diretamente a partir dos seus testes de código. Originalmente desenvolvida para Java, agora possui implementações para outras linguagens, incluindo [Golang](https://golang.testcontainers.org/quickstart/). 
A principal vantagem do Testcontainers é fornecer um ambiente de teste isolado e consistente, eliminando as variáveis e inconsistências que podem ocorrer em testes que dependem de serviços externos.

## Como o Testcontainers Funciona?
Testcontainers usa a API Docker para criar, configurar e gerenciar contêineres. De uma forma resumida, aqui estão os passos básicos de como ele funciona:

- **Criação do Contêiner**: O Testcontainers inicia um contêiner com base em uma imagem Docker especificada. Ele pode ser configurado para usar qualquer imagem disponível no Docker Hub ou em repositórios privados.
- **Configuração**: Você pode configurar o contêiner para atender às necessidades específicas do seu teste. Isso inclui definir variáveis de ambiente, montar volumes e configurar portas.
- **Esperas e Estratégias de Inicialização**: O Testcontainers fornece estratégias para esperar que o contêiner esteja pronto antes de executar os testes. Por exemplo, você pode esperar até que uma determinada porta esteja aberta ou até que um log específico apareça.
- **Conexão**: Uma vez que o contêiner está em execução e configurado, o Testcontainers fornece os detalhes de conexão (como URL de conexão do banco de dados) que podem ser usados nos testes.
- **Limpeza**: Após a execução dos testes, o Testcontainers garante que os contêineres sejam interrompidos e removidos, mantendo o ambiente de desenvolvimento limpo.

Essa abordagem garante que cada teste seja executado em um ambiente isolado, evitando interferências e garantindo reprodutibilidade.

## Por que usar Testcontainers?
Testcontainers oferece várias vantagens para testes de integração, entre elas:

- **Isolamento**: Cada teste é executado em um ambiente isolado, eliminando interferências entre testes.
- **Consistência**: Garantia de que o ambiente de teste é sempre o mesmo, independentemente de onde ou quando o teste é executado.
- **Facilidade de Configuração**: Automatiza a configuração do ambiente de teste, incluindo a inicialização e limpeza de contêineres Docker.
- **Reprodutibilidade**: Facilita a reprodução de bugs em um ambiente controlado e previsível.

## Case Real de uso

Toda essa teoria é muito legal, mas chegou a hora de aplicá-la na vida real. Para isso deixei um CRUD bem simples de uma loja de livros e será nosso exemplo de como podemos implementar um teste de integração e testar todo o caminho de nossa API.

> Link do repositório da [book-store](https://github.com/rflpazini/articles/tree/main/book-store)

Essa é a estrutura do nosso projeto:

```bash
book-store/
├── cmd/
│   └── bookstore/
│       └── main.go
├── internal/
│   ├── book/
│   │   ├── model.go
│   │   ├── repository.go
│   │   └── service.go
│   └── server/
│       └── server.go
├── pkg/
│   ├── api/
│   │   └── book/
│   │       └── handler.go //arquivo que iremos testar
│   ├── database/
│   │   └── postgres.go
│   └── utils/
│       └── response.go
└── go.mod
└── go.sum
```
## Implementação dos Testes de Integração
Vamos criar testes de integração para os handlers no pacote book. Usaremos o [Postgres module](https://golang.testcontainers.org/modules/postgres/)  para configurar um contêiner PostgreSQL para os testes. 
> Podemos utilizar qualquer contêiner que quisermos. Caso não exista uma implementação para um module específico para seu caso, basta utilizar o [GenericContainer](https://golang.testcontainers.org/features/creating_container/)

### Configuração do Contêiner e implementando os testes

A primeira coisa que iremos fazer é criar um arquivo de teste `handler_test.go` dentro do pacote `pkg/api/book`.

Uma de nossas principais funções será a
`setupTestContainer`. Ela configura e inicializa um contêiner PostgreSQL para testes de integração, retorna um pool de conexão do PostgreSQL e uma função de `teardown` para limpar o ambiente de teste após a execução dos testes.

```go
// pkg/api/book/handler_test.go

package book

import (
	"context"
	"testing"

	"github.com/jackc/pgx/v4/pgxpool"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

const (
	dbName = "bookstore"
	dbUser = "user"
	dbPass = "S3cret"
)

func setupTestContainer(t *testing.T) (*pgxpool.Pool, func()) {
	ctx := context.Background()

	// Configura o contêiner com a imagem Docker da versão que queremos utilizar,
	// nome do banco de dados, usuário e senha, e o driver de comunicação.
	postgresC, err := postgres.Run(
		ctx,
		"postgres:16-alpine",
		postgres.WithDatabase(dbName),
		postgres.WithUsername(dbUser),
		postgres.WithPassword(dbPass),
		postgres.BasicWaitStrategies(),
		postgres.WithSQLDriver("pgx"),
	)
	if err != nil {
		t.Fatal(err)
	}

	// Obtém a URI de conexão diretamente do contêiner criado.
	dbURI, err := postgresC.ConnectionString(ctx)
	if err != nil {
		t.Fatal(err)
	}

	// Cria a conexão utilizando o driver PGX.
	db, err := pgxpool.Connect(ctx, dbURI)
	if err != nil {
		t.Fatal(err)
	}

	// Cria a tabela "books" no banco de dados.
	_, err = db.Exec(ctx, `
        CREATE TABLE books (
            id SERIAL PRIMARY KEY,
            title VARCHAR(255) NOT NULL,
            author VARCHAR(255) NOT NULL,
            isbn VARCHAR(20) NOT NULL
        );
    `)
	if err != nil {
		t.Fatal(err)
	}


	teardown := func() {
		db.Close()
		if err := postgresC.Terminate(ctx); err != nil {
			t.Fatalf("failed to terminate container: %s", err)
		}
	}

	return db, teardown
}
```

## Escrevendo os cenários de teste
Agora que temos uma função que cria o contêiner, é hora de escrever os cenários de teste. Neste caso utilizaremos os seguintes cenários:

1. Create and Get Book: que irá adicionar um novo livro e retornar em nossa API
2. Update Book: atualizará as informações do livro do DB
3. Delete Book: apagará as informações do nosso livro

### Estrutura dos Testes
Para facilitar a leitura e ser mais fácil a manutenção de nosso teste, vou utilizar um modelo de escrita que se chama [Table Driven Tests](https://go.dev/wiki/TableDrivenTests), onde cada teste é definido por um struct que contém:

- `name`: Nome do teste.
- `method`: Método HTTP a ser usado (GET, POST, PUT, DELETE).
- `url`: URL do endpoint a ser testado.
- `body`: Corpo da requisição.
- `setupFunc`: Função opcional para configurar o estado inicial do banco de dados.
- `assertFunc`: Função para verificar a resposta do teste.

```go
tests := []struct {
		name       string                                       
		method     string                                      
		url        string                                       
		body       string                                       
		setupFunc  func(*testing.T, *pgxpool.Pool)              
		assertFunc func(*testing.T, *httptest.ResponseRecorder)
}
```

### Execução dos Testes
Para cada caso de teste, a função `t.Run` é usada para executar o teste. Dentro de cada teste, se houver uma `setupFunc`, ela é chamada para configurar o estado inicial. Em seguida, uma requisição HTTP é criada e enviada ao endpoint apropriado. A função `assertFunc` é então chamada para verificar se a resposta está correta.

E agora basta adicionar os nós do struct com os cenários de teste que queremos. A função de testes ficará assim: 

```go 
package book

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"

	"book-store/internal/book"
	"github.com/jackc/pgx/v4/pgxpool"
	"github.com/labstack/echo/v4"
	"github.com/stretchr/testify/assert"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
)

const (
	dbName = "bookstore"
	dbUser = "user"
	dbPass = "S3cret"
)

func setupTestContainer(t *testing.T) (*pgxpool.Pool, func()) {
	ctx := context.Background()

	// Configura o contêiner com a imagem Docker da versão que queremos utilizar,
	// nome do banco de dados, usuário e senha, e o driver de comunicação.
	postgresC, err := postgres.Run(
		ctx,
		"postgres:16-alpine",
		postgres.WithDatabase(dbName),
		postgres.WithUsername(dbUser),
		postgres.WithPassword(dbPass),
		postgres.BasicWaitStrategies(),
		postgres.WithSQLDriver("pgx"),
	)
	if err != nil {
		t.Fatal(err)
	}

	// Obtém a URI de conexão diretamente do contêiner criado.
	dbURI, err := postgresC.ConnectionString(ctx)
	if err != nil {
		t.Fatal(err)
	}

	// Cria a conexão utilizando o driver PGX.
	db, err := pgxpool.Connect(ctx, dbURI)
	if err != nil {
		t.Fatal(err)
	}

	// Cria a tabela "books" no banco de dados.
	_, err = db.Exec(ctx, `
		CREATE TABLE books (
			id SERIAL PRIMARY KEY,
			title VARCHAR(255) NOT NULL,
			author VARCHAR(255) NOT NULL,
			isbn VARCHAR(20) NOT NULL
		);
	`)
	if err != nil {
		t.Fatal(err)
	}

	teardown := func() {
		db.Close()
		if err := postgresC.Terminate(ctx); err != nil {
			t.Fatalf("failed to terminate container: %s", err)
		}
	}

	return db, teardown
}

func TestHandlers(t *testing.T) {
	db, teardown := setupTestContainer(t)
	defer teardown()

	e := echo.New()
	RegisterRoutes(e, db)

	tests := []struct {
		name       string                                       // Nome do teste
		method     string                                       // Metodo HTTP que será utilizado
		url        string                                       // URL da API
		body       string                                       // Body do request
		setupFunc  func(*testing.T, *pgxpool.Pool)              // Função de configuração do nosso teste
		assertFunc func(*testing.T, *httptest.ResponseRecorder) // Função onde faremos os asserts
	}{
		{
			name:   "Create and Get Book",
			method: http.MethodPost,
			url:    "/books",
			body:   `{"title":"Test Book","author":"Author","isbn":"123-4567891234"}`,
			assertFunc: func(t *testing.T, rec *httptest.ResponseRecorder) {
				assert.Equal(t, http.StatusCreated, rec.Code)
				var createdBook book.Book
				json.Unmarshal(rec.Body.Bytes(), &createdBook)
				assert.NotEqual(t, 0, createdBook.ID)

				// Get book
				req := httptest.NewRequest(http.MethodGet, "/books/"+strconv.Itoa(createdBook.ID), nil)
				rec = httptest.NewRecorder()
				c := e.NewContext(req, rec)
				c.SetParamNames("id")
				c.SetParamValues(strconv.Itoa(createdBook.ID))

				if assert.NoError(t, GetBook(c, book.NewService(book.NewRepository(db)))) {
					assert.Equal(t, http.StatusOK, rec.Code)
					var fetchedBook book.Book
					json.Unmarshal(rec.Body.Bytes(), &fetchedBook)
					assert.Equal(t, createdBook.Title, fetchedBook.Title)
					assert.Equal(t, createdBook.Author, fetchedBook.Author)
					assert.Equal(t, createdBook.ISBN, fetchedBook.ISBN)
				}
			},
		},
		{
			name:   "Update Book",
			method: http.MethodPut,
			url:    "/books/1",
			body:   `{"title":"Updated Book","author":"Another Author","isbn":"123-4567891235"}`,
			setupFunc: func(t *testing.T, db *pgxpool.Pool) {
				_, err := db.Exec(context.Background(), `INSERT INTO books (title, author, isbn) VALUES ('Another Book', 'Another Author', '123-4567891235')`)
				assert.NoError(t, err)
			},
			assertFunc: func(t *testing.T, rec *httptest.ResponseRecorder) {
				assert.Equal(t, http.StatusOK, rec.Code)
				var updatedBook book.Book
				json.Unmarshal(rec.Body.Bytes(), &updatedBook)
				assert.Equal(t, "Updated Book", updatedBook.Title)
			},
		},
		{
			name:   "Delete Book",
			method: http.MethodDelete,
			url:    "/books/1",
			setupFunc: func(t *testing.T, db *pgxpool.Pool) {
				_, err := db.Exec(context.Background(), `INSERT INTO books (title, author, isbn) VALUES ('Book to Delete', 'Author', '123-4567891236')`)
				assert.NoError(t, err)
			},
			assertFunc: func(t *testing.T, rec *httptest.ResponseRecorder) {
				assert.Equal(t, http.StatusOK, rec.Code)

				// Try to get deleted book
				req := httptest.NewRequest(http.MethodGet, "/books/1", nil)
				rec = httptest.NewRecorder()
				c := e.NewContext(req, rec)
				c.SetParamNames("id")
				c.SetParamValues("1")

				if assert.NoError(t, GetBook(c, book.NewService(book.NewRepository(db)))) {
					assert.Equal(t, http.StatusNotFound, rec.Code)
				}
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if tt.setupFunc != nil {
				tt.setupFunc(t, db)
			}

			req := httptest.NewRequest(tt.method, tt.url, strings.NewReader(tt.body))
			req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			switch tt.method {
			case http.MethodPost:
				assert.NoError(t, CreateBook(c, book.NewService(book.NewRepository(db))))
			case http.MethodPut:
				c.SetParamNames("id")
				c.SetParamValues("1")
				assert.NoError(t, UpdateBook(c, book.NewService(book.NewRepository(db))))
			case http.MethodDelete:
				c.SetParamNames("id")
				c.SetParamValues("1")
				assert.NoError(t, DeleteBook(c, book.NewService(book.NewRepository(db))))
			}

			tt.assertFunc(t, rec)
		})
	}
}
```

E depois de implementarmos o teste, basta executá-lo:
> Lembre-se de estar com o Docker rodando neste momento

```shell
$ go test ./pkg/api/book -v
```

O resultado será o seguinte: 
```shell
 RUN   TestHandlers
2024/07/16 21:34:05 github.com/testcontainers/testcontainers-go - Connected to docker: 
  Server Version: 27.0.3
  API Version: 1.46
  Operating System: Docker Desktop
  Total Memory: 11952 MB
  Testcontainers for Go Version: v0.32.0
  Resolved Docker Host: unix:///var/run/docker.sock
  Resolved Docker Socket Path: /var/run/docker.sock
  Test SessionID: e58625d6d53c88c2512974450a2b42bc1dfe03ae1aeadc227a66aa27f5abef32
  Test ProcessID: 82261770-4ede-47ff-a009-3a5a7f4290c2
2024/07/16 21:34:06 🐳 Creating container for image testcontainers/ryuk:0.7.0
2024/07/16 21:34:06 ✅ Container created: 172f8461e2b6
2024/07/16 21:34:06 🐳 Starting container: 172f8461e2b6
2024/07/16 21:34:07 ✅ Container started: 172f8461e2b6
2024/07/16 21:34:07 ⏳ Waiting for container id 172f8461e2b6 image: testcontainers/ryuk:0.7.0. Waiting for: &{Port:8080/tcp timeout:<nil> PollInterval:100ms}
2024/07/16 21:34:07 🔔 Container is ready: 172f8461e2b6
2024/07/16 21:34:07 🐳 Creating container for image postgres:16-alpine
2024/07/16 21:34:07 ✅ Container created: 05b177dc6549
2024/07/16 21:34:07 🐳 Starting container: 05b177dc6549
2024/07/16 21:34:07 ✅ Container started: 05b177dc6549
2024/07/16 21:34:07 ⏳ Waiting for container id 05b177dc6549 image: postgres:16-alpine. Waiting for: &{timeout:<nil> deadline:0x140003f8230 Strategies:[0x140003eeff0 0x140002b4260]}
2024/07/16 21:34:08 🔔 Container is ready: 05b177dc6549
=== RUN   TestHandlers/Create_and_Get_Book
=== RUN   TestHandlers/Update_Book
=== RUN   TestHandlers/Delete_Book
2024/07/16 21:34:08 🐳 Terminating container: 05b177dc6549
2024/07/16 21:34:08 🚫 Container terminated: 05b177dc6549
--- PASS: TestHandlers (3.46s)
    --- PASS: TestHandlers/Create_and_Get_Book (0.00s)
    --- PASS: TestHandlers/Update_Book (0.00s)
    --- PASS: TestHandlers/Delete_Book (0.00s)
PASS
ok      book-store/pkg/api/book
```

## Melhorias na Produtividade e Qualidade do Software
- Produtividade: Testcontainers automatiza a configuração do ambiente de teste, eliminando a necessidade de configurar manualmente bancos de dados para testes. Isso economiza tempo e reduz a complexidade dos testes.
- Qualidade do Software: Testes de integração garantem que os componentes do sistema funcionem corretamente juntos. Usar Testcontainers garante que os testes sejam executados em um ambiente consistente, reduzindo a probabilidade de erros que só ocorrem em ambientes específicos.
- Reprodutibilidade: Cada teste é executado em um ambiente limpo e isolado, tornando os testes mais reprodutíveis e facilitando a identificação e correção de bugs.

## Conclusão
Usar Testcontainers é uma maneira poderosa de garantir que seus testes de integração sejam executados em um ambiente isolado e consistente.