Introdução
É comum que bugs apareçam durante o desenvolvimento de software, devido a erros de sintaxe ou de lógica de programação. A aplicação de testes durante o desenvolvimento tem como principal objetivo mitigar os bugs presentes no software. Além disso, quando bem escritos, os testes facilitam a alteração e implementação de novas funcionalidades em um software.
Nesse tutorial serão apresentadas duas abordagens de escrita de testes unitários, na linguagem Go. As abordagens são: Given-When-Then e Table Driven.
Como escrever um teste unitário em Go
Um teste unitário visa avaliar se uma pequena porção de código (neste caso, uma função), produz o resultado esperado para uma dada entrada. Em Go, um teste unitário pode ser escrito da seguinte forma:
package packagename
import (
"testing"
)
func TestSomething(t *testing.T) {
// ...
}
Neste caso, o pacote que será testado se chama packagename
. O Go fornece um pacote para realização de testes chamado testing. O pacote fornece diferentes recursos, como verificação da cobertura dos testes e benchmark.
Por padrão, os nomes das funções de testes devem começar com a palavra Test e seguidos de uma frase relacionada com o teste que será realizado. Além disso, as funções de testes devem receber um único parâmetro, que neste caso é:
*testing.T t
onde t
é um ponteiro, para uma variável do tipo T
, implementado pelo pacote testing. O tipo T
é responsável por manipular o estado do teste e apresentar mensagens de log.
Por fim, o nome do arquivo de teste segue o padrão packagename_test.go
, onde o sufixo _test.go deve ser usado em todos os arquivos de testes.
Arquivos do tutorial
Para ambas abordagens, o presente tutorial fará uso dos seguintes arquivos:
- go.mod: arquivo criado para gerenciar o módulo;
- main.go: arquivo de utilização do pacote
users
; - pacote users:
- users.go: implementação da função testada.
- users_test.go: implementação dos testes da função implementada no arquivo acima.
go.mod
Para iniciar o módulo, rode o seguinte comando:
go mod init writing-tests
E o arquivo go.mod
será gerado.
main.go
Esse é o arquivo que fará uso da função a ser testada. O programa principal fará as simples tarefas de criar um usuário e apresentá-lo no terminal.
O código é o seguinte:
package main
import (
"fmt"
"writing-tests/users"
)
func main() {
, err := users.Create(users.Params{Email: "user@user"})
userif err != nil {
panic(nil)
}
.Printf("%+v\n",user)
fmt}
pacote users
O repositório users deve conter todos os arquivos do pacote.
users.go
Esse será o arquivo que implementará a função a ser testada, além de fornecer alguns dados úteis para os testes.
Como o Go não suporta parâmetros opcionais nas assinaturas de funções, precisamos de uma alternativa para contornar tal limitação. Uma das soluções para tal limitação, é definir um tipo personalizado para atender as especificações da função.
Para contornar tal problema, eu criei um tipo chamado Params
, que será usado como parâmetro da função Create
.
O resultado da implementação foi o seguinte:
package users
import (
"errors"
"fmt"
)
var defaultPictureUrl = "https://domain/assets/default_picture.jpg"
var idController = 0
type User struct {
int
Id string
Name string
Email string
Profile }
type Params struct {
string
Name string
Email string
PictureUrl }
func Create(params Params) (User, error) {
// Campo obrigatório
if params.Email == "" {
return User{}, errors.New("email is required to create user")
}
:= getId()
userId
// Definindo um nome genérico
if params.Name == "" {
.Name = fmt.Sprintf("User%d", userId)
params}
// Definindo a imagem padrão
if params.PictureUrl == "" {
.PictureUrl = defaultPictureUrl
params}
// A string formata usada no campo profile
:= func () string {
profile := "Name: %s, E-mail: %s, User picture: %s."
layout return fmt.Sprintf(layout, params.Name, params.Email, params.PictureUrl)
}
:= User {
user : userId,
Id: params.Name,
Name: params.Email,
Email: profile(),
Profile}
return user, nil
}
func getId() int {
++
idControllerreturn idController
}
A função deverá apresentar o seguinte comportamento:
- Caso não seja fornecido um e-mail, independente dos valores dados nos demais campos, um erro é criado e retornado juntamente com uma variável do tipo
User
, onde cada campo está com o zero value do tipo usado; - Caso o nome do usuário não seja fornecido, um nome padrão será atribuído. Este nome é composto pela palavra “User” seguido do ID do usuário;
- Caso não seja fornecida uma URL da foto do usuário, a URL da foto padrão será atribuída;
- O campo
Profile
armazena uma string formatada, que é uma combinação das demais informações.
Por fim, uma variável do tipo User
é retornada, juntamente com o valor nil, indicando que não foi apresentado nenhum erro, durante a criação do usuário.
Tabela com as informações do zero value de cada tipo de dado.
Tipo | Zero Value |
---|---|
int, int8, int16, int32, int64 | 0 |
uint, uint8, uint16, uint32, uint64 | 0 |
byte, rune | 0 |
float32, float64 | 0.0 |
string | “” |
bool | false |
chan, interface, map, func | nil |
pointer, slice, array | nil |
users_test.go
Por fim, teremos o arquivo onde os testes serão implementados. O conteúdo do arquivo será o seguinte:
package users
import (
"fmt"
"testing"
)
func TestCreateUser(t *testing.T) {
// ...
}
O corpo da função de teste TestCreateUser
será preenchido de acordo com abordagem escolhida.
Given-When-Then
Cada palavra que compõe o nome desta abordagem possui um significado, no contexto de testes. O significado de cada uma das palavras é o seguinte:
- Given: para uma dada entrada;
- When: chama o método analisado;
- Then: realiza comparações.
Exemplo da abordagem Given-When-Then:
func TestCreateUser(t *testing.T) {
// Given
:= Params {
userParams : "Johnny",
Name}
:= User {}
expected
// When
, _ := Create(userParams)
result
// Then
if expected != result {
.Errorf("FAILED -> expected: \"%+v\", result: \"%+v\".", expected, result)
t} else {
.Logf("SUCCEDED -> expected: \"%+v\", result: \"%+v\".", expected, result)
t}
}
Esta abordagem funciona da seguinte maneira:
- Para uma dada entrada (userParams);
- Quando aplicada na função analisada, retorna um resultado (result);
- E, por fim, é verificado se o resultado dado pela função (result) é igual ao resultado esperado (expected).
O uso dessa abordagem não é muito indicado quando o teste realizado é simples. A função de teste criada acima, verifica apenas um dos possíveis casos de parâmetros fornecidos para a função analisada.
Quando existe a necessidade de criar mais de uma entrada (múltiplos casos de teste), para testes simples, a abordagem recomendada é a Table Driven. Criar uma função, como a apresentada acima, para cada caso de teste, acaba dificultando a manutenibilidade e compreensão dos testes.
Table Driven
Como dito anteriormente, essa abordagem favorece a criação de múltiplos casos de teste, pois o teste a ser a realizado, é simples. Além da limitação, em relação a complexidade do teste, essa abordagem necessita de uma estrutura de dados para armazenar os casos de teste.
Algumas soluções empregam o uso de um slice, mas o mais recomendado é fazer uso de um map
para armazenar as informações dos casos de teste.
Os casos de testes poderiam ser escritos da seguinte forma:
// Exemplo inicial de estrutura de dados usado para criar as entradas dos testes
:= map[string]struct {
testsCases string
name string
email string
pictureUrl
expected User} {
// Casos de teste
}
Mas o tipo Params
, definido no pacote, pode ser usada para essa finalidade. Podemos utilizar o tipo apenas usando o seu nome. Como o arquivo de teste e de implementação estão no mesmo pacote, o arquivo de teste tem acesso a todas os dados do pacote.
Após o uso do tipo Params
, o resultado deve ser o seguinte:
// Após a modificação
:= map[string]struct {
testsCases
userParams Params
expected User} {
// Casos de teste
}
Com base nessa abordagem, o teste ficaria da seguinte forma:
package users
import (
"fmt"
"testing"
)
func TestCreateUser(t *testing.T) {
:= "Name: %s, E-mail: %s, User picture: %s."
layoutProfile
:= []struct {
testCases string
name
params Params
expected User} {
{
: "given the name but not the email and picture",
name: Params {
params: "Johnny",
Name},
: User {},
expected},
{
: "given the name and picture but not the email",
name: Params {
params: "Mike",
Name: "https://domain/assets/picture.png",
PictureUrl},
: User {},
expected},
{
: "given the name and email but not the picture",
name: Params {
params: "Joseph",
Name: "joseph@domain",
Email},
: User {
expected: 1,
Id: "Joseph",
Name: "joseph@domain",
Email: fmt.Sprintf(layoutProfile, "Joseph", "joseph@domain", defaultPictureUrl),
Profile},
},
{
: "given the email and picture but not the name",
name: Params {
params: "olaf@anotherdomain",
Email: "https://anotherdomain/assets/picture.jpg",
PictureUrl},
: User {
expected: 2,
Id: "User2",
Name: "olaf@anotherdomain",
Email: fmt.Sprintf(layoutProfile, "User2", "olaf@anotherdomain", "https://anotherdomain/assets/picture.jpg"),
Profile},
},
{
: "given the email, picture and name",
name: Params {
params: "Paul",
Name: "paul@anotherdomain",
Email: "https://anotherdomain/assets/profile.png",
PictureUrl},
: User {
expected: 3,
Id: "Paul",
Name: "paul@anotherdomain",
Email: fmt.Sprintf(layoutProfile, "Paul", "paul@anotherdomain", "https://anotherdomain/assets/profile.png"),
Profile},
},
}
// Rodando cada caso de teste
for _, test := range testCases {
.Run(test.name, func(t *testing.T) {
t, _ := Create(test.params)
result
if result != test.expected {
.Errorf("FAILED -> expected: \"%+v\", result: \"%+v\".", test.expected, result)
t} else {
.Logf("SUCCEDED -> expected: \"%+v\", result: \"%+v\".", test.expected, result)
t}
})
}
}
Executando testes
Para executar os testes, você deve estar localizado no diretório onde os arquivos de testes se encontram. Dado que você está na localização correta, rode o seguinte comando:
go test
A saída esperada deve ser algo do tipo:
PASS
ok writing-tests/users 0.002s
Executando um teste específico
Você pode executar um teste específico usando a flag -run
seguido do nome da função de teste. Exemplo:
go test -run=TestName
Caso você deseje ver as mensagens apresentadas pelos testes, adicione a flag -v
. Além da possibilidade de executar uma função de teste específica, podemos executar uma função de teste específica e um caso de teste específico. Veja um exemplo:
go test -run="TestName/nome do caso de teste" -v
Cobertura dos testes
Para visualizar a cobertura dos testes, podemos utilizar a flag -cover
. Veja o exemplo:
go test -cover
> saída:
PASS
writing-tests/users coverage: 100.0% of statements
ok writing-tests/users 0.003s
Caso você queira criar um arquivo com informações sobre a cobertura, é possível usando a flag -coverprofile
.
go test -coverprofile=cover_out
> arquivo:
mode: set
writing-tests/users/users.go:24.42,26.24 1 1
writing-tests/users/users.go:26.24,28.3 1 1
writing-tests/users/users.go:30.3,33.23 2 1
writing-tests/users/users.go:33.23,35.3 1 1
writing-tests/users/users.go:38.2,38.29 1 1
writing-tests/users/users.go:38.29,40.3 1 1
writing-tests/users/users.go:43.3,43.29 1 1
writing-tests/users/users.go:43.29,46.4 2 1
writing-tests/users/users.go:49.3,56.18 2 1
writing-tests/users/users.go:59.18,62.2 2 1
Caso você deseje visualizar no formato HTML:
go tool cover -html=cover_out -o cover_out.html
E, a saída será uma página HTML contendo informações sobre a cobertura dos testes e quais partes do código foram testadas.
See you space cowboy…