Pular para o conteúdo principal

Testes — Test::Mojo + prove + Devel::Cover

Decisão: Test::Mojo para testes de API HTTP; Test::More para testes unitários; prove como runner TAP; Devel::Cover para cobertura. ADR-011 — Estratégia de Testes


Por que Test::Mojo

O Test::Mojo vem embutido no Mojolicious (zero dependência adicional) e testa rotas HTTP completas em memória — as requisições atravessam o dispatcher da aplicação sem levantar um servidor real. Isso garante testes rápidos e sem portas em uso, executáveis em qualquer ambiente.

O prove é o runner padrão do ecossistema Perl: varre t/, executa cada arquivo .t como processo independente e coleta saída TAP — consumível nativamente pelo GitHub Actions sem plugins.


Estrutura de diretórios

t/
├── unit/
│ ├── model/
│ │ ├── ticket.t ← Stega::Model::Ticket (Moo)
│ │ ├── comment.t
│ │ └── product.t
│ └── service/
│ └── notification.t
├── api/
│ ├── health.t ← GET /healthz
│ ├── tickets.t ← CRUD de tickets
│ ├── comments.t
│ ├── products.t
│ └── auth.t ← rotas protegidas com JWT
└── integration/
└── worker.t ← NotificationWorker com RabbitMQ mockado

Teste unitário — modelo Moo

# t/unit/model/ticket.t
use v5.42;
use Test::More;

use Stega::Model::Ticket;

subtest 'construção com atributos válidos' => sub {
my $ticket = Stega::Model::Ticket->new(
id => 1,
title => 'Erro no login',
priority => 'high',
);

is $ticket->id, 1, 'id correto';
is $ticket->title, 'Erro no login', 'title correto';
is $ticket->priority, 'high', 'priority correta';
is $ticket->status, 'open', 'status padrão é open';
ok $ticket->is_open, 'is_open() retorna verdadeiro';
ok !$ticket->is_closed, 'is_closed() retorna falso';
};

subtest 'priority inválida lança exceção' => sub {
eval { Stega::Model::Ticket->new(id => 1, title => 'X', priority => 'urgente') };
like $@, qr/priority inválida/, 'exceção para priority fora do enum';
};

subtest 'as_json retorna hashref serializável' => sub {
my $ticket = Stega::Model::Ticket->new(id => 2, title => 'Bug');
my $json = $ticket->as_json;

is ref($json), 'HASH', 'retorna hashref';
is $json->{id}, 2, 'id no hashref';
ok exists $json->{status}, 'status presente';
};

done_testing;

Teste de API com Test::Mojo

# t/api/tickets.t
use v5.42;
use Test::More;
use Test::Mojo;

my $t = Test::Mojo->new('Stega');

# Injetar claims JWT — dispensa Keycloak real nos testes
$t->app->hook(before_dispatch => sub {
my $c = shift;
return unless $c->req->url->path =~ m{^/api/v1/};
if (my ($token) = ($c->req->headers->authorization // '') =~ /^Bearer (.+)/) {
$c->stash('jwt_claims', {
sub => 'test-uuid-agent',
email => 'agent@stega.local',
role => 'agent',
});
}
});

# Helper para request autenticada
sub auth_get { $t->get_ok($_[0], { Authorization => 'Bearer test' }) }
sub auth_post { $t->post_ok($_[0], { Authorization => 'Bearer test' }, json => $_[1]) }

subtest 'GET /healthz — sem autenticação' => sub {
$t->get_ok('/healthz')->status_is(200)->json_is('/status', 'ok');
};

subtest 'GET /api/v1/tickets — autenticado retorna 200' => sub {
auth_get('/api/v1/tickets')->status_is(200)->json_is([], 'lista vazia inicial');
};

subtest 'GET /api/v1/tickets — sem token retorna 401' => sub {
$t->get_ok('/api/v1/tickets')->status_is(401);
};

subtest 'POST /api/v1/tickets — body inválido retorna 400' => sub {
auth_post('/api/v1/tickets', { product_id => 1 }) # falta title e body
->status_is(400);
};

subtest 'POST /api/v1/tickets — válido cria e retorna 201' => sub {
auth_post('/api/v1/tickets', {
product_id => 1,
title => 'Falha ao exportar PDF',
body => 'O botão de exportação retorna erro 500 ao clicar.',
priority => 'high',
})->status_is(201)
->json_has('/id', 'id retornado');
};

done_testing;

Teste com mocking de dependência externa

# t/integration/worker.t
use v5.42;
use Test::More;
use Test::MockObject;

use Stega::Worker::NotificationWorker;

subtest 'dispatch de ticket.status_changed envia e-mail' => sub {
my $email_sent;

# Mock do módulo de envio de e-mail
my $mock_mailer = Test::MockObject->new;
$mock_mailer->mock('send', sub { $email_sent = $_[1] });

# Substituir o mailer real pelo mock
local *Stega::Worker::NotificationWorker::_send_email = sub {
$email_sent = shift;
};

# Simular processamento de mensagem
Stega::Worker::NotificationWorker::_dispatch(
'ticket.status_changed',
{ ticket_id => 42, new_status => 'resolved', actor_id => 'uuid' }
);

ok defined $email_sent, 'e-mail foi "enviado"';
is $email_sent->{ticket_id}, 42, 'ticket_id correto na mensagem';
};

done_testing;

Executando os testes

# Todos os testes (recursivo, com relatório resumido)
carton exec prove -lr t/

# Subdiretório específico
carton exec prove -lr t/api/

# Um arquivo específico com saída verbose
carton exec prove -lv t/api/tickets.t

# Saída verbosa completa (mostra todos os subtest)
carton exec prove -lrv t/

# Em paralelo (reduz tempo de CI)
carton exec prove -lrj4 t/ # 4 processos paralelos

Cobertura de código com Devel::Cover

# Instrumentar e rodar os testes
PERL5OPT="-MDevel::Cover" carton exec prove -lr t/

# Gerar relatório HTML
carton exec cover

# Relatório disponível em: cover_db/coverage.html

# Formato Clover para CI (GitHub Actions, Jenkins)
carton exec cover -report clover

# Ver sumário no terminal
carton exec cover -report text

Adicionar ao .gitignore:

cover_db/

Integração com GitHub Actions

# .github/workflows/ci.yml (fragmento)
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: stega_test
POSTGRES_USER: stega_migrate
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-retries 5

steps:
- uses: actions/checkout@v4

- uses: shogo82148/actions-setup-perl@v1
with:
perl-version: '5.42'

- name: Instalar Carton
run: cpanm --notest Carton

- name: Instalar dependências
run: carton install --deployment

- name: Aplicar migrations
run: carton exec perl eng/migrate.pl
env:
POSTGRESQL_MIGRATION_URL: postgresql://stega_migrate:test@localhost/stega_test

- name: Rodar testes
run: carton exec prove -lr t/
env:
POSTGRESQL_URL: postgresql://stega_migrate:test@localhost/stega_test

- name: Gerar cobertura
run: |
PERL5OPT="-MDevel::Cover" carton exec prove -lr t/
carton exec cover -report clover
env:
POSTGRESQL_URL: postgresql://stega_migrate:test@localhost/stega_test

Convenções de teste do stack

ConvençãoRazão
done_testing (sem número de testes)Flexível — não exige atualização ao adicionar subtests
subtest 'descrição' => sub { ... }Agrupa testes relacionados com saída TAP hierárquica
Um arquivo .t por rota ou modeloFacilita rodar um subconjunto; falhas isoladas não travam tudo
Mock de JWT em todos os testes de APITestes de API não dependem do Keycloak — rodam offline
Test::MockObject para deps externasRabbitMQ e e-mail não são chamados em testes de unidade

Armadilhas comuns

ArmadilhaDescriçãoComo evitar
Test::Mojo->new('Stega') sem bancoSe o controller acessa $self->pg imediatamente no startup, falha sem PostgreSQLConfigure banco de teste ou use MOJO_MODE=test para desabilitar conexões no startup
Hook before_dispatch acumuladoEm múltiplos subtest, o hook é adicionado várias vezes — chama o mock múltiplas vezesDefina o hook uma vez, fora dos subtest
Testes que dependem de ordemBanco compartilhado entre testes causa estado residualUse transações por teste: begin no início, rollback no fim
prove sem -lMódulos em lib/ não são encontrados sem -l (adiciona lib/ ao @INC)Sempre prove -l ou prove -lr (recursivo)
Cobertura de 100% como metaDevel::Cover pode incentivar testes triviais apenas para cobrir linhasFoque em comportamento: fluxos de erro, edge cases, integrações