Projeto irmão do api-tarefas-mvc. Os dois oferecem o mesmo CRUD com PostgreSQL,
mas este separa regras e persistência para demonstrar abstração, interfaces e
testes unitários isolados.
Requisição HTTP
↓
Controller → Service → Repository → PostgreSQL
↓
View JSON
internal/model: entidade e erros conhecidos pela aplicação;internal/repository: implementação concreta da persistência PostgreSQL;internal/service: contrato do Repository e regras de negócio;internal/controller: protocolo HTTP e escolha dos status;internal/view: formato e serialização do JSON;cmd/api: conexão e montagem explícita das dependências.
O acesso ao PostgreSQL usa pgxpool diretamente. A API recebe várias requisições
concorrentes, portanto o pool fornece e reutiliza conexões sem passar pelo
adaptador database/sql:
PostgresTaskRepository → pgxpool.Pool → PostgreSQL
O identificador mantém um tipo adequado em cada fronteira:
URL/JSON string → Controller uuid.Parse → Model/Service/Repository uuid.UUID
↓
PostgreSQL UUID DEFAULT uuidv7()
O PostgreSQL 18 gera o UUID versão 7. O pacote github.com/google/uuid
representa o identificador no código Go, e a View volta a serializá-lo como
string no JSON.
Este é um MVC organizado em camadas, não um MVC puro. Service e Repository acrescentam responsabilidades que não existiam no projeto mais simples.
TaskService depende da interface TaskRepository, declarada no próprio pacote
service, e não de PostgresTaskRepository. Em Go, a interface normalmente fica
perto de quem a consome. O tipo concreto satisfaz o contrato implicitamente, sem
uma declaração como implements.
Por isso, o teste entrega um Repository falso e verifica as regras sem Docker, rede ou banco.
O Controller segue a mesma ideia: declara a interface TaskService com os casos
de uso de que precisa. Seu teste usa uma Service falsa e observa somente leitura
da requisição, status e resposta JSON.
| Partes reais no cenário | Dependência substituída | Nome usado neste projeto |
|---|---|---|
| Service | Repository falso | Teste unitário |
| Controller chamado diretamente | Service falsa | Teste unitário |
Router + Controller + View com NewRecorder |
Service falsa | Teste de componente HTTP sem socket |
| Socket HTTP + Router + Controller + View | Service falsa | Integração HTTP local de componente |
| Repository + PostgreSQL | Nenhuma | Integração de persistência |
| Socket HTTP + todas as camadas + PostgreSQL | Nenhuma | Integração full-stack local |
As interfaces não tornam o PostgreSQL desnecessário. Elas permitem escolher quando o teste precisa dele. Essa separação também tem um custo: mais tipos, arquivos e montagem. A abstração vale a pena quando existe uma fronteira que realmente precisa ser isolada. A integração full-stack ainda não é chamada de ponta a ponta porque o teste monta a aplicação dentro do próprio processo; um E2E executaria o binário como sistema externo e o observaria pela fronteira pública.
Em Go, os testes unitários normalmente ficam ao lado do código que verificam,
com o sufixo _test.go. Não é necessário criar uma árvore tests/unit como é
comum em alguns projetos Python. Isso permite testar cada pacote diretamente e,
quando necessário, usar o pacote externo nome_test para observar somente a API
exportada.
Este projeto separa os testes mais caros por build tag:
internal/model/*_test.go testes unitários do Model
internal/service/*_test.go testes unitários da Service e doubles
internal/controller/*_test.go testes unitários e de componente HTTP
integration/*_test.go integração real; exige -tags=integration
integration/testdata/*.sql fixtures/seed SQL
examples/complexity/* exemplo isolado de benchmark e Big O
testdata é uma convenção reconhecida pelas ferramentas Go: seu conteúdo pode
ser lido pelos testes, mas o diretório não vira um pacote da aplicação.
Os testes unitários não precisam do PostgreSQL:
go test ./... -vPara medir cobertura:
go test ./... -coverprofile=coverage.out
go tool cover -func=coverage.outOs arquivos mostram os três conceitos do módulo:
internal/service/task_test.go: fake de Repository, table-driven tests e subtests;internal/controller/task_test.go: fake de Service e teste HTTP comhttptest;- a saída de cobertura mostra quais camadas ainda precisam de testes de integração.
Inicie somente o PostgreSQL 18 dedicado aos testes e aguarde o healthcheck:
docker compose --profile test up -d --wait postgres-testExecute somente a suíte de integração:
go test -tags=integration -count=1 -v ./integrationO endereço pode ser substituído quando necessário:
TEST_DATABASE_URL='postgres://postgres:postgres@localhost:5434/tarefas_test?sslmode=disable' \
go test -tags=integration -count=1 -v ./integrationO serviço postgres-test usa porta, database e volume diferentes do PostgreSQL
da aplicação. O helper também recusa qualquer URL cujo database não termine em
_test. Cada teste executa:
SETUP: aplica database/init.sql → TRUNCATE → executa o seed
TESTE: usa Repository real ou aplicação HTTP completa
CLEANUP: TRUNCATE → fecha o pool de conexões
O seed está em integration/testdata/seed_tasks.sql e fornece dois registros
determinísticos. Não usamos rollback por teste no fluxo HTTP porque o servidor e
o Repository podem obter conexões diferentes do pool; setup e cleanup explícitos
tornam o isolamento visível e repetível.
Para executar testes rápidos e integração em um único comando:
go test -tags=integration -count=1 ./...Este projeto usa PostgreSQL 18.4 e uma porta diferente do exemplo simples para
que ambos possam ser executados ao mesmo tempo. Inicie o banco na porta local
5433 e espere o healthcheck ficar saudável:
docker compose up -d --waitNa imagem oficial do PostgreSQL 18, PGDATA é
/var/lib/postgresql/18/docker e o volume deve ser montado em
/var/lib/postgresql. Esse caminho é diferente do usado pelas imagens 17 e
anteriores. O compose.yaml usa um volume chamado postgres18_data para não
reaproveitar acidentalmente dados criados por outra versão principal.
Depois inicie a API na porta 8081:
go run ./cmd/apiA conexão padrão pode ser substituída:
DATABASE_URL="postgres://usuario:senha@localhost:5433/tarefas?sslmode=disable" go run ./cmd/apiPara apenas parar e remover os containers, preservando o volume:
docker compose downOs arquivos em /docker-entrypoint-initdb.d só são executados quando o volume
está vazio. Se a intenção for apagar deliberadamente os dados locais e
reconstruir o banco com database/init.sql:
docker compose down -vEsse último comando remove o volume local do projeto. Não o use para uma instância que contenha dados que precisam ser preservados.
| Método | Rota | Descrição |
|---|---|---|
POST |
/tarefas |
Cria uma tarefa pendente |
GET |
/tarefas |
Lista as tarefas |
GET |
/tarefas/{id} |
Busca uma tarefa |
PUT |
/tarefas/{id} |
Atualiza título e conclusão |
DELETE |
/tarefas/{id} |
Exclui uma tarefa |
Exemplo de criação:
{
"titulo": "Estudar testes em Go"
}Exemplo de resposta (id é string JSON):
{
"id": "019535d9-3df7-7001-8000-000000000001",
"titulo": "Estudar testes em Go",
"concluida": false
}Nas rotas com {id}, o Controller rejeita texto que não possa ser convertido
em um UUID válido antes de chamar a Service. No banco, database/init.sql usa:
id UUID PRIMARY KEY DEFAULT uuidv7()- Comece por
cmd/api/main.goe identifique a montagem das dependências. - Leia a entidade pequena em
internal/model/task.go. - Veja a interface
TaskRepositorye as regras eminternal/service/task.go. - Observe que o Repository concreto não importa nem conhece essa interface.
- Compare essas regras com o fake em
internal/service/task_test.go. - Veja a interface
TaskServicedeclarada pelo Controller. - Compare o Controller com seu teste em
internal/controller/task_test.go. - Por último, leia a implementação PostgreSQL do Repository.
| Projeto simples | Projeto testável |
|---|---|
| Model mistura regras e SQL | Service contém regras; Repository contém SQL |
| Controller depende do Model concreto | Controller depende da interface da Service |
| CRUD exige PostgreSQL no teste | Regras e HTTP podem ser testados sem PostgreSQL |
| Menos código e conceitos | Mais isolamento e mais tipos |
O objetivo não é afirmar que mais camadas são sempre melhores. Os dois projetos permitem discutir quando a simplicidade é suficiente e quando o isolamento das dependências justifica uma abstração.
Os arquivos abaixo demonstram mocks, testes HTTP, integração local e benchmarks:
internal/service/task_doubles_test.go: Stub + Spy injetado na Service;internal/service/task_fake_mock_test.go: Fake funcional e Mock clássico manual injetados na Service;internal/controller/router_test.go: testes POST e GET que atravessam Router e Controller;internal/controller/router_server_test.go: integração HTTP local de componente comhttptest.NewServere Service falsa;internal/repository/task_integration_test.go: integração com PostgreSQL local;integration/setup_test.go: conexão fail-closed, setup, seed e cleanup compartilhados;integration/repository_test.go: seed e CRUD completo do Repository com PostgreSQL real;integration/http_test.go: integração full-stack local de todas as camadas;integration/testdata/seed_tasks.sql: fixture SQL determinística;examples/complexity: teste e benchmark comparando O(1), O(n), O(n²) e O(n³).
Use os comandos abaixo para os exercícios e exemplos específicos:
# Stub + Spy na Service
go test ./internal/service \
-run TestTaskServiceCreateWithStubAndSpy -count=1 -v
# Fake funcional e Mock clássico manual
go test ./internal/service \
-run '^(TestTaskServiceLifecycleWithFake|TestTaskServiceCreateWithMock)$' \
-count=1 -v
# Testes HTTP POST e GET através do Router
go test ./internal/controller \
-run '^TestRouter(Create|Get)Task$' -count=1 -v
# Integração local, com postgres-test já iniciado
TEST_DATABASE_URL='postgres://postgres:postgres@localhost:5434/tarefas_test?sslmode=disable' \
go test -tags=integration ./internal/repository \
-run TestPostgresTaskRepositoryCreateAndGet -count=1 -v
# Suíte ativa: seed, Repository, constraints e HTTP full-stack
go test -tags=integration -count=1 -v ./integration
# Comparação curta de O(1), O(n), O(n²) e O(n³)
go test -run '^$' -bench '^BenchmarkO' -benchmem ./examples/complexity