TDD e Table-Driven Tests em Go

Rafael Pazini - Jul 29 - - Dev Community

Imagine começar a construir uma casa sem um projeto. Estranho, né? Agora, pense em escrever código sem antes definir como ele deve se comportar. O Test-Driven Development (TDD) é como ter um projeto para sua casa: você escreve testes antes do código funcional. O ciclo de TDD é composto por três etapas:

  1. 🚫 Red: Escreva um teste que falha porque a funcionalidade ainda não foi implementada.
  2. Green: Implemente o código necessário para fazer o teste passar.
  3. 🔄 Refactor: Melhore a estrutura do código mantendo todos os testes passando.

Benefícios do TDD

  • Confiança no Código: Você sabe que seu código funciona porque ele passou por testes rigorosos. Em um cenário do mundo real, isso significa que quando você faz uma alteração no código, seus testes garantem que não quebrou nada que já estava funcionando. Isso é especialmente importante em aplicações críticas, como sistemas financeiros ou de saúde, onde uma falha pode ter consequências sérias.

  • Design de Qualidade: TDD força você a pensar sobre o design e a interface da sua aplicação. No mundo real, isso resulta em código mais modular e mais fácil de manter. Por exemplo, se você está desenvolvendo um serviço de e-commerce, a prática de TDD ajuda a garantir que cada componente, como o carrinho de compras, o sistema de pagamento e o gerenciamento de estoque, seja bem definido e independente.

  • Feedback Rápido: Descobre falhas rapidamente, evitando surpresas desagradáveis mais tarde. Em um projeto real, isso significa que você pode corrigir bugs antes que eles se tornem grandes problemas. Imagine que você está desenvolvendo uma aplicação de streaming de vídeo (como a PlutoTV, onde você trabalha). Com TDD, você pode identificar e corrigir problemas de buffering ou de qualidade de vídeo de maneira eficiente, antes que impactem os usuários finais.

  • Redução de Debugging: Menos tempo gasto em debugging e mais tempo em desenvolvimento de novas funcionalidades. No mundo real, isso se traduz em maior produtividade e prazos de entrega mais curtos. Imagine que você está adicionando uma nova funcionalidade de recomendação de vídeos baseada em IA. Com TDD, você pode focar em desenvolver e treinar o modelo de IA, sabendo que a integração com o sistema existente está bem testada.

  • Documentação Viva: E por ultimo, mas talvez o mais importante... Os testes atuam como uma documentação viva do comportamento esperado do sistema. Isso é extremamente útil quando novos desenvolvedores entram na equipe ou quando você retorna a um projeto após algum tempo. No mundo real, se um novo desenvolvedor se juntar à sua equipe de backend, eles podem entender rapidamente como as APIs de recomendação de vídeo funcionam apenas lendo os testes.

Mão na massa

Chega de falar e vamos colocar a mão no código. Primeiro vamos começar pelo básico, uma função que calcula o "fatorial" de um número. O fatorial de um número é uma operação matemática que multiplica o número por todos os inteiros positivos menores que ele e para representarmos o fatorial usamos o exclamação "!".

1 - Escrever o teste que queremos para calcular o fatorial, nessa primeira etapa ele falhará pois ainda não temos a função Factorial:

package main

import (
    "testing"
)

func TestFactorial(t *testing.T) {
    result := Factorial(5)
    expected := 120
    if result != expected {
        t.Errorf("Expected: %d, Actual: %d", expected, result)
    }
}
Enter fullscreen mode Exit fullscreen mode

Se rodarmos o teste, ele retornará o seguinte resultado de erro:

./tdd_test.go:8:12: undefined: Factorial
Enter fullscreen mode Exit fullscreen mode

Atingimos o 🚫 Red.

2 - Agora vamos implementar nossa função que esta faltando e que calcula o fatorial:

package tdd


func Factorial(n int) int {
    if n == 0 {
        return 1
    }

    result := 1
    for i := 1; i <= n; i++ {
        result *= i
    }

    return result
}
Enter fullscreen mode Exit fullscreen mode

Após executar o teste, ele passará. Atingimos o ✅ Green.

3 - Agora vamos fazer o Refactor de nosso código. Que tal implementarmos o factorial de uma forma recursiva e deixar nosso código mais simples? Podemos utilizar uma recursão para deixar o código mais simples.

package tdd

func Factorial(n int) int {
    if n == 0 {
        return 1
    }
    return n * Factorial(n-1)
}
Enter fullscreen mode Exit fullscreen mode

Aproveitando o Refactor, podemos também reescrever nosso teste usando o Table Driven e também adicionar mais testes e cobrir um número maior de casos.

package tdd

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestFactorial(t *testing.T) {
    tests := []struct {
        name     string
        input    int
        expected int
    }{
        {
            name:     "should calculate factorial for 5",
            input:    5,
            expected: 120,
        },
        {
            name:     "should calculate factorial for 0",
            input:    0,
            expected: 1,
        },
        {
            name:     "should calculate factorial for 3",
            input:    3,
            expected: 6,
        },
    }

    for _, tt := range tests {
        result := Factorial(tt.input)
        assert.Equal(t, tt.expected, result)
    }
}
Enter fullscreen mode Exit fullscreen mode

E agora sim, nosso 🔄 Refactor está feito...

Caramba Rafa, mas o que é esse tal de Table Driven? Bora nos aprofundar um pouco sobre uma das técnicas mais usadas nos testes em Go.

Table-Driven Tests

Table-Driven Tests são uma maneira de tornar seus testes mais organizados e reutilizáveis. Você define uma tabela com entradas e saídas esperadas, e o mesmo teste é executado para cada par de entrada/saída.

Uma coisa que eu amo fazer, é brincar com exemplos do mundo real para que todos consigam entender do que estamos falando, então imagine que você é um chef de cozinha e precisa testar várias receitas de um novo prato. Em vez de fazer um teste separado para cada ingrediente e combinação, você faz uma tabela onde lista todas as combinações possíveis de ingredientes e suas respectivas expectativas de sabor. Agora, ao invés de experimentar uma por uma, você segue essa tabela, economizando tempo e garantindo que todas as combinações sejam testadas.

É exatamente isso que fazemos com table-driven tests no mundo da programação!

Estrutura Básica

Geralmente partimos da seguinte base:

  • Descrição/Nome (Opcional): Explicação breve do caso de teste.
  • Entradas: Valores que serão passados para a função ou método.
  • Saída Esperada: Resultado que esperamos obter com as entradas fornecidas.
package main

import (
    "testing"
)

// Struct que define um caso de teste
type testCase struct {
    name     string
    input    int
    expected int
}
Enter fullscreen mode Exit fullscreen mode

Bora ver um exemplo prático de como podemos aplicar, vamos considerar uma função mais complexa que calcula o preço final de um produto após aplicar desconto e imposto. Vamos seguir com o TDD.

1 - Criamos um struct testCase para armazenar os dados de cada teste, incluindo um nome para nosso teste, entradas (basePrice, discount, taxRate) e a saída esperada (expected).

package main

type priceTestCase struct {
    name      string
    basePrice float64
    discount  float64
    taxRate   float64
    expected  float64
}
Enter fullscreen mode Exit fullscreen mode

2 - Criamos a função de teste TestCalculatePrice e definimos uma slice de priceTestCase chamada tests, onde cada elemento representa um caso de teste específico.

package main

type priceTestCase struct {
    name      string
    basePrice float64
    discount  float64
    taxRate   float64
    expected  float64
}

func TestCalculatePrice(t *testing.T) {
    tests := []priceTestCase{
        {
            "should calculate final price without discount and tax",
            100.0,
            0.0,
            0.0,
            100.0,
        },
        {
            "should calculate final price with 10% off and without tax",
            100.0,
            10.0,
            0.0,
            90.0,
        },
        {
            "should calculate final price without discount and 10% tax",
            100.0,
            0.0,
            10.0,
            110.0,
        },
        {
            "should calculate final price with 10% off and 10% tax",
            100.0,
            10.0,
            10.0,
            99.0,
        },
        {
            "should calculate final price with discount and tax applied",
            200.0,
            15.0,
            8.0,
            183.6,
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

3 - Criamos um loop for para iterar sobre cada caso de teste na tabela. Dentro do loop, usamos t.Run para criar subtestes, o que facilita a identificação de qual caso falhou. E por fim fazemos o assert para descobrir se nosso tt.expected é igual ao resultado que a função CalculatePrice retornou.

package main

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

type priceTestCase struct {
    name      string
    basePrice float64
    discount  float64
    taxRate   float64
    expected  float64
}

func TestCalculatePrice(t *testing.T) {
    tests := []priceTestCase{
        {
            "should calculate final price without discount and tax",
            100.0,
            0.0,
            0.0,
            100.0,
        },
        {
            "should calculate final price with 10% off and without tax",
            100.0,
            10.0,
            0.0,
            90.0,
        },
        {
            "should calculate final price without discount and 10% tax",
            100.0,
            0.0,
            10.0,
            110.0,
        },
        {
            "should calculate final price with 10% off and 10% tax",
            100.0,
            10.0,
            10.0,
            99.0,
        },
        {
            "should calculate final price with discount and tax applied",
            200.0,
            15.0,
            8.0,
            183.6,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := CalculatePrice(tt.basePrice, tt.discount, tt.taxRate)
            assert.Equal(t, tt.expected, result)
        })
    }

}
Enter fullscreen mode Exit fullscreen mode

4 - Por fim, implementamos a função CalculatePrice, que deve receber como parâmetros 3 valores float: basePrice, discount, taxRate.

package main

func CalculatePrice(basePrice float64, discount float64, taxRate float64) float64 {
    discountedPrice := basePrice - (basePrice * discount / 100)
    return discountedPrice + (discountedPrice * taxRate / 100)
}
Enter fullscreen mode Exit fullscreen mode

E agora quando rodarmos os testes esse será nosso output:

--- PASS: TestCalculatePrice (0.00s)
    --- PASS: TestCalculatePrice/should_calculate_final_price_without_discount_and_tax (0.00s)
    --- PASS: TestCalculatePrice/should_calculate_final_price_with_10%_off_and_without_tax (0.00s)
    --- PASS: TestCalculatePrice/should_calculate_final_price_without_discount_and_10%_tax (0.00s)
    --- PASS: TestCalculatePrice/should_calculate_final_price_with_10%_off_and_10%_tax (0.00s)
    --- PASS: TestCalculatePrice/should_calculate_final_price_with_discount_and_tax_applied (0.00s)
PASS

Process finished with the exit code 0
Enter fullscreen mode Exit fullscreen mode

Conclusão

Essa abordagem permite a definição clara de diversos cenários de teste, reduzindo a repetição de código e facilitando a manutenção e escalabilidade dos testes.

  • Facilidade de Adição: Para adicionar um novo caso de teste, basta adicionar um novo elemento à slice tests.
  • Leitura Clara: A estrutura clara dos casos de teste facilita a compreensão de quais cenários estão sendo testados.
  • Manutenção Simples: Modificar os casos de teste ou as expectativas é simples e direto.

Espero que vocês gostem e caso queiram mais exemplos de teste, deixem seus comentários :)

. . . . . . . . . . . .
Terabox Video Player