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.
Rafael Pazini | Sciencx (2024-07-17T00:53:47+00:00) Testcontainers + Golang: Melhorando seus testes com Docker. Retrieved from https://www.scien.cx/2024/07/17/testcontainers-golang-melhorando-seus-testes-com-docker/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.