Escrevendo testes de integração em Golang.
O que são testes de integração?
Testes de integração são testes que têm como objetivo assegurar o funcionamento em conjunto dos componentes da nossa aplicação, ou seja, esse irá testar a integração entre várias unidades assegurando que elas tenham determinado comportamento e produzam determinado resultado.
Se dermos alguns passos para trás e olharmos a estrutura “clássica” da pirâmide de testes, temos:
Unit (base): Testes de unidade. São os testes que asseguram o funcionamento e/ou comportamento de determinada unidade de código. (A unidade pode ser uma função, método, um pacote ou uma classe).
Integration (meio): Testes de integração. São os que asseguram o funcionamento e/ou comportamento de vários componentes trabalhando em conjunto, ou seja, irá validar a integração desses componentes.
E2E (topo): Testes end-to-end. São os testes que irão testar todo o fluxo da aplicação do início ao fim, de ponta-a-ponta. Testes e2e tendem a simular fluxos reais que são feitos por usuários reais quando estão usando a aplicação.
Testes de Integração em Go (montando a Suíte)
Para exemplificar a aplicação dos testes de integração em Go, vamos ter uma API simples com duas rotas:
1- Criação de usuários.
2- Listagem de usuários.
E consequentemente, teremos dois arquivos de testes (um para cada rota) usando a abordagem de table-driven testing para verificarmos vários cenários por teste.
Nesse exemplo, estou usando o labstack/echo
para construir a camada HTTP e um “Custom Context” para realizar a injeção de dependências, como conexão com banco de dados e DTO da requisição HTTP.
Portanto, o código fica mais ou menos assim:
func SetupServer(ctx *context.Context) *echo.Echo {
e := echo.New()
// Rota de listagem de usuários
e.GET("/list", func(c echo.Context) error {
ctx := c.(*context.Context)
r := repository.NewRepository(ctx.GetDBConnection())
u, err := user.NewUserEntity(r).ListUsers()
if err != nil {
return c.String(http.StatusBadRequest, err.Error())
}
return c.String(http.StatusOK, writer.ToJSON(u))
})
// Rota de criação de usuários
e.POST("/new", func(c echo.Context) error {
ctx := c.(*context.Context)
u, _ := ctx.GetDTO()
r := repository.NewRepository(ctx.GetDBConnection())
created, err := user.NewUserEntity(r).NewUser(u)
if err != nil {
return c.String(http.StatusBadRequest, err.Error())
}
return c.String(http.StatusCreated, writer.ToJSON(created))
})
// Middleware para injeção de dependências via Context
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx.Context = c
return next(ctx)
}
})
return e
}
Caso queira clonar o projeto para ir acompanhando e entender o panorama geral, o código está hospedado no Github e você pode acessá-lo por esse link: https://github.com/albuquerque53/medium-integr-test-go.
Continuando, para conseguirmos testar a nível de integração e com uma boa taxa de assertividade, nossos testes precisam:
1 — Iniciar a conexão com o banco de dados.
2 — Rodar as migrations (criação de tabelas).
3 — Chamar a rota em que irá ser testada.
4 — Verificar se produziu o resultado esperado.
Começando pelo DB, precisamos ter as migrations bem configuradas na aplicação. No caso estou usando o pacote golang-migrate/migrate
para trabalhar com migrations.
Então na camada de infra/db
, teremos nosso Migration
:
type Migration struct {
Migrate *migrate.Migrate
}
// Up deverá rodar as migrations de criação
func (m *Migration) Up() error {
err := m.Migrate.Up()
if err != nil && err != migrate.ErrNoChange {
return err
}
return nil
}
// Down deverá rodar as migrations de reversão
func (m *Migration) Down() error {
return m.Migrate.Down()
}
// Deverá criar um *Migration configurado e pronto para rodar as migrações
func CreateMigration(dbConn *sql.DB, migrationsFolderLocation string) (*Migration, error) {
driver, err := _mysql.WithInstance(dbConn, &_mysql.Config{})
if err != nil {
return nil, err
}
pathToMigrate := fmt.Sprintf("file://%s", migrationsFolderLocation)
m, err := migrate.NewWithDatabaseInstance(pathToMigrate, "mysql", driver)
if err != nil {
return nil, err
}
return &Migration{Migrate: m}, nil
}
Os métodos acima serão usados pela nossa suíte de testes.
Falando em suíte de testes, vamos criar ela. A suíte nada mais é do que o “ambiente de testes” que iremos montar, nesse ambiente precisamos ter tudo o que é necessário para testarmos nosso fluxo, então precisamos ter todas as tabelas do banco de dados e também uma conexão ativa com esse banco.
Dentro de internal
vou criar o pacote integration
onde criarei os testes de integração e também a suíte de testes. Lá dentro irei criar o arquivo suite_test.go
contendo nossa Suite (struct
) e dois métodos bem importantes:
SetupSuite
: Deverá configurar todo o ambiente necessário para rodarmos nossos testes.TearDownSuite
: Deverá derrubar todo o ambiente criado pelo SetupSuite.
type Suite struct {
suite.Suite // obrigatório para funcionamento da suite
DBConn *sql.DB // conexão com o DB que usaremos nos testes
Srv *httptest.Server // servidor de teste necessário para chamarmos as rotas
Migration *db.Migration // instância de migration
}
// SetupSuite será chamado sempre que formos rodar um arquivo de teste
// ele deve montar todo o ambiente necessário para a execução do mesmo.
func (s *Suite) SetupSuite() {
var err error
s.DBConn = db.ConectToDatabase()
// criando *Migration
s.Migration, err = db.CreateMigration(s.DBConn, "../infra/db/migrations")
// verificando se não houve erro ao criar *Migration
require.NoError(s.T(), err)
}
// TearDownSuite será chamado sempre todos os testes do arquivo terminarem
// a execução, ele deve derrubar todo o ambiente montado pelo SetupSuite.
func (s *Suite) TearDownSuite() {
s.DBConn.Close()
}
Além dos dois métodos acima, iremos adicionar os seguintes:
SetupTest
: Irá ser chamado sempre que um teste individual começar a rodar.TearDownTest
: Irá ser chamado sempre que um teste individual finalizar a execução.
A idéia é que toda vez que começar um teste, iremos subir as migrations, instanciar o servidor e botar pra rodar.
Quando o teste em questão finalizar, iremos dar rollback nas migrations e derrubar o servidor, garantindo que quando o próximo teste for rodar, tudo esteja limpo. Então basicamente teremos:
type Suite struct {
suite.Suite // obrigatório para funcionamento da suite
DBConn *sql.DB // conexão com o DB que usaremos nos testes
Srv *httptest.Server // servidor de teste necessário para chamarmos as rotas
Migration *db.Migration // instância de migration
}
func (s *Suite) SetupSuite() {
// código já apresentado
}
func (s *Suite) TearDownSuite() {
// código já apresentado
}
// SetupTest irá ser chamado sempre que um teste individual
// começar a rodar
func (s *Suite) SetupTest() {
err := s.Migration.Up()
require.NoError(s.T(), err)
ctx := &context.Context{}
ctx.SetDBConnection(s.DBConn)
app := server.SetupServer(ctx)
s.Srv = httptest.NewServer(app)
}
// SetupTest irá ser chamado sempre que um teste individual
// finalizar a execução
func (s *Suite) TearDownTest() {
err := s.Migration.Down()
require.NoError(s.T(), err)
s.Srv.Close()
}
Para nossa suíte funcionar devidamente, ela vai precisar de um método chamado TestSuite
, ele será usado pelo Go para rodar nossos testes (sem a suíte nem vai ser chamada), então:
type Suite struct {
suite.Suite // obrigatório para funcionamento da suite
DBConn *sql.DB // conexão com o DB que usaremos nos testes
Srv *httptest.Server // servidor de teste necessário para chamarmos as rotas
Migration *db.Migration // instância de migration
}
func (s *Suite) SetupSuite() {
// código já apresentado
}
func (s *Suite) TearDownSuite() {
// código já apresentado
}
func (s *Suite) SetupTest() {
// código já apresentado
}
func (s *Suite) TearDownTest() {
// código já apresentado
}
func TestSuite(t *testing.T) {
suite.Run(t, new(Suite))
}
Finalmente! Nossa suíte de testes está pronta para ser usada. Temos todo o ambiente configurado e pronto para que possamos escrever nossos suados e famigerados testes de integração.
Testes de Integração em Go (o ultimato):
Com nossa suíte finalizada, podemos criar arquivos de testes.
Como já mencionado anteriormente, teremos dois arquivos de teste, eles serão:
1 — list_in_test.go:
Irá salvar usuários fake no banco de dados, irá chamar a rota “/list
” da nossa API e irá verificar se os usuários fake estão sendo retornados pela rota.
2 — new_in_test.go
: Irá enviar usuários para a rota “/new
” e após receber a response, irá verificar se os usuários enviados se encontram no banco de dados.
Repare que na nomenclatura dos testes estou incluindo o pósfixo
in_test.
Sabemos que para o GO identificar os arquivos de teste ele precisa ter o pósfixo_test
, mas para nós identificarmos que o teste se trata de um teste de integração, recomendo o uso doin_test
, assim ao batermos o olho sabemos que se trata de um teste de integração e não um teste “convencional”.
new_in_test.go
:
func (s *Suite) TestNewUsers() {
type sceneries struct {
description string
statCode int
user user
}
testSceneries := []sceneries{
{description: "Must save user", statCode: 201, user: user{Name: "Zeca Baleiro", Email: "zeca@baleiro.com"}},
{description: "Don't send any user", statCode: 400, user: user{}},
}
for _, scenery := range testSceneries {
s.Run(scenery.description, func() {
body, _ := json.Marshal(scenery.user)
resp, err := http.Post(fmt.Sprintf("%s/new", s.Srv.URL), "application/json", bytes.NewReader(body))
s.NoError(err, "Expected no error during request")
s.Equal(scenery.statCode, resp.StatusCode)
defer resp.Body.Close()
if (user{} == scenery.user) {
return
}
_, err = io.ReadAll(resp.Body)
s.NoError(err, "Expected no error during read of response body")
seeInDatabase(s, "users", map[string]string{
"name": scenery.user.Name,
"email": scenery.user.Email,
})
})
}
}
E na mesma pegada, o list_in_test.go
:
func (s *Suite) TestListUsers() {
type sceneries struct {
description string
expctUsers []user
}
testSceneries := []sceneries{
{
description: "Must return one user",
expctUsers: []user{
{Name: "James Hetfield", Email: "hetfield@napster.com"},
},
},
{
description: "Must return many users",
expctUsers: []user{
{Name: "James Hetfield", Email: "hetfield@napster.com"},
{Name: "Lars Ulrich", Email: "ulrich@napster.com"},
},
},
{
description: "Must return no users", expctUsers: []user{},
},
}
for _, scenery := range testSceneries {
s.Run(scenery.description, func() {
for _, expctUser := range scenery.expctUsers {
s.DBConn.Query("INSERT INTO users(name, email) VALUES(?, ?)", expctUser.Name, expctUser.Email)
}
resp, err := http.Get(fmt.Sprintf("%s/list", s.Srv.URL))
s.NoError(err, "Expected no error during request")
s.Equal(200, resp.StatusCode)
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
s.NoError(err, "Expected no error during read of response body")
var r []user
err = json.Unmarshal(b, &r)
s.NoError(err, "Expected no error during unmarshal of response body to struct")
for i := 0; i < len(scenery.expctUsers); i++ {
expctUser := scenery.expctUsers[i]
user := r[i]
s.Equal(expctUser.Name, user.Name)
s.Equal(expctUser.Email, user.Email)
}
})
}
}
Repare que nossos testes:
- Iniciam o servidor da nossa API.
- Criam as tabelas do banco de dados.
- Populam a tabela caso necessário.
- Chamam as rotas a serem testadas.
- Verificam se as rotas realizaram as operações necessárias.
Ou seja, temos testes que pegam do início ao fim do fluxo das nossas rotas. Isso significa que estamos assegurando que todos os componentes usados nessa rota (Servidor, Handlers, Domínio, Entidade, Repositório) estão funcionais e trabalhando em conjunto.
Afinal, qual tipo de teste é melhor?
Como maior vantagem, os testes de integração têm uma cobertura muito maior do que os testes de unidade, pois testam diversas unidades e a integração entre elas. Já a desvantagem é que se torna um pouco mais complexo testar o comportamento específico de um método ou uma classe, para validarmos esse tipo de coisa é melhor usarmos os testes de unidade ao invés dos de integração, eles foram feitos para isso.
A receita para o sucesso é usar tanto os testes de unidade quanto os de integração em conjunto e sempre buscarmos a cobertura de 100% no nosso código.
Conclusão
Enfim, espero que eu tenha contribuido de alguma forma com seus estudos. Tentei montar um exemplo simples para passar a idéia, tudo isso aqui é um esqueleto que pode ser incrementado de acordo com as suas necessidades.
Só relembrando que caso queira ver o código do projeto completo, é só clicar aqui.
хорошая учеба!