Todos concordam que ter testes é bom, que TDD melhora a qualidade do software e que ter CI (continuos integration) é essencial. Tendo esta questão em mente, decidi que vou aprender GO (golang) pelo viés dos testes.

Go já incorpora em sua filosofia como linguagem testes. Sem a necessidade de usar bibliotecas ou comandos de terceiros.

A ideia deste post é estudar testes no contexto das quatro operações básicas de inteiros.

Inteiro porque é um tipo muito simples e em geral o seu comportamento é igual em todas as linguagens. Se começasse por string, por exemplo, eu não sei, neste momento, qual o resultado ao aplicar o operador "+" entre duas strings. Testando por um tipo cuja natureza tende a não mudar de linguagem para linguagem, posso explorar bem os conceitos de testes, o objetivo primário deste post, sem precisar me ater a detalhes profundos da linguagem, como alocação de memória.

Antes de começarmos, o comando básico para executar testes em go é test (¬¬)

Você pode acessar os códigos deste post no meu repositório.

Criando o primeiro caso de teste

Para criar um teste basta criar um arquivo com o sufixo _test.go. Para o nosso caso vamos criar o arquivo inteiro_test.go.

Dentro do arquivo tenho:

package main

import "testing"

func TestSoma(t *testing.T){}

Conforme mencionado no post do hello world a primeira linha indica que o arquivo está no pacote que contém o ponto inicial de execução de um programa. Já a linha importa para o contexto a biblioteca de testes. Observe que eu não precisei instalar nada para ter este módulo. Já a última linha declara o escopo de teste. Vamos por hora ignorar a sintaxe do argumento do teste.

Podemos executar o comando test e observar o resultado:

go test inteiro_test.go
ok      command-line-arguments  0.074s

Como não escrevemos nada no escopo do teste TestSoma o resultado ok é o esperado.

Testando soma de inteiros

Vamos agora testar a soma de dois inteiros e atribuir a uma variável. O objetivo também é observar como é a sintaxe de inicialização de variáveis.

Pode-se iniciar uma variável declarando que é uma variável, seu nome e seu tipo. Exemplo:

var x int
x = 1 + 1

var é a declaração de uma variável. x é o nome da variável e int é o tipo da variável. Ou pode-se usar outra sintaxe que aproveita o resultado de uma atribuição:

x := 1 + 1

Neste exemplo não foi necessário declarar previamente a variável nem seu tipo. O tipo é determinado pelo resultado da operação.

Observe que ":=" é utilizado para quando a variável está sendo declarada pela primeira vez no escopo. No exemplo declarativo utilizei apenas "=" porque x já estava inicializada.

Uma das coisas bacanas do go, é que o compilador não permite que você declare uma variável e não use. Se complementarmos o arquivo inteiro_test.go com:

package main

import "testing"

func TestSoma(t *testing.T){
    x := 1 + 1
}

O compilador já retorna uma mensagem de erro, sem ao menos testar:

./inteiro_test.go:6: x declared and not used
FAIL    command-line-arguments [build failed]

Vamos agora criar um caso real de teste. Vamos ver se ao somar 1 + 1 o resultado da operação retorna 2.

package main

import "testing"

func TestSoma(t *testing.T){
    x := 1 + 1
    if x != 2 {
     t.Error("Opa! 1+1 não é igual a 2, obtive", x)
    }
}

Executando:

go test inteiro_test.go
ok      command-line-arguments  0.098s

Amém! 1 + 1 é igual a 2 em go!

Como go sabe que o teste não deu erro? Simplesmente se o teste não é interrompido, o teste é satisfatório. Por isto o primeiro caso de teste, sem nada dentro da função, passou.

Vamos forçar um erro. Vamos supor que eu pense que o operador "+" na verdade concatene os números, e 1 + 1 seja 11.

package main

import "testing"

func TestSoma(t *testing.T){
    x := 1 + 1
    if x != 11 {
     t.Error("Opa! 1 + 1 não é igual a 11, obtive", x)
    }
}

Executando:

 go test inteiro_test.go
--- FAIL: TestSoma (0.00s)
    inteiro_test.go:8: Opa! 1 + 1 não é igual a 11, obtive 2
FAIL
FAIL    command-line-arguments  0.044s

Então, 1 + 1 não é igual a 11. huuuum!

O if do go não precisa por os argumentos de teste entre parênteses. Caso o argumento de teste seja verdadeiro vai entrar no bloco subsequente. Ai encontramos o na linha 8: t.Error.

O t (minúsculo) é uma instância de testing.T . O módulo testing possuem diversos tipos. Vamos começar pelo T. Você pode saber sobre o tipo T fazendo: "godoc testing T"

O T é usado para controlar o estado dos testes e para formatar as mensagens e possui diversos tipos de funções. No nosso primeiro exemplo uso a função Error, que para a execução dos testes e imprime uma mensagem de erro passada como argumento.

Criando uma função que soma dois números

Vamos criar uma situação um pouco mais real. Vou criar uma função Soma noutro arquivo e testar a funcionalidade numa abordagem TDD.

Primeiro vou criar o arquivo inteiro.go e dentro dele uma função soma. O objetivo deste arquivo será agrupar métodos de operação de inteiros.

package main

func soma(){}

Nada muito especial no arquivo. Observe que o nome do pacote é main. O mesmo do teste.

Agora vou mudar o teste para usar a função soma.

package main

import "testing"

func TestSoma(t *testing.T){
    x := soma(1, 1)
    if x != 2 {
     t.Error("Opa! 1 + 1 não é igual a 2, obtive", x)
    }
}

Executando:

 go test
./inteiro_test.go:6: too many arguments in call to soma
./inteiro_test.go:6: soma(1, 1) used as value
FAIL    _/inteiros [build failed]

Eu precisei mudar o comando primeiro, antes executávamos "go test inteiro_test.go" agora é apenas "go test" isto para incluir no contexto de execução o arquivo inteiro.go

Você observará que após mudar para apenas "go test" o comando não dará erro. Por que a função não está pronta para a execução. Eu passo, dois valores como parâmetro de soma. E a função não espera nada. Mudando a função para aceitar dois parâmetros:

package main

func soma(x int, y int){ }

Lembrem-se que o tipo vem a direita da variável. A função está correta sintaticamente mas não está idiomática. O jeito GO de escrever dois argumentos de mesmo tipo sequencialmente é: func soma(x, y int)

Vejamos agora o que teremos como resposta ao teste:

 go test
./inteiro_test.go:6: soma(1, 1) used as value
FAIL    _/inteiros [build failed]

Ou seja, quando uso a função soma eu espero que tenha uma resultado. basta alterar a função soma para:

func soma(x, y int) int {

}

:::bash go test ./inteiro.go:5: missing return at end of function FAIL _/inteiros [build failed]

Falta o argumento de retorno.

func soma(x, y int) int {
 return x + x
}

Executando:

go test
PASS
ok      _/inteiros  0.034s

Sucesso! Eu consegui escrever minha primeira função em go! Mas será que ela está correta? Vamos estressar um pouco mais a função. Vamos somar valores diferentes.

package main

import "testing"

func TestSomaUmMaisDois(t *testing.T){
    x := soma(1, 2)
    if x != 3 {
     t.Error("Opa! 1 + 2 não é igual a 3, obtive", x)
    }
}

func TestSomaUmMaisUm(t *testing.T){
    x := soma(1, 1)
    if x != 2 {
     t.Error("Opa! 1 + 1 não é igual a 2, obtive", x)
    }
}

Agora criei uma função que testa valores diferentes, no caso 1 + 2 vejamos a execução:

go test
--- FAIL: TestSomaUmMaisDois (0.00s)
    inteiro_test.go:8: Opa! 1 + 2 não é igual a 3, obtive 2
FAIL
exit status 1
FAIL    _/inteiros  0.023s

De fato, o teste evidenciou um erro! Tem algo de errado na minha função soma. Olhando a função de perto vejo que somei duas vezes o número x. alterando para x + y o resultado do testes é satisfatório.

Dividindo ou testando um erro esperado

Vamos criar mais uma função, a divisão. Sabemos que a divisão por 0 não está definida. Então é esperado que o código consiga validar este tipo de situação.

Vamos criar a função divide:

package main

func divide(x, y int) int{
 return x / y
}

Testando o caminho feliz:

package main

import "testing"

func TestDivide(t *testing.T){
    x := divide(4, 2)
    if x != 2 {
     t.Error("Opa! 4 / 2 não é igual a 2, obtive", x)
    }
}

Executando:

go test
PASS
ok      _/inteiros  0.040s

Tudo feliz.

Agora vamos forçar uma situação de erro. Dividindo por 0.

func TestDividePorZero(t *testing.T){
    x := divide(4, 0)
    if x != 2 {
     t.Error("Opa! 4 / 2 não é igual a 2, obtive", x)
    }
}

Executando?

go test
--- FAIL: TestDividePorZero (0.00s)
panic: runtime error: integer divide by zero [recovered]
    panic: runtime error: integer divide by zero
[signal 0x8 code=0x7 addr=0x5be42 pc=0x5be42]

...

Então minha função não está pronta para um caso absurdo como a divisão por zero. O que preciso fazer é preparar a função para retornar um erro de validação para o absurdo.

Go, aceita saídas de multiplos tipos, alterando a função para:

func divide(x int, y int) (int, error){
 return x / y, nil
}

Agora a função retorna dois valores, o resultado da função e um possível erro. Mas já estamos cientes que desta forma o teste não passará pois é necessário validar melhor a entrada. Assim precisamos implementar a função de forma a não dar erro:

import "errors"

var errorDivisorZero = errors.New("O divisor da operação não pode ser zero")

func divide(x int, y int) (int, error){
 var err error
 var resultado int
 if y == 0{
   err = errorDivisorZero
 }else{
  resultado = x / y
 }
 return resultado, err
}

Eu alterei a função para validar se o segundo parâmetro (divisor) é zero. Caso seja, err passa a ser ter como valor o erro declarado errorDivisorZero, que contém a mensagem "O divisor da operação não pode ser zero"

Caso o segundo parâmetro não seja zero, é realizado a divisão.

Agora precisamos alterar o teste para o caso de retorno de uma mensagem de erro:

func TestDividePorZero(t *testing.T){
    _, err := divide(4, 0)
    if err != nil {
     t.Error(err)
    }
}

Executando:

go test
--- FAIL: TestDividePorZero (0.00s)
    inteiro_test.go:29: O divisor da operação não pode ser zero
FAIL
exit status 1
FAIL    _/inteiros  0.049s

Agora eu obtenho a mensagem de erro que programei. Mas o teste ainda falha. O que eu quero testar é se ao passar zero como divisor e testar o erro esperado:

func TestDividePorZero(t *testing.T){
    _, err := divide(4, 0)
    if err != errorDivisorZero {
     t.Error(err)
    }
}

Executando:

go test
PASS
ok      _/inteiros  0.073s

Sucesso! Obtive o erro esperado quando tento fazer uma divisão por zero.

Cobertura de código, quando menos é menos mesmo!

Das quatro operações básicas da matemática ainda temos a subtração e a multiplicação. Vamos escrever uma função para subtração:

func subtrai(x, y int) int {
 return x - x
}

Testando...

go test
PASS
ok      _/inteiros  0.073s

Epa, mas você não escreveu o teste! Como ser notificado que existe um trecho de código que não está sendo testado? Isto é o caso para a cobertura de testes! Lembra que eu falei que golang tem preocupação com testes desde o início?

Pois bem, o comando test aceita o argumento -cover para medir a cobertura de teste. Mas antes precisamos ver o conceito de GOPATH.

GOPATH, primeiros passos

Go necessita que você especifique um caminho absoluto para o ambiente do projeto. Este caminho será baseado as dependências do projeto, os comandos auxiliares, sua fonte de arquivos. Vamos criar um ambiente voltado para concluir o caso de testes:

  • crie um diretório onde você quiser chamado estudo-golang. Será a raiz do seu projeto
  • crie um diretório src e dentro dele crie um diretório inteiros
  • mova os arquivos inteiro.go e inteiro_test.go para dentro do diretório inteiros

o resultado esperado é:

| - estudo-golang/
   |- src/
    |- inteiros/
        |- inteiro.go
        |- inteiro_test.go

ou você pode clonar meu repositório no github que já contém esta estrutura de arquivos;

agora defina a variável de ambiente GOPATH

export GOPATH=${PWD}

Cover

O go não trás na conjunto padrão de ferramentas a cobertura de testes. Para instalar a ferramenta cover faça:

go get golang.org/x/tools/cmd/cover

Caso tenha dificuldades com o seu sistema operacional não exite em comentar que tentarei te ajudar.

Vejamos o que acontece quando eu executo os testes medindo a cobertura com a função subtrai declarada mas não testada, lembrando que as funções soma e divide estão no contexto:

go test inteiros -cover
ok      inteiros    0.002s  coverage: 87.5% of statements

Aha! O comando de teste agora diz que tenho 87.5% de cobertura.

Num projeto grande fica difícil saber qual o ponto do código não está sendo testado, vamos melhorar um pouco a saída do teste:

primeiro gere um arquivo de saída:

go test -coverprofile=c.out inteiros

então abra no browser a interpretação da saída:

go tool cover -html=c.out

Como resultado:

cobertura do teste de inteiros

Observe que as linhas em vermelho são os trechos sem cobertura.

Agora escrevo um teste para subtração:

func TestSubtrai(t *testing.T){
    resultado := subtrai(4, 5)
    if resultado != -1 {
     t.Error("curioso, na terra dos gophers 4-5 não é -1. é", resultado)
    }
}

E temos cobertura de 100%!

go test inteiros -cover
ok      inteiros    0.004s  coverage: 100.0% of statements

Teste de performance: multiplicando o tempo

Go prove outro tipo de teste: Benchmark! A classe usada para realizar este tipo de teste é B.

Mas, antes de escrevermos um teste de performance vamos escrever uma função que multiplica. Lembro quando estava na escola a primeira forma de multiplicar que aprendi foi realizar uma sequencia de somas. Em go o algoritmo seria desta forma:

func multiplicaSomando(x, y int) int{
    resultado := 0
    if y > 0{
     for i := 0; i<y; i++{
        resultado = soma(resultado, x)
     }
    }else if y < 0{
        for i := y; i<0; i++ {
            resultado = subtrai(resultado, x)
        }
    }
    return resultado;
}

O exemplo acima apresenta como fazer um laço usando for. A sintaxe lembra bastante C ou javascript, mas não temos a necessidade de por a expressão entre parênteses e observe como é iniciada a variável de controle "i". E mostra o reuso das funções soma e subtrai previamente criadas.

Antes de fazermos o teste de performance, vejamos se a função consegue multiplicar corretamente, considerando os casos:

  • multiplica por 0
  • multiplica por -1
  • multiplica por outro número qualquer
  • multiplica menos com menos

Segue o código:

func TestMultiplicaSomandoSimples(t *testing.T){
    resultado := multiplicaSomando(4, 5)
    if resultado != 20 {
     t.Errorf("4 * 5 não é 20 é %d", resultado)
    }
}

func TestMultiplicaSomandoPor0(t *testing.T){
    resultado := multiplicaSomando(4, 0)
    if resultado != 0 {
     t.Errorf("4 * 0 não é 0 é %d", resultado)
    }
}

func TestMultiplicaSomandoPorMenos1(t *testing.T){
    resultado := multiplicaSomando(4, -1)
    if resultado != -4 {
     t.Errorf("4 * -1 não é -4 é %d", resultado)
    }
}

func TestMultiplicaSomandoPor1(t *testing.T){
    resultado := multiplicaSomando(4, 1)
    if resultado != 4 {
     t.Errorf("4 * 1 não é 4 é %d", resultado)
    }
}

func TestMultiplicaSomandoPorMenos3(t *testing.T){
    resultado := multiplicaSomando(4, -3)
    if resultado != -12 {
     t.Errorf("4 * -3 não é -12 é %d", resultado)
    }
}

func TestMultiplicaSomandoMenosComMenos(t *testing.T){
    resultado := multiplicaSomando(-4, -3)
    if resultado != 12 {
     t.Errorf("-4 * -3 não é 12 é %d", resultado)
    }
}

func TestMultiplicaSomandoMenosComMenosUm(t *testing.T){
    resultado := multiplicaSomando(-4, -1)
    if resultado != 4 {
     t.Errorf("-4 * -1 não é 4 é %d", resultado)
    }
}

Antes de discutirmos os resultados, observe que mudei o a função para reportar o erro, de t.Error para t.Errorf. A diferença é que no caso de t.Error quando é evocado para o testes. Já o Errorf marca a teste como falho mas não interrompe a execução dos outros testes por vir.

Executando os testes:

go test inteiros
ok      inteiros    0.054s

Perfeito!

Mas sabemos que podemos usar outra abordagem para multiplicar. Que é utilizar o operador correto, "*". Então escrevo outra a função, multiplica, usando o operador correto e duplico as funções de testes. Mudando o prefixo de TestMultiplicaSomando para TestMultiplica apenas. Você pode ver as funções indo no repositório que criei no github.

Agora, vem a pergunta. Qual das duas funções é executa mais rápido? Para responder a esta pergunta vamos o tipo de teste Benchmark.

Primeiro eu crio outro arquivo: "inteiro_bench_test.go", para deixar separado os tipos de teste, com o seguinte conteúdo:

package main

import "testing"

func BenchmarkMultiplicaSomando(b *testing.B) {
    for i := 0; i < b.N; i++ {
        multiplicaSomando(-4, -3)
        multiplicaSomando(-4, -1)

        multiplicaSomando(4, -3)
        multiplicaSomando(4, -1)
        multiplicaSomando(4, 0)

        multiplicaSomando(4, 1)
        multiplicaSomando(4, 5)
    }
}

func BenchmarkMultiplica(b *testing.B) {
    for i := 0; i < b.N; i++ {
        multiplica(-4, -3)
        multiplica(-4, -1)

        multiplica(4, -3)
        multiplica(4, -1)
        multiplica(4, 0)

        multiplica(4, 1)
        multiplica(4, 5)
    }
}

Observe que o nome da função agora inicia com Benchmark. Isto é necessário para o go saber que o tipo do teste é de benchmark. Agora, usamos como tipo de o B, que é de benchmark.

O laço dentro da função é para repetir o bloco que se quer testar repetidas vezes. Note que a condição de parada é b .N e não há nada dentro da função que inicialize esta variável. Isto é o próprio go que especifica a quantidade de repetições para ter realizar uma média de tempo confiável de cada função.

Apliquei a mesma quantidade de chamadas a cada função e utilizei os mesmo dados. Já tinha testado previamente cada função segundo as mesmas entradas e sei que cada um tem o resultado esperado. Assim, os dois testes tem o mesmo valor semântico.

Então vamos rodar o comando:

go test -bench=.
PASS
BenchmarkMultiplicaSomando  30000000            36.2 ns/op
BenchmarkMultiplica 500000000            3.70 ns/op
ok      _/inteiros  3.384s

O resultado nos mostra que multiplicar usando o operador próprio de multiplicação é o mais rápido! O teste está nos mostrando que:

  • 30 milhões de operações de multiplicaSomando a 36.2 nano segundos por laço.
  • 500 milhões de operações de multiplica a 3,7 nano segundos por laço.

O teste de benchmark é útil para fazer comparação e inclusive para monitorar a cada nova implementação o impacto do novo código.

Quanto ao comando utilizado, para rodar o teste de benchmark é necessário passar a flag "-bench" com o parametro de qual caminho a olhar os testes.

Conclusão

O go tem um ferramental para testes bom. O fato de ter nativo testes de benchmark me deixou bem feliz. Agora é possível sem grandes malabarismos realizar métricas de performance evolutivas com o testes (tema para um próximo post).

No entando a forma de testar em go nativa achei bem "pé-duro". Mas existem diversas bibliotecas que auxiliam o teste (tema para outro post :) ).