DEV Community

Cover image for Testcontainers + Golang: Melhorando seus testes com Docker
Rafael Pazini
Rafael Pazini

Posted on • Edited on

Testcontainers + Golang: Melhorando seus testes com Docker

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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, 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)
}
Enter fullscreen mode Exit fullscreen mode

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)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
ederfmatos profile image
Eder Matos

Ó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.

Collapse
 
rflpazini profile image
Rafael Pazini

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-...