Conteúdos

Ferramentas para testes em GO

Reuni aqui algumas ferramentas e técnicas que usamos nos nossos projetos a fim de tornar nossos testes mais ágeis, rápidos e eficientes.

Separação do package

Iniciamos com um tópico simples: separar os testes em um package próprio sufixado com _test

1
2
3
4
5
package separacao

func Sum(a int, b int) int {
	return a + b
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package separacao_test

import (
	"separacao"
	"testing"
)

func TestSum(t *testing.T) {
	e := 5
	g := separacao.Sum(2, 3)

	if e != g {
		t.Fatalf("Valor diferente do esperado. %v, Esperado: %v", g, e)
	}
}

Isso traz uma separação clara dos escopos dos testes e do código de implementação em si, evitando, por exemplo, de receber uma variável disponível no package de implementação dentro dos testes.

Subtest

O Go possibilita rodar testes dentro de testes, o que ele chama de subtest, com isso, é possível reutilizar um escopo para um teste, facilitando a execução de vários testes. Um exemplo, seria um método para verificar se um fatura foi paga com o valor correto e dentro da data de vencimento.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package subtest

import (
	"errors"
	"time"
)

var (
	ErrExpired          error = errors.New("Invoice expired")
	ErrAmountPaidLower  error = errors.New("Amount paid is lower than expected")
	ErrAmountPaidHigher error = errors.New("Amount paid is higher than expected")
)

type Invoice struct {
	DueDate  time.Time
	Amount   int
	Discount int
}

type Payment struct {
	PayedDate time.Time
	Amount    int
}

func PayInvoice(i Invoice, p Payment) error {
	if p.PayedDate.After(i.DueDate) {
		return ErrExpired
	}

	sumPayed := (i.Amount - i.Discount) - p.Amount

	if sumPayed < 0 {
		return ErrAmountPaidHigher
	}

	if sumPayed > 0 {
		return ErrAmountPaidLower
	}

    // Executa a atualização no banco de dados

	return nil
}

Podemos usar os subtest do GO usando a sintaxe t.Run(name, func(){}) para rodar diversos cenários para validar esse código

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package subtest_test

import (
	"subtest"
	"testing"
	"time"
)

func TestPayInvoice(t *testing.T) {
	scenarios := []struct {
		name            string
		invoiceAmount   int
		invoiceDiscount int
		invoiceDueDate  string
		paymentAmount   int
		paymentDate     string
		expectedError   error
	}{
		{"Paid in due date", 100, 0, "2024-05-20", 100, "2024-05-19", nil},
		{"Paid in same date", 100, 0, "2024-05-20", 100, "2024-05-20", nil},
		{"Paid out of due date", 100, 0, "2024-05-20", 100, "2024-05-21", subtest.ErrExpired},
		{"Amount is lower", 100, 0, "2024-05-20", 80, "2024-05-19", subtest.ErrAmountPaidLower},
		{"With discount amount is higher", 100, 20, "2024-05-20", 100, "2024-05-19", subtest.ErrAmountPaidHigher},
	}

	for _, sc := range scenarios {
		t.Run(sc.name, func(t *testing.T) {
			idd, err := time.Parse("2006-01-02", sc.invoiceDueDate)
			if err != nil {
				t.Fatal("Error on parse invoice due date", err)
			}

			pdd, err := time.Parse("2006-01-02", sc.paymentDate)
			if err != nil {
				t.Fatal("Error on parse payment due date", err)
			}

			i := subtest.Invoice{DueDate: idd, Amount: sc.invoiceAmount, Discount: sc.invoiceDiscount}

			p := subtest.Payment{PayedDate: pdd, Amount: sc.paymentAmount}

			errGot := subtest.PayInvoice(i, p)

			if errGot != sc.expectedError {
				t.Fatalf("Error got is different. Expected: %v, Got: %v", sc.expectedError, errGot)
			}
		})
	}
}

Então podemos adicionar novos cenários de testes no slice scenarios, evitando ter que reescrever os parses de data, e toda a construção dos Invoice e Payment.

Algumas ideias gerais sobre essa abordagem:

  • As vezes pode ser difícil criar dentro de um único teste todos os cenários envolvendo muitos mocks, muitos retornos e afins, então, avalie se não vale a pena separar os testes, por exemplo, um para os cenários de sucesso e outro para cenários de falha
  • Pela facilidade em criar cenários, explore o máximo possível cenários diferentes, mesmo que esteja fora do escopo da tarefa. O que acontece se eu gerar uma fatura com valor negativo? O código continua funcionando?
  • Sempre que um novo bug aparecer, adicione nesses cenários. Por algum motivo, foi descoberto que se a fatura estiver com data “2024-01-01”, o sistema não consegue calcular. Adicione o cenário e corrija o problema. Isso evitará que o problema volte e deixará seus testes mais robustos.

Imaginar diferentes cenários de testes, tornam nosso código mais resiliente ao tempo, principalmente quando pensamos em coisas que podemo nunca acontecer (quais as chances de um cliente gerar uma fatura, receber um desconto de 100% e mesmo assim, ainda tentar pagar a fatura? Mas vai que…)

Sqlmock

Outro exemplo comum de testes que precisamos fazer no dia a dia são os testes de banco de dados. Para simplificar os testes unitários e não precisar da dependência de containers externos ou outros serviços, temos bibliotecas que facilitam esses tipos de testes como o sqlmock. Essa lib é um driver no qual é possível fazer asserts se um sql específico foi executado ou não. É bem simples de configurar, basta passar a conexão o sqlmock como conexão para a biblioteca de banco de dados que já está em uso, no caso daqui, vou usar o gorm com mysql:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package sqlm

import (
	"database/sql"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

type User struct {
	gorm.Model
	Name  string
	Email string
}

type UserRepo struct {
	db *gorm.DB
}

func BuildGorm(sqlConn *sql.DB) (*UserRepo, error) {
	db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlConn}), &gorm.Config{
		SkipDefaultTransaction: true,
	})
	if err != nil {
		return nil, err
	}

	return &UserRepo{db: db}, nil
}

func (r *UserRepo) Create(name string, email string) error {
	result := r.db.Create(&User{Name: name, Email: email})
	if result.Error != nil {
		return result.Error
	}

	return nil
}

Aqui temos um código que cria um usuário no banco. Para facilitar a leitura está tudo em um único arquivo, mas sempre separe a conexão com o banco das queries em si.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package sqlm_test

import (
	"regexp"
	"sqlm"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
)

func TestCreateUser(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
	}
	defer db.Close()

	expectedName := "User Name"
	expectedEmail := "[email protected]"

	mock.ExpectQuery("SELECT VERSION()").WillReturnRows(sqlmock.NewRows([]string{"VERSION()"}).AddRow("8.0.27-log"))

	any := sqlmock.AnyArg()

	mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `users`")).
		WithArgs(any, any, nil, expectedName, expectedEmail).
		WillReturnResult(sqlmock.NewResult(2, 1))

	repo, _ := sqlm.BuildGorm(db)

	err = repo.Create(expectedName, expectedEmail)

	if err != nil {
		t.Fatalf("an error '%s' was not expected", err)
	}
}

Já aqui temos o teste em si. Começamos com a criação da conexão do sqlmock, ele retorna a conexão com o banco de dados falso, o mock que podemos interagir para adicionar os expects, e um erro caso exista.

Depois podemos adicionar as queries que esperamos que nosso código irá gerar, no caso, o GORM sempre faz essa verificação da versão do banco, então fazemos um ExpectQuery desse select, e em seguida, adicionamos o ExpectExec para a query de inserção do usuário novo. Podemos passar os argumentos esperados e também o retorno do banco (que no caso é o ID (2) dessa inserção e quantas linhas foram alteradas (1)).

Com isso podemos rodar os testes, verificando se as sqls estão rodando conforme o esperado, e sem a necessidade de ter um banco de dados real executando.

Ooze

A última ferramenta do artigo é a que utilizamos para testes de mutação. Usamos o Ooze, com ela podemos testar nossos testes, e assim, termos uma ideia melhor se nossos testes estão pegando bons cenários. Na prática o que os testes de mutação fazem é, rodar todos os testes, então ele altera algo no código do projeto, como por exemplo, trocar um sinal de + para um sinal de -, e depois roda os testes novamente. Se os testes continuarem passando, significa que tem algo errado, que não tem teste o suficiente, que verifique essa troca de sinal. Na própria página do projeto tem uma lista de possíveis “vírus” que são inseridos. Como a cada nova modificação, os testes precisam ser executados novamente, esses testes tendem a ser mais custosos com relação a processamento, então é interessante colocar eles no final da pipeline, depois dos testes unitários e cobertura. Outro ponto importante, é ter 100% de cobertura assim os testes de mutação serão mais eficazes. Segue abaixo um exemplo simples de configuração do Ooze:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package oo

func IsValid(a, b int) bool {
	newValue := a - b

	if newValue < 0 {
		return false
	}

	return true
}

Aqui temos um código bem simples, que só verifica se o número é menor que zero e retorna true/false

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package oo_test

import (
	"oo"
	"testing"
)

func TestDecrement(t *testing.T) {
	scenarios := []struct {
		name     string
		a        int
		b        int
		expected bool
	}{
		{"valid", 3, 2, true},
		{"not valid", 1, 3, false},
	}

	for _, scenario := range scenarios {
		t.Run(scenario.name, func(t *testing.T) {
			result := oo.IsValid(scenario.a, scenario.b)
			if result != scenario.expected {
				t.Fatalf("Expected %v, but got %v", scenario.expected, result)
			}
		})
	}

}

Aqui temos os testes, com dois cenários, um para válido e outro para inválido. Se vermos a cobertura dos testes, com go test ./... -coverprofile=cover.out, podemos ver que está em 100%. Perfeito! Agora vamos configurar o Ooze. A gente cria uma pasta separada raiz do projeto chamada tests e cria um arquivo chamado mutation_test.go (essas foram só convenções que a gente adotou aqui, na real, essas pastas e arquivos podem ter o nome que quiser). Nele teremos o seguinte conteúdo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
//go:build mutation

package test

import (
	"testing"

	"github.com/gtramontina/ooze"
)

func TestMutation(t *testing.T) {
	ooze.Release(
		t,
		ooze.WithRepositoryRoot(".."),
		ooze.WithMinimumThreshold(0.75),
	)
}

Na primeira linha temos a tag de build para esse código só ser executado/buildado quando a tag existir na linha de comando. Dentro da função temos a configuração de onde estão os arquivos GO que queremos verificar, e qual o threshold, ou o mínimo da relação falha/sucesso que queremos, nesse caso é de 0.75, ou seja, pelo menos 75% dos “mutantes” devem ser mortos pelos nossos testes.

Então é só executar os testes com go test ./... -tags=mutation -v. O resultado?

relatório do ooze com 3 'mutantes' ainda vivos

Temos 3 mutantes sobreviventes. Se olharmos o output do Ooze, vemos o que foi feito:

relatório do ooze com as alterações feitas

O Ooze alterou o código em alguns pontos: ele colocou <= onde era <, colocou -2 onde era 0 e colocou 1 onde era 0. Em cada uma dessas alterações ele rodou os testes novamente, e os testes passaram!

Ou seja, nossos testes não estão pegando todos os possíveis caminhos. Podemos adicionar mais cenários nossos nossos testes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package oo_test

import (
	"oo"
	"testing"
)

func TestDecrement(t *testing.T) {
	scenarios := []struct {
		name     string
		a        int
		b        int
		expected bool
	}{
		{"valid", 3, 2, true},
		{"not valid", 1, 3, false},
		{"valid", 1, 1, true}, // <- aqui
		{"not valid", 1, 2, false}, // <- aqui
	}

	for _, scenario := range scenarios {
		t.Run(scenario.name, func(t *testing.T) {
			result := oo.IsValid(scenario.a, scenario.b)
			if result != scenario.expected {
				t.Fatalf("Expected %v, but got %v", scenario.expected, result)
			}
		})
	}

}

E se rodarmos os testes de mutação novamente:

relatório do ooze com o score em 100%

Sucesso!

O Ooze nos ajuda a encontrar esses possíveis problemas de lógica, ou caminhos do código no qual não nos atentamos nos nossos testes.

Então é isso, esse foi o compilado de algumas estratégias que adotamos para melhorar os testes dos nossos códigos GO!