Test-Driven Development (TDD) e Integração Contínua (CI/CD) são práticas essenciais para desenvolvimento de software profissional. Este guia completo mostra como implementar TDD e pipelines de CI/CD em projetos Go, desde testes unitários até deploy automatizado.
Em 2026, a pergunta prática para times brasileiros não é mais se vale testar Go, e sim qual pacote mínimo de qualidade impede regressão sem travar entrega. Vagas backend, plataforma e DevOps citam Go junto de GitHub Actions, Docker, Kubernetes, AWS, observabilidade e segurança; por isso um bom pipeline precisa provar mais do que go test feliz. Ele deve checar formatação, dependências, vulnerabilidades alcançáveis, corrida de dados, build e, quando fizer sentido, testes de integração.
Este tutorial foi atualizado para conectar TDD com produção: table-driven tests, go test -race, cobertura proporcional, govulncheck, cache de módulos no GitHub Actions e critérios de promoção para deploy. Se você ainda está montando a base da aplicação, combine este guia com API REST em Go, Go Modules na prática, autenticação e autorização em Go e GoReleaser com checksums e SBOM.
Por Que TDD + CI/CD em Go?
Go é ideal para TDD e CI/CD porque:
- Testes rápidos — Compilação e execução em segundos
- Binário único — Deploy simplificado
- Biblioteca padrão robusta — Sem dependências complexas
- Cross-compilation nativa — Build para múltiplas plataformas
Pipeline mínimo para projeto Go em produção
Um pipeline bom começa pequeno e previsível. Para um serviço ou biblioteca Go que já recebe pull requests, o mínimo recomendável é:
gofmtougofmt -wchecado sem alterar arquivos no CI.go mod tidyvalidado para impedir dependência esquecida.go test ./...em todos os pacotes.go test -race ./...pelo menos em branches principais ou PRs relevantes.go vet ./...para erros comuns que o compilador não pega.govulncheck ./...quando o projeto expõe dependências a usuários ou produção.go build ./...ou build do binário principal.
A tentação é começar com um pipeline enorme. O melhor caminho é o oposto: crie um portão que rode sempre, passe rápido e bloqueie bugs reais. Depois adicione integração com banco, containers, cobertura mínima e release assinado conforme o risco do produto cresce.
Exemplo de GitHub Actions para Go
name: go-ci
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25.x'
cache: true
- name: Check formatting
run: |
test -z "$(gofmt -l .)"
- name: Check modules
run: |
go mod tidy
git diff --exit-code go.mod go.sum
- name: Vet
run: go vet ./...
- name: Tests
run: go test ./...
- name: Race detector
run: go test -race ./...
- name: Build
run: go build ./...
Esse arquivo não faz deploy. Essa separação é saudável: primeiro prove que o código é confiável; depois promova artefato, imagem ou binário. Para binários públicos, conecte com GoReleaser. Para APIs, conecte com Docker, health checks e graceful shutdown.
Fundamentos de TDD
O Ciclo Red-Green-Refactor
┌─────────┐ ┌─────────┐ ┌─────────┐
│ RED │ → │ GREEN │ → │ REFACTOR│
│ (Falha)│ │ (Passa) │ │(Melhora)│
└─────────┘ └─────────┘ └─────────┘
↑ │
└──────────────────────────────┘
Exemplo Prático: Calculadora com TDD
Passo 1: Escreva o teste (RED)
// calculator_test.go
package calculator
import "testing"
func TestAdd(t *testing.T) {
calc := NewCalculator()
result := calc.Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; esperado 5", result)
}
}
Execute: go test — FALHA (código não existe)
Passo 2: Implementação mínima (GREEN)
// calculator.go
package calculator
type Calculator struct{}
func NewCalculator() *Calculator {
return &Calculator{}
}
func (c *Calculator) Add(a, b int) int {
return a + b
}
Execute: go test — PASSA ✓
Passo 3: Refatoração
// Adicione mais casos de teste
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positivos", 2, 3, 5},
{"negativos", -2, -3, -5},
{"zero", 0, 5, 5},
{"ambos_zero", 0, 0, 0},
}
calc := NewCalculator()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calc.Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; esperado %d",
tt.a, tt.b, got, tt.expected)
}
})
}
}
Código Testável em Go
Princípios SOLID para Testes
1. Inversão de Dependência (DIP)
// ❌ Ruim: Acoplamento direto
type Service struct {
db *sql.DB // dependência concreta
}
// ✅ Bom: Dependa de interfaces
type Repository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
type Service struct {
repo Repository // dependência abstrata
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
2. Injeção de Dependências
// main.go - Wiring manual
func main() {
db, _ := sql.Open("postgres", dsn)
repo := NewPostgresRepository(db)
service := NewService(repo)
handler := NewHandler(service)
// ...
}
Mocking e Stubs
Mock Manual
// Mock para testes
type MockRepository struct {
users map[string]*User
err error
}
func (m *MockRepository) GetUser(id string) (*User, error) {
if m.err != nil {
return nil, m.err
}
return m.users[id], nil
}
func (m *MockRepository) SaveUser(user *User) error {
if m.err != nil {
return m.err
}
m.users[user.ID] = user
return nil
}
Teste com Mock
func TestService_GetUser(t *testing.T) {
// Setup
mockRepo := &MockRepository{
users: map[string]*User{
"123": {ID: "123", Name: "João"},
},
}
svc := NewService(mockRepo)
// Execute
user, err := svc.GetUser("123")
// Assert
if err != nil {
t.Errorf("erro inesperado: %v", err)
}
if user.Name != "João" {
t.Errorf("nome = %s; esperado João", user.Name)
}
}
func TestService_GetUser_NotFound(t *testing.T) {
mockRepo := &MockRepository{users: map[string]*User{}}
svc := NewService(mockRepo)
_, err := svc.GetUser("999")
if err != ErrUserNotFound {
t.Errorf("erro = %v; esperado ErrUserNotFound", err)
}
}
Análise de Cobertura
Gerar Relatório de Coverage
# Cobertura do pacote atual
go test -cover ./...
# Cobertura detalhada
go test -coverprofile=coverage.out ./...
# Visualizar em HTML
go tool cover -html=coverage.out -o coverage.html
# Ver funções não cobertas
go tool cover -func=coverage.out
Cobertura Mínima em CI
#!/bin/bash
# check-coverage.sh
THRESHOLD=80
coverage=$(go test -cover ./... | grep -o '[0-9.]*%' | tr -d '%' | awk '{s+=$1; n++} END {printf "%.2f", s/n}')
echo "Cobertura: $coverage%"
if (( $(echo "$coverage < $THRESHOLD" | bc -l) )); then
echo "❌ Cobertura abaixo de $THRESHOLD%"
exit 1
fi
echo "✅ Cobertura aceita"
GitHub Actions para Go
Pipeline Básica
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test -v -race ./...
- name: Check coverage
run: |
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out
Pipeline Avançada
# .github/workflows/ci-cd.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
GO_VERSION: '1.22'
REGISTRY: ghcr.io
jobs:
# Job 1: Lint e Testes
test:
name: Testes
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Cache Go modules
uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
- name: Download dependencies
run: go mod download
- name: Run linter
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout=5m
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.out
fail_ci_if_error: false
# Job 2: Build
build:
name: Build
runs-on: ubuntu-latest
needs: test
strategy:
matrix:
os: [linux, darwin, windows]
arch: [amd64, arm64]
exclude:
- os: windows
arch: arm64
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
- name: Build binary
env:
GOOS: ${{ matrix.os }}
GOARCH: ${{ matrix.arch }}
run: |
output_name="myapp-${{ matrix.os }}-${{ matrix.arch }}"
if [ "$GOOS" = "windows" ]; then
output_name+='.exe'
fi
go build -ldflags="-s -w" -o "dist/$output_name" ./cmd/app
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binaries
path: dist/
# Job 3: Deploy (somente na main)
deploy:
name: Deploy
runs-on: ubuntu-latest
needs: [test, build]
if: github.ref == 'refs/heads/main'
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: binaries
path: dist/
- name: Deploy to staging
run: |
echo "Deploy para staging..."
# Comandos de deploy aqui
Benchmarks em CI
Detectar Regressões de Performance
- name: Run benchmarks
run: |
go test -bench=. -benchmem ./... | tee benchmark.txt
- name: Compare benchmarks
uses: benchmark-action/github-action-benchmark@v1
with:
tool: 'go'
output-file-path: benchmark.txt
github-token: ${{ secrets.GITHUB_TOKEN }}
auto-push: true
Benchmark Script
// Exemplo de benchmark para CI
func BenchmarkProcessData(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer()
for i := 0; i < b.N; i++ {
ProcessData(data)
}
}
func BenchmarkProcessDataParallel(b *testing.B) {
data := generateTestData(1000)
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ProcessData(data)
}
})
}
Testes de Integração
Estratégia de Testes
unit/
├── service_test.go # Testes unitários (mock)
└── handler_test.go # Testes HTTP (httptest)
integration/
├── database_test.go # Testes com banco real
└── api_test.go # Testes end-to-end
Test Container para PostgreSQL
// integration_test.go
//go:build integration
package integration
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func TestDatabaseIntegration(t *testing.T) {
ctx := context.Background()
// Criar container PostgreSQL
container, err := postgres.Run(ctx,
"postgres:16",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
if err != nil {
t.Fatal(err)
}
defer container.Terminate(ctx)
// Obter connection string
connStr, _ := container.ConnectionString(ctx)
// Testar com banco real
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatal(err)
}
// Executar testes...
}
Execute: go test -tags=integration ./integration/
Projeto Real: Estrutura Completa
myproject/
├── .github/
│ └── workflows/
│ ├── ci.yml # Testes e lint
│ └── release.yml # Build e release
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── domain/
│ │ ├── user.go
│ │ └── user_test.go
│ ├── service/
│ │ ├── user_service.go
│ │ └── user_service_test.go
│ └── repository/
│ ├── postgres/
│ │ ├── user_repo.go
│ │ └── user_repo_test.go
│ └── mock/
│ └── user_repo_mock.go
├── pkg/
│ └── utils/
│ └── validator.go
├── Makefile
├── go.mod
└── README.md
Makefile Útil
.PHONY: test coverage lint build clean
test:
go test -v -race ./...
coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
lint:
golangci-lint run
build:
go build -ldflags="-s -w" -o bin/api ./cmd/api
clean:
rm -rf bin/ coverage.out
integration-test:
go test -tags=integration ./integration/...
dev:
air
Checklist TDD + CI/CD
Antes de Commitar
- Todos os testes passam (
go test ./...) - Cobertura > 80%
- Linter sem erros (
golangci-lint run) - Código formatado (
go fmt ./...) - Módulos atualizados (
go mod tidy)
Pipeline CI/CD
- Build em múltiplas plataformas
- Testes unitários
- Testes de integração
- Análise de segurança (
govulncheck) - Verificação de cobertura
- Deploy automatizado
Métricas de Qualidade
Dashboard de Métricas
| Métrica | Meta | Ferramenta |
|---|---|---|
| Cobertura | > 80% | go test -cover |
| Lint | 0 erros | golangci-lint |
| Vulnerabilidades | 0 críticas | govulncheck |
| Build Time | < 5 min | GitHub Actions |
| Test Duration | < 2 min | go test |
Próximos Passos
- Go Clean Architecture — Estrutura testável
- Go Performance Profiling — Otimização com benchmarks
- GitHub Actions Docs — Automação avançada
Checklist de maturidade para times Go
Antes de chamar um projeto Go de “pronto para produção”, revise estes pontos:
- O CI roda em todo pull request e em push para
main. - Falha de teste, formatação,
go vetougo mod tidybloqueia merge. - Testes unitários cobrem regra de negócio, não só handlers felizes.
- Testes de integração usam banco/serviço realista quando contrato externo importa.
- O pipeline usa cache, mas não depende de estado local escondido.
- Vulnerabilidades são triadas com contexto, não ignoradas nem aceitas cegamente.
- O release gera artefato rastreável: commit, versão, imagem ou binário.
- Deploy tem rollback, health check e logs suficientes para diagnosticar regressão.
Para carreira, esse checklist vira argumento em entrevista. Em vez de dizer apenas “sei testes”, você consegue explicar como evitar regressão, como organizar table-driven tests, quando usar race detector, como colocar govulncheck no CI e como separar CI de deploy. Compare com entrevista técnica Go e com vagas Go DevOps/SRE para ver como esses sinais aparecem nas descrições reais.
Para comparar como esse mesmo tema aparece em outras stacks do mercado brasileiro, acompanhe também Python Dev Brasil. Testes, CI e deploy mudam de ferramenta, mas a lógica de portão de qualidade, revisão e observabilidade é a mesma.
TDD e CI/CD garantem código confiável e entrega contínua. Implemente hoje!