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 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.
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
Essa é a estrutura do nosso projeto:
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 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
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.
// 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:
- Create and Get Book: que irá adicionar um novo livro e retornar em nossa API
- Update Book: atualizará as informações do livro do DB
- 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, 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.
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:
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
$ go test ./pkg/api/book -v
O resultado será o seguinte:
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.
Top comments (2)
Ótimo artigo, com ótimos exemplos.
Fiquei só com uma dúvida, existe alguma estratégia para manter um container rodando em diferentes testes? Tipo testes de repositórios diferentes, ou basicamente testes que estão em arquivos diferentes.
Nesse caso, você pode criar uma Singleton para configurar o container desejado e quando for necessário utilizar o mesmo em outros testes, você consegue reutilizar o mesmo container que já esta rodando.
Se quiser dar uma olhada no artigo que escrevi de singleton, lá tem alguns exemplos de como implementar e ai é só adaptar pro Testcontainer
dev.to/rflpazini/singleton-design-...