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 p…

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:

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

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.


Print Share Comment Cite Upload Translate Updates
APA

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/

MLA
" » Testcontainers + Golang: Melhorando seus testes com Docker." Rafael Pazini | Sciencx - Wednesday July 17, 2024, https://www.scien.cx/2024/07/17/testcontainers-golang-melhorando-seus-testes-com-docker/
HARVARD
Rafael Pazini | Sciencx Wednesday July 17, 2024 » Testcontainers + Golang: Melhorando seus testes com Docker., viewed ,<https://www.scien.cx/2024/07/17/testcontainers-golang-melhorando-seus-testes-com-docker/>
VANCOUVER
Rafael Pazini | Sciencx - » Testcontainers + Golang: Melhorando seus testes com Docker. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/07/17/testcontainers-golang-melhorando-seus-testes-com-docker/
CHICAGO
" » Testcontainers + Golang: Melhorando seus testes com Docker." Rafael Pazini | Sciencx - Accessed . https://www.scien.cx/2024/07/17/testcontainers-golang-melhorando-seus-testes-com-docker/
IEEE
" » Testcontainers + Golang: Melhorando seus testes com Docker." Rafael Pazini | Sciencx [Online]. Available: https://www.scien.cx/2024/07/17/testcontainers-golang-melhorando-seus-testes-com-docker/. [Accessed: ]
rf:citation
» Testcontainers + Golang: Melhorando seus testes com Docker | Rafael Pazini | Sciencx | 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.

You must be logged in to translate posts. Please log in or register.