body of water between green leaf trees

Como fazer teste unitário no GORM com testify e sqlmock

No último final de semana, eu passei praticamente a tarde toda do domingo tentando escrever testes unitários para o GORM utilizando sqlmock.

O problema é que todos os tutoriais que eu encontrei eram de versões antigas, tanto do GORM, quanto do sqlmock.

Somente na segunda-feira, depois de mais umas 2h tentando entender como eles funcionavam e o que os erros estavam me dizendo foi que consegui fazer os testes funcionarem.

Por causa desse trabalho todo, resolvi fazer esse post mostrando como escrever testes unitários para GORM com sqlmock e testify.

Se você nunca utilizou testify ou quer saber um pouco mais sobre, recomendo a leitura do nosso post “Como usar testify para escrever testes“. Além de um exemplo básico, explicamos qual a finalidade de cada um dos 4 packages que compõem a suite do testify.

Continuando… para facilitar, vou separar o post em testes para INSERT, UPDATE, DELETE e SELECT.

Antes de começar a escrever os testes, vamos criar um arquivo person.go com uma struct, um repository e uma função para fazer migration e retornar uma nova instância do repository.

package db

import (
	"gorm.io/gorm"
	"github.com/google/uuid"
)

type Person struct {
	ID uuid.UUID
  Name string
  Age uint8
}

type repository struct {
	db *gorm.DB
}

func NewRepository(db *gorm.DB) *repository {
	db.AutoMigrate(&Person{})
	return &repository{db}
}

No arquivo de testes, o person_test.go, vamos precisar de uma suite de testes do testify. Para isso, precisamos criar uma struct e um método SetupSuite.

package db

import (
	"database/sql"
	"regexp"
	"testing"

	"github.com/google/uuid"
	"github.com/DATA-DOG/go-sqlmock"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
)

type RepositorySuite struct {
	suite.Suite
	conn *sql.DB
	DB *gorm.DB
	mock sqlmock.Sqlmock

	repo *repository
	person *Person
}

func (rs *RepositorySuite) SetupSuite() {
	var (
		err error
	)

	rs.conn, rs.mock, err = sqlmock.New()
	assert.NoError(rs.T(), err)

	dialector := postgres.New(postgres.Config{
		DSN: "sqlmock_db_0",
		DriverName: "postgres",
		Conn: rs.conn,
		PreferSimpleProtocol: true,
	})

	rs.DB, err = gorm.Open(dialector, &gorm.Config{})
	assert.NoError(rs.T(), err)

	rs.repo = NewRepository(rs.DB)
	assert.IsType(rs.T(), &repository{}, rs.repo)

	rs.person = &Person{
		ID: uuid.New(),
		Name: "Tiago",
		Age: 32,
	}
}

Se você prestar atenção, vai ver que o import desse arquivo tem mais packages do que usamos até agora. Não se preocupe, todos esses packages serão usados ao longo da implementação dos testes. Preferi adicioná-los logo no começo para que possamos focar na escrita dos testes.

Por último, antes de iniciar, vamos adicionar mais um método à nossa suite de testes.

func (rs *RepositorySuite) AfterTest(_, _ string) {
	assert.NoError(rs.T(), rs.mock.ExpectationsWereMet())
}

Esse método irá checar após cada teste se todas as expectativas foram atendidas corretamente.

Insert

Para o insert, vamos considerar o seguinte método.

func (r *repository) Insert(p *Person) (*Person, error) {
	result := r.db.Create(&p)
	if result.Error != nil {
		return nil, result.Error
	}

	return p, nil
}

Como ao executar o método Create o GORM executa uma transação, para escrever nosso teste precisamos seguir os seguintes passo:

  • Iniciar uma transação
  • Executar a transação
  • Avaliar o retorno
  • Commitar a transação
func (rs *RepositorySuite) TestInsert() {
	rs.mock.ExpectBegin() // inicia a transação
	rs.mock.ExpectExec( // executa a transação
		regexp.QuoteMeta(`INSERT INTO "people" ("id","name","age") VALUES ($1,$2,$3)`)).
		WithArgs( // adiciona os argumentos referentes ao $1,$2,$3
			rs.person.ID,
			rs.person.Name,
			rs.person.Age).
		WillReturnResult(sqlmock.NewResult(1, 1)) // avalia o resultado
	rs.mock.ExpectCommit() // commita a transação

	p, err := rs.repo.Insert(rs.person) // chama o método Insert do repository
	assert.NoError(rs.T(), err) // avalia se não houve nenhum erro na execução
	assert.Equal(rs.T(), rs.person, p) // verificar se as struts são iguais
}

Se você olhar na documentação do sqlmock verá que a função NewResult espera os parâmetros ID e número de linhas afetadas.

Como ambos os parâmetros são do tipo int e nosso id é um UUID, não conseguimos passar o UUID como parâmetro. No entanto, utilizando o valor 1 como ID ele funciona corretamente.

Update

No repository, vamos adicionar o seguinte método.

func (r *repository) Update(p *Person) (*Person, error) {
	result := r.db.Model(&p).Updates(Person{
		Name: p.Name,
	})
	if result.Error != nil {
		return nil, result.Error
	}

	return p, nil
}

Assim como o método Create, o método Updates também realiza uma transação com o banco de dados. Sendo assim, os passos necessários para testá-lo são praticamente os mesmos do insert.

func (rs *RepositorySuite) TestUpdate() {
	rs.person.Name = "Tiago Temporin"

	rs.mock.ExpectBegin() // inicia a transação
	rs.mock.ExpectExec( // executa a transação
		regexp.QuoteMeta(`UPDATE "people" SET "name"=$1 WHERE "id" = $2`)).
		WithArgs( // inicia os argumentos referentes a $1 e $2
			rs.person.Name,
			rs.person.ID).
		WillReturnResult(sqlmock.NewResult(1, 1)) // avalia o resultado
	rs.mock.ExpectCommit() // commita a transação

	p, err := rs.repo.Update(rs.person) // chama o método Update do repository
	assert.NoError(rs.T(), err) // avalia se não houve nenhum erro na execução
	assert.Equal(rs.T(), rs.person, p) // verificar se as struts são iguais
}

Um ponto que vale a pena salientar aqui é que você não consegue mudar a order dos parâmetros na query. Em outras palavras, escrever a query assim UPDATE "people" SET "name"=$2 WHERE "id" = $1 não funciona.

Delete

Voltando para o nosso repository, vamos implementar o método Delete.

func (r *repository) Delete(id uuid.UUID) error {
	p := Person{ID: id}

	result := r.db.Delete(&p)

	return result.Error
}

E adivinha?!?!?! O método Delete também realiza uma transação com o banco de dados com a função Exec do package database/sql!

E como já conhecemos bem a ordem lógica, bora para o person_test.go implementar esse teste.

func (rs *RepositorySuite) TestDelete() {
	rs.mock.ExpectBegin() // inicia a transação
	rs.mock.ExpectExec( // executa a transação
		regexp.QuoteMeta(`DELETE FROM "people" WHERE "people"."id" = $1`)).
		WithArgs(rs.person.ID). // inicia o argumento $1
		WillReturnResult(sqlmock.NewResult(0, 1)) // valida o resultado
	rs.mock.ExpectCommit() // commita a transação

	err := rs.repo.Delete(rs.person.ID) // chama o método Delete do repository
	assert.NoError(rs.T(), err) // avalia se não houve nenhum erro na execução
}

Note que diferente do update, o where no delete precisa ter o nome da tabela antes do nome do campo.

Select

Para o select, vamos implementar dois métodos. O primeiro será para buscar todos os registros, enquanto o segundo irá filtrar os registros pelo ID.

func (r *repository) Find() ([]Person, error) { // busca todos
	var people []Person
	result := r.db.Find(&people)
	if result.Error != nil {
		return nil, result.Error
	}

	return people, nil
}

func (r *repository) FindByID(id uuid.UUID) (*Person, error) { // filtra
	var p Person

	result := r.db.First(&p, id)
	if result.Error != nil {
		return nil, result.Error
	}

	return &p, nil
}

Para testar uma função do tipo select, vamos precisar mudar um pouco a sequência lógica que utilizamos até aqui.

Pelo fato dos select ser implementado em cima da função Query do package database/sql os passos serão o seguintes:

  • Adicionar uma lista de registros
  • Adicionar a query que esperamos ser gerada
  • Validar os registros retornados
func (rs *RepositorySuite) TestFind() {
	rows := sqlmock.NewRows([]string{"id", "name", "age"}). // adiciona o nome das colunas
		AddRow( // adiciona o primeiro registro
			rs.person.ID,
			rs.person.Name,
			rs.person.Age).
		AddRow( // adiciona o segundo registro
			uuid.New(),
			"Maria Silva",
			27)

	rs.mock.ExpectQuery( // adiciona a query que esperamos ser gerada
		regexp.QuoteMeta(`SELECT * FROM "people"`)).
		WithArgs().
		WillReturnRows(rows) // valida os registros retornados

	people, err := rs.repo.Find() // chama o método Find do repository
	assert.NoError(rs.T(), err) // valida se houve algum erro
	assert.Contains(rs.T(), people, *rs.person) // valida se o registro está no slice do resultado
}

Para filtrar um registro, só precisamos mudar a query que esperamos e adicionar os argumentos para realizar o filtro.

func (rs *RepositorySuite) TestFindyByID() {
	rows := sqlmock.NewRows([]string{"id", "name", "age"}). // adiciona o nome das colunas
		AddRow( // adiciona o primeiro registro
			rs.person.ID,
			rs.person.Name,
			rs.person.Age)

	rs.mock.ExpectQuery( // adiciona a query que esperamos ser gerada
		regexp.QuoteMeta(`SELECT * FROM "people" WHERE "people"."id" = $1`)).
		WithArgs(rs.person.ID).
		WillReturnRows(rows)

	p, err := rs.repo.FindByID(rs.person.ID) // chama o método FindByID do repository
	assert.NoError(rs.T(), err) // valida se houve algum erro
	assert.Equal(rs.T(), rs.person, p) // valida se as structs são iguais
}

Calma que ainda não acabou!!!!

Se você tentar executar os testes agora, verá que nada irá acontecer. Isso por que não implementamos nenhuma função de teste do Go, tudo que fizemos até agora foi configurar a suite de testes do testify.

Para que essa suite de testes seja executada, precisamos adicionar a seguinte função.

func TestSuite(t *testing.T) {
	suite.Run(t, new(RepositorySuite))
}

Agora sim!!!! Se você executar um go test ./... verá que todos os testes irão passar.

Deixem suas dúvidas nos comentários.

Até a próxima!


Se inscreva na nossa newsletter

* indicates required

Deixe uma resposta