Go

Dominando Testes em Go: testing Package, Table-Driven Tests e Subtests em Projetos Reais

13 min de leitura

Dominando Testes em Go: testing Package, Table-Driven Tests e Subtests em Projetos Reais

O Package Testing em Go: Fundamentos Go oferece uma abordagem minimalista e integrada para testes através do package . Diferente de frameworks pesados em outras linguagens, Go adota a filosofia Unix: fazer uma coisa e fazer bem. Os testes em Go são funções normais que seguem uma convenção simples: devem estar em arquivos terminados com e receber um parâmetro . A razão dessa simplicidade é profunda. Go foi desenhado para aplicações de servidor de grande escala, onde confiabilidade é crítica. Os criadores perceberam que testes complexos e difíceis de entender frequentemente não são mantidos. Portanto, a biblioteca padrão fornece exatamente o que você precisa, sem abstrações desnecessárias. Quando você cria um teste em Go, está escrevendo código que será lido dezenas de vezes e modificado constantemente — simplicidade não é um luxo, é um requisito. O parâmetro oferece métodos para relatar falhas: , , e . Use quando o teste deve continuar (permitindo múltiplas asserções), e quando a falha indica

<h2>O Package Testing em Go: Fundamentos</h2>

<p>Go oferece uma abordagem minimalista e integrada para testes através do package <code>testing</code>. Diferente de frameworks pesados em outras linguagens, Go adota a filosofia Unix: fazer uma coisa e fazer bem. Os testes em Go são funções normais que seguem uma convenção simples: devem estar em arquivos terminados com <code>_test.go</code> e receber um parâmetro <code>*testing.T</code>.</p>

<p>A razão dessa simplicidade é profunda. Go foi desenhado para aplicações de servidor de grande escala, onde confiabilidade é crítica. Os criadores perceberam que testes complexos e difíceis de entender frequentemente não são mantidos. Portanto, a biblioteca padrão fornece exatamente o que você precisa, sem abstrações desnecessárias. Quando você cria um teste em Go, está escrevendo código que será lido dezenas de vezes e modificado constantemente — simplicidade não é um luxo, é um requisito.</p>

<pre><code class="language-go">package calculator

import &quot;testing&quot;

// Função que vamos testar

func Add(a, b int) int {

return a + b

}

// Teste básico usando testing.T

func TestAdd(t *testing.T) {

result := Add(2, 3)

expected := 5

if result != expected {

t.Errorf(&quot;Add(2, 3) = %d, want %d&quot;, result, expected)

}

}</code></pre>

<p>O parâmetro <code>*testing.T</code> oferece métodos para relatar falhas: <code>Error()</code>, <code>Errorf()</code>, <code>Fatal()</code> e <code>Fatalf()</code>. Use <code>Errorf()</code> quando o teste deve continuar (permitindo múltiplas asserções), e <code>Fatalf()</code> quando a falha indica um problema tão grave que continuar não faz sentido. Essa distinção é importante para escrever testes que forneçam informações úteis quando falham.</p>

<h2>Table-Driven Tests: Escalabilidade sem Repetição</h2>

<p>Table-driven tests resolvem um problema real: testar a mesma função com múltiplas entradas. A abordagem ingênua seria repetir o código de teste várias vezes — ineficiente e difícil de manter. A solução elegante de Go é usar uma slice de structs, onde cada struct contém os dados de entrada e a saída esperada.</p>

<p>Este padrão não é apenas uma convenção cultural em Go — é amplamente considerado a forma correta de escrever testes parametrizados. Grandes projetos como o Kubernetes e Docker usam table-driven tests extensivamente. A razão é simples: você consegue adicionar novos casos de teste sem escrever nenhuma função nova, apenas adicionando uma linha à tabela.</p>

<pre><code class="language-go">package calculator

import &quot;testing&quot;

func Divide(a, b int) (int, error) {

if b == 0 {

return 0, ErrDivisionByZero

}

return a / b, nil

}

func TestDivideTableDriven(t *testing.T) {

tests := []struct {

name string

a int

b int

want int

wantErr bool

}{

{

name: &quot;positive numbers&quot;,

a: 10,

b: 2,

want: 5,

wantErr: false,

},

{

name: &quot;division by zero&quot;,

a: 10,

b: 0,

want: 0,

wantErr: true,

},

{

name: &quot;negative result&quot;,

a: -10,

b: 2,

want: -5,

wantErr: false,

},

}

for _, tt := range tests {

t.Run(tt.name, func(t *testing.T) {

got, err := Divide(tt.a, tt.b)

if (err != nil) != tt.wantErr {

t.Errorf(&quot;Divide(%d, %d) error = %v, wantErr %v&quot;,

tt.a, tt.b, err, tt.wantErr)

return

}

if got != tt.want {

t.Errorf(&quot;Divide(%d, %d) = %d, want %d&quot;,

tt.a, tt.b, got, tt.want)

}

})

}

}</code></pre>

<p>Observe o padrão: a slice contém uma struct anônima com campos que descrevem cada caso. O campo <code>name</code> é crucial — permite rodar casos específicos via <code>go test -run TestDivideTableDriven/positive</code>. Os nomes também aparecem no relatório de falhas, tornando imediatamente claro qual cenário quebrou. A iteração usa <code>t.Run()</code> para criar subtestes, que cobriremos em detalhes na próxima seção.</p>

<h3>Vantagens Práticas do Padrão</h3>

<p>O padrão table-driven torna fácil adicionar casos extremos sem modificar lógica. Se você descobrir um bug através de um relatório de usuário, pode reproduzir adicionando um caso à tabela. Isso cria um registro histórico de bugs que ajuda na revisão de código e documentação.</p>

<h2>Subtests: Isolamento e Composição</h2>

<p>Subtestes, introduzidos no Go 1.7, são testes aninhados criados via <code>t.Run()</code>. Cada subteste executa de forma isolada com seu próprio <code>*testing.T</code>, permitindo que falhas em um subteste não afetem outros. Isso é particularmente poderoso quando combinado com table-driven tests.</p>

<p>A semântica de execução é importante: se um teste falha, os subtestes subsequentes ainda executam. Isso fornece uma visão completa dos problemas. Você pode também interromper a execução condicionalmente usando <code>t.FailNow()</code> ou <code>t.Fatalf()</code>, o que encerra apenas aquele subteste.</p>

<pre><code class="language-go">package calculator

import &quot;testing&quot;

func TestCalculatorOperations(t *testing.T) {

// Subtest para validação de entrada

t.Run(&quot;input validation&quot;, func(t *testing.T) {

tests := []struct {

name string

a int

b int

valid bool

}{

{&quot;valid inputs&quot;, 5, 3, true},

{&quot;zero allowed&quot;, 0, 5, true},

{&quot;negative allowed&quot;, -5, 3, true},

}

for _, tt := range tests {

t.Run(tt.name, func(t *testing.T) {

// Validação simplificada

valid := tt.a &gt;= -1000 &amp;&amp; tt.b &gt;= -1000

if valid != tt.valid {

t.Errorf(&quot;validation failed for (%d, %d)&quot;, tt.a, tt.b)

}

})

}

})

// Subtest para operações aritméticas

t.Run(&quot;arithmetic operations&quot;, func(t *testing.T) {

t.Run(&quot;addition&quot;, func(t *testing.T) {

if Add(2, 3) != 5 {

t.Error(&quot;addition failed&quot;)

}

})

t.Run(&quot;division&quot;, func(t *testing.T) {

result, err := Divide(10, 2)

if err != nil || result != 5 {

t.Error(&quot;division failed&quot;)

}

})

})

}</code></pre>

<p>Subtestes habilitam uma estrutura hierárquica nos seus testes. Você pode agrupar testes relacionados logicamente e ainda executar seletivamente via flags. Por exemplo, <code>go test -run TestCalculatorOperations/arithmetic</code> executa apenas o grupo de operações aritméticas. Isso é extremamente útil em projetos grandes onde querer iterar rapidamente em uma área específica.</p>

<h3>Usando Subtests com Cleanup</h3>

<p>Go 1.14 introduziu <code>t.Cleanup()</code>, permitindo registrar funções de limpeza que executam ao final de cada subteste. Isso elimina a necessidade de setUp/tearDown em estilos antigos:</p>

<pre><code class="language-go">func TestWithCleanup(t *testing.T) {

t.Run(&quot;test with setup&quot;, func(t *testing.T) {

// Setup

resource := acquireResource()

t.Cleanup(func() {

resource.Close()

})

// Seu teste usa resource aqui

if resource.Value() != expected {

t.Error(&quot;resource test failed&quot;)

}

})

}</code></pre>

<p>Isso garante limpeza mesmo se o teste falhar com <code>t.Fatal()</code>, uma garantia que setUp/tearDown tradicional não oferecia.</p>

<h2>Padrões Avançados e Boas Práticas</h2>

<h3>Testando Erros e Edge Cases</h3>

<p>Um erro comum é testar apenas o caminho feliz. Go incentiva tratamento explícito de erros, portanto seus testes devem fazer o mesmo. Estruture suas structs de teste para permitir testes robustos de casos de erro:</p>

<pre><code class="language-go">package validator

import (

&quot;errors&quot;

&quot;testing&quot;

)

var ErrInvalidEmail = errors.New(&quot;invalid email&quot;)

func ValidateEmail(email string) error {

if !contains(email, &quot;@&quot;) {

return ErrInvalidEmail

}

return nil

}

func contains(s, substr string) bool {

// implementação

for i := 0; i &lt; len(s)-len(substr)+1; i++ {

if s[i:i+len(substr)] == substr {

return true

}

}

return false

}

func TestValidateEmail(t *testing.T) {

tests := []struct {

name string

input string

wantErr error

}{

{name: &quot;valid email&quot;, input: &quot;user@example.com&quot;, wantErr: nil},

{name: &quot;missing @&quot;, input: &quot;userexample.com&quot;, wantErr: ErrInvalidEmail},

{name: &quot;empty string&quot;, input: &quot;&quot;, wantErr: ErrInvalidEmail},

{name: &quot;only @&quot;, input: &quot;@&quot;, wantErr: nil}, // Aceita para fins demo

}

for _, tt := range tests {

t.Run(tt.name, func(t *testing.T) {

err := ValidateEmail(tt.input)

if err != tt.wantErr {

t.Errorf(&quot;ValidateEmail(%q) error = %v, want %v&quot;,

tt.input, err, tt.wantErr)

}

})

}

}</code></pre>

<p>A chave aqui é nomear casos de erro de forma descritiva e testar tanto o valor de retorno quanto o erro. Não assuma que &quot;sem erro&quot; significa &quot;funcionou&quot; — verifique os valores reais.</p>

<h3>Organizando Testes em Arquivos</h3>

<p>A convenção é colocar testes no mesmo package do código que testam, em arquivos <code>_test.go</code>. Para packages grandes, você pode criar múltiplos arquivos de teste: <code>unit_test.go</code>, <code>integration_test.go</code>, <code>benchmark_test.go</code>. Use build tags para testes que devem ser opcionais:</p>

<pre><code class="language-go">// +build integration

package mypackage

import &quot;testing&quot;

func TestIntegration(t *testing.T) {

// Testes que requerem recursos externos

}</code></pre>

<p>Execute apenas testes de integração com <code>go test -tags=integration</code>. Essa separação mantém feedback rápido em testes unitários enquanto permite testes mais completos sob demanda.</p>

<h3>Exemplo Realista Completo</h3>

<p>Vamos ver um exemplo de um package mais complexo com testes bem estruturados:</p>

<pre><code class="language-go">package userstore

import &quot;testing&quot;

type User struct {

ID int

Name string

Email string

}

type UserStore struct {

users map[int]*User

nextID int

}

func NewUserStore() *UserStore {

return &amp;UserStore{users: make(map[int]*User), nextID: 1}

}

func (s UserStore) Create(name, email string) (User, error) {

if name == &quot;&quot; || email == &quot;&quot; {

return nil, ErrInvalidInput

}

user := &amp;User{ID: s.nextID, Name: name, Email: email}

s.users[s.nextID] = user

s.nextID++

return user, nil

}

func (s UserStore) GetByID(id int) (User, error) {

user, exists := s.users[id]

if !exists {

return nil, ErrNotFound

}

return user, nil

}

var (

ErrInvalidInput = errors.New(&quot;invalid input&quot;)

ErrNotFound = errors.New(&quot;user not found&quot;)

)

// Testes

func TestUserStore(t *testing.T) {

t.Run(&quot;create&quot;, func(t *testing.T) {

tests := []struct {

name string

userName string

email string

wantErr bool

}{

{name: &quot;valid user&quot;, userName: &quot;John&quot;, email: &quot;john@example.com&quot;, wantErr: false},

{name: &quot;empty name&quot;, userName: &quot;&quot;, email: &quot;john@example.com&quot;, wantErr: true},

{name: &quot;empty email&quot;, userName: &quot;John&quot;, email: &quot;&quot;, wantErr: true},

}

for _, tt := range tests {

t.Run(tt.name, func(t *testing.T) {

store := NewUserStore()

user, err := store.Create(tt.userName, tt.email)

if (err != nil) != tt.wantErr {

t.Errorf(&quot;Create() error = %v, wantErr %v&quot;, err, tt.wantErr)

return

}

if !tt.wantErr &amp;&amp; user.Name != tt.userName {

t.Errorf(&quot;Create() user.Name = %s, want %s&quot;, user.Name, tt.userName)

}

})

}

})

t.Run(&quot;get by id&quot;, func(t *testing.T) {

store := NewUserStore()

created, _ := store.Create(&quot;Jane&quot;, &quot;jane@example.com&quot;)

t.Run(&quot;existing user&quot;, func(t *testing.T) {

user, err := store.GetByID(created.ID)

if err != nil {

t.Fatalf(&quot;GetByID() error = %v, want nil&quot;, err)

}

if user.Name != &quot;Jane&quot; {

t.Errorf(&quot;GetByID() returned wrong user&quot;)

}

})

t.Run(&quot;non-existing user&quot;, func(t *testing.T) {

_, err := store.GetByID(9999)

if err != ErrNotFound {

t.Errorf(&quot;GetByID() error = %v, want ErrNotFound&quot;, err)

}

})

})

}</code></pre>

<p>Este exemplo mostra como estruturar testes reais: subtestes agrupam funcionalidades relacionadas, table-driven tests cobrem múltiplos cenários, e cada teste é independente (note como cada caso cria seu próprio <code>NewUserStore()</code>).</p>

<h2>Conclusão</h2>

<p>Dominando testes em Go, você aprendeu três conceitos complementares que trabalham juntos. O package <code>testing</code> fornece a base minimalista necessária para escrever testes confiáveis e mantíveis. Table-driven tests escalam seus testes sem repetição de código, permitindo cobrir casos extremos facilmente. Subtests organizam testes hierarquicamente, habilitando execução seletiva e isolamento claro de responsabilidades.</p>

<p>A verdadeira força surge quando você combina esses padrões em projetos reais. Um developer experiente em Go reconhece que testes simples e bem-organizados são mais valiosos que frameworks sofisticados. Quando seus testes são fáceis de ler, fácil é adicionar novos casos, e portanto mais código você testa. Isso reduz bugs em produção — o verdadeiro objetivo.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://golang.org/pkg/testing/" target="_blank" rel="noopener noreferrer">Go Testing Package - Official Documentation</a></li>

<li><a href="https://golang.org/doc/effective_go#testing" target="_blank" rel="noopener noreferrer">Effective Go - Testing</a></li>

<li><a href="https://dave.cheney.net/2013/06/09/writing-table-driven-tests-in-go" target="_blank" rel="noopener noreferrer">Table Driven Tests - Dave Cheney</a></li>

<li><a href="https://golang.org/blog/subtests" target="_blank" rel="noopener noreferrer">Subtests and Sub-benchmarks - Go Blog</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Go

Validação de Dados em APIs Go com go-playground/validator: Do Básico ao Avançado
Validação de Dados em APIs Go com go-playground/validator: Do Básico ao Avançado

Introdução: Por Que Validar Dados em APIs? Quando você constrói uma API em Go...

Boas Práticas de Ponteiros em Go: Endereços, Dereferência e Passagem por Referência para Times Ágeis
Boas Práticas de Ponteiros em Go: Endereços, Dereferência e Passagem por Referência para Times Ágeis

O que é um Ponteiro em Go Um ponteiro é uma variável que armazena o endereço...

Guia Completo de Channels Bufferizados e Direcionais em Go na Prática
Guia Completo de Channels Bufferizados e Direcionais em Go na Prática

Entendendo Channels em Go: Fundamentos Essenciais Channels são um mecanismo d...