Por que testar em camadas (unitário, integração e HTTP)
Em um back-end com Slim, é útil separar testes por objetivo: unitários validam regras de domínio e casos de uso isolados (rápidos e determinísticos), integração valida a conversa com infraestrutura real (ex.: repositório + banco), e HTTP/rotas valida o comportamento do sistema “de fora”, exercitando handlers, middlewares e serialização de respostas. Essa separação reduz falsos positivos/negativos e facilita localizar a origem de falhas.
| Tipo | O que valida | Dependências | Quando usar |
|---|---|---|---|
| Unitário | Regras de domínio, casos de uso, mapeamentos | Doubles (mocks/stubs/fakes) | Para garantir lógica e contratos |
| Integração | Repositórios, queries, transações, constraints | Banco real (ou container), fixtures | Para garantir persistência correta |
| HTTP | Rotas, middlewares, headers, JSON, status | App Slim + request/response simulados | Para garantir API conforme contrato |
Ferramentas e organização de pastas
Uma estrutura comum de testes (ajuste ao seu projeto):
tests/ Unit/ Domain/ Application/ Integration/ Infrastructure/Repository/ Http/ Routes/ Middleware/ ErrorHandling/ Support/ factories/ fixtures/ bootstrap.phpRecomendações práticas:
- Use
phpunitcomo runner. - Crie uma base de testes para compartilhar helpers (ex.: criar app, criar request, decodificar JSON).
- Separe
Unit,IntegrationeHttppara evitar misturar dependências e tempos de execução.
Exemplo de phpunit.xml (com suites)
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="tests/bootstrap.php" colors="true"> <testsuites> <testsuite name="unit"> <directory>tests/Unit</directory> </testsuite> <testsuite name="integration"> <directory>tests/Integration</directory> </testsuite> <testsuite name="http"> <directory>tests/Http</directory> </testsuite> </testsuites> <php> <env name="APP_ENV" value="test"/> </php> </phpunit>Testes unitários: domínio e casos de uso com doubles
Conceito: testar comportamento, não implementação
Em testes unitários, o foco é: dado um input, o caso de uso produz um output (ou exceção) esperado e interage com suas dependências por meio de interfaces. Para isso, substituímos repositórios/serviços por doubles:
- Stub: retorna valores pré-definidos.
- Mock: além de retornar, verifica chamadas (quantidade/argumentos).
- Fake: implementação simples em memória (útil quando mocks ficam complexos).
Exemplo de caso de uso e contratos
<?php interface UserRepository { public function findByEmail(string $email): ?User; public function save(User $user): void; } interface PasswordHasher { public function hash(string $plain): string; } final class RegisterUserInput { public function __construct( public readonly string $email, public readonly string $password ) {} } final class RegisterUserOutput { public function __construct( public readonly string $id, public readonly string $email ) {} } final class EmailAlreadyExists extends DomainException {} final class RegisterUser { public function __construct( private UserRepository $users, private PasswordHasher $hasher ) {} public function execute(RegisterUserInput $in): RegisterUserOutput { $existing = $this->users->findByEmail($in->email); if ($existing) { throw new EmailAlreadyExists(); } $hash = $this->hasher->hash($in->password); $user = User::register($in->email, $hash); $this->users->save($user); return new RegisterUserOutput($user->id(), $user->email()); } }Passo a passo: teste unitário com mocks (PHPUnit)
Objetivo: garantir que (1) e-mail duplicado lança exceção, (2) fluxo feliz salva usuário e retorna output.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
<?php use PHPUnit\Framework\TestCase; final class RegisterUserTest extends TestCase { public function test_it_throws_when_email_already_exists(): void { $users = $this->createMock(UserRepository::class); $hasher = $this->createMock(PasswordHasher::class); $users->method('findByEmail')->with('a@a.com')->willReturn(User::fromExisting('1', 'a@a.com', 'hash')); $useCase = new RegisterUser($users, $hasher); $this->expectException(EmailAlreadyExists::class); $useCase->execute(new RegisterUserInput('a@a.com', 'secret')); } public function test_it_saves_user_and_returns_output(): void { $users = $this->createMock(UserRepository::class); $hasher = $this->createMock(PasswordHasher::class); $users->method('findByEmail')->with('new@a.com')->willReturn(null); $hasher->method('hash')->with('secret')->willReturn('hashed'); $users->expects($this->once())->method('save')->with($this->callback(function(User $u) { return $u->email() === 'new@a.com'; })); $useCase = new RegisterUser($users, $hasher); $out = $useCase->execute(new RegisterUserInput('new@a.com', 'secret')); $this->assertSame('new@a.com', $out->email); $this->assertNotEmpty($out->id); } }Quando preferir fakes em memória
Se o caso de uso interage com o repositório de forma mais rica (ex.: múltiplas consultas, paginação, filtros), mocks podem ficar frágeis. Um fake em memória reduz acoplamento ao “como” e foca no “o quê”.
<?php final class InMemoryUserRepository implements UserRepository { private array $items = []; public function findByEmail(string $email): ?User { foreach ($this->items as $u) { if ($u->email() === $email) return $u; } return null; } public function save(User $user): void { $this->items[$user->id()] = $user; } }Testes de integração: repositórios com banco, transações e fixtures
Conceito: validar SQL, mapeamento e constraints
Teste de integração deve usar o repositório real e um banco real (idealmente isolado para testes). O objetivo é pegar problemas que unitários não pegam: joins errados, colunas, tipos, constraints, transações, índices, etc.
Estratégia 1: transação por teste (rollback no tearDown)
Essa estratégia é rápida e mantém o banco limpo, desde que o seu código use a mesma conexão (ou você consiga garantir isso no teste).
<?php use PHPUnit\Framework\TestCase; final class UserRepositoryPdoIntegrationTest extends TestCase { private PDO $pdo; private UserRepository $repo; protected function setUp(): void { $this->pdo = TestDatabase::pdo(); // helper que cria PDO para o banco de teste $this->pdo->beginTransaction(); $this->repo = new PdoUserRepository($this->pdo); } protected function tearDown(): void { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } } public function test_save_and_find_by_email(): void { $user = User::register('int@a.com', 'hashed'); $this->repo->save($user); $found = $this->repo->findByEmail('int@a.com'); $this->assertNotNull($found); $this->assertSame('int@a.com', $found->email()); } }Estratégia 2: fixtures controladas (seed mínimo por cenário)
Fixtures devem ser pequenas e específicas. Evite “dump” de banco inteiro. Uma abordagem simples é ter scripts SQL por cenário e executá-los no setUp.
<?php final class Fixtures { public static function load(PDO $pdo, string $name): void { $sql = file_get_contents(__DIR__ . '/fixtures/' . $name . '.sql'); $pdo->exec($sql); } }<?php public function test_find_returns_null_when_not_exists(): void { Fixtures::load($this->pdo, 'users_empty'); $found = $this->repo->findByEmail('missing@a.com'); $this->assertNull($found); }Boas práticas para integração com banco
- Garanta que o banco de teste é isolado (schema próprio) e que migrations já foram aplicadas.
- Evite dependência entre testes: cada teste prepara seu estado (via transação/fixtures).
- Teste também falhas esperadas: constraint de e-mail único, foreign keys, etc.
- Se seu repositório usa transações internas, valide efeitos atômicos (ex.: duas tabelas).
Testes HTTP: simulando Request/Response e validando contrato
Conceito: testar a API como consumidor
Testes HTTP devem validar: status code, headers (ex.: Content-Type), e payload JSON (estrutura e valores). Eles também ajudam a garantir que middlewares e error handlers estão conectados corretamente.
Passo a passo: helper para criar App e executar request
Crie um helper que constrói o app em modo teste e permite executar uma requisição. A ideia é: montar um ServerRequestInterface, chamar $app->handle($request) e inspecionar o ResponseInterface.
<?php use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Slim\App; use Slim\Psr7\Factory\ServerRequestFactory; final class HttpTestHelper { public static function app(): App { return AppFactoryForTests::create(); // factory do app com container/config de teste } public static function request(string $method, string $path, array $jsonBody = [], array $headers = []): ServerRequestInterface { $factory = new ServerRequestFactory(); $req = $factory->createServerRequest($method, $path); foreach ($headers as $k => $v) { $req = $req->withHeader($k, $v); } if (!empty($jsonBody)) { $req->getBody()->write(json_encode($jsonBody)); $req = $req->withHeader('Content-Type', 'application/json'); } return $req; } public static function json(ResponseInterface $res): array { $res->getBody()->rewind(); return json_decode((string)$res->getBody(), true) ?? []; } }Teste de rota: status, headers e JSON
<?php use PHPUnit\Framework\TestCase; final class RegisterUserRouteTest extends TestCase { public function test_register_user_returns_201_and_json(): void { $app = HttpTestHelper::app(); $req = HttpTestHelper::request('POST', '/users', [ 'email' => 'http@a.com', 'password' => 'secret' ]); $res = $app->handle($req); $this->assertSame(201, $res->getStatusCode()); $this->assertStringContainsString('application/json', $res->getHeaderLine('Content-Type')); $json = HttpTestHelper::json($res); $this->assertArrayHasKey('data', $json); $this->assertSame('http@a.com', $json['data']['email']); $this->assertNotEmpty($json['data']['id']); } }Validando erros HTTP padronizados
Além do “happy path”, valide que erros retornam o formato combinado (por exemplo: {"error":{"code","message","details"}}) e o status correto.
<?php final class RegisterUserErrorsRouteTest extends TestCase { public function test_duplicate_email_returns_409_with_standard_error(): void { $app = HttpTestHelper::app(); // 1) cria usuário $req1 = HttpTestHelper::request('POST', '/users', [ 'email' => 'dup@a.com', 'password' => 'secret' ]); $app->handle($req1); // 2) tenta criar novamente $req2 = HttpTestHelper::request('POST', '/users', [ 'email' => 'dup@a.com', 'password' => 'secret' ]); $res2 = $app->handle($req2); $this->assertSame(409, $res2->getStatusCode()); $json = HttpTestHelper::json($res2); $this->assertArrayHasKey('error', $json); $this->assertSame('EMAIL_ALREADY_EXISTS', $json['error']['code']); $this->assertNotEmpty($json['error']['message']); } }Testando middlewares: unidade e integração no pipeline
Conceito: middleware como função de transformação/guarda
Um middleware pode: bloquear (retornar resposta sem chamar o próximo), enriquecer request (atributos), ou enriquecer response (headers). Testes devem cobrir esses três comportamentos.
Passo a passo: teste unitário de middleware com handler “fake”
Você pode testar um middleware isoladamente criando um RequestHandlerInterface fake que retorna uma resposta conhecida.
<?php use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Slim\Psr7\Response; final class FakeHandler implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): Response { $res = new Response(200); $res->getBody()->write(json_encode(['ok' => true])); return $res->withHeader('Content-Type', 'application/json'); } } final class CorrelationIdMiddlewareTest extends TestCase { public function test_it_adds_correlation_id_header(): void { $mw = new CorrelationIdMiddleware(); $req = HttpTestHelper::request('GET', '/ping'); $res = $mw->process($req, new FakeHandler()); $this->assertSame(200, $res->getStatusCode()); $this->assertNotEmpty($res->getHeaderLine('X-Correlation-Id')); } }Teste de integração do middleware no app (ordem importa)
Alguns comportamentos dependem da ordem no pipeline (ex.: autenticação antes de autorização). Nesse caso, valide via teste HTTP que o middleware está realmente aplicado.
<?php final class AuthMiddlewareHttpTest extends TestCase { public function test_protected_route_requires_token(): void { $app = HttpTestHelper::app(); $req = HttpTestHelper::request('GET', '/me'); $res = $app->handle($req); $this->assertSame(401, $res->getStatusCode()); $json = HttpTestHelper::json($res); $this->assertSame('UNAUTHORIZED', $json['error']['code']); } }Testando error handlers: exceções, mapeamento e consistência
Conceito: garantir que toda falha vira resposta previsível
Mesmo com exceções diferentes (domínio, validação, infraestrutura), o consumidor da API deve receber um formato consistente. Testes devem cobrir:
- Exceções de domínio mapeadas para status adequados (ex.: 409, 404).
- Erros de validação (ex.: 422) com
detailspor campo. - Erros inesperados (500) sem vazar detalhes sensíveis.
Passo a passo: teste HTTP para exceção não tratada virar 500 padronizado
Crie uma rota “de teste” apenas no ambiente de teste (ou injete um handler fake) que lança uma exceção e valide o formato.
<?php final class ErrorHandlerHttpTest extends TestCase { public function test_unhandled_exception_returns_standard_500(): void { $app = HttpTestHelper::app(); $req = HttpTestHelper::request('GET', '/__test__/explode'); $res = $app->handle($req); $this->assertSame(500, $res->getStatusCode()); $json = HttpTestHelper::json($res); $this->assertSame('INTERNAL_ERROR', $json['error']['code']); $this->assertArrayNotHasKey('trace', $json['error']); } }Checklist de padronização de erros (para transformar em asserts)
Content-Typesempre JSON em erro.- Campo
error.codeestável (útil para clientes). error.messagelegível e sem dados sensíveis.error.detailspresente apenas quando fizer sentido (ex.: validação).- Em 500, não retornar stack trace em ambiente de teste/produção (a menos que seu contrato permita explicitamente).
Estratégia prática de cobertura: o que testar em cada nível
Domínio
- Entidades/Value Objects: invariantes, normalização, comparações.
- Exceções de domínio: quando devem ocorrer.
Casos de uso
- Fluxo feliz e fluxos alternativos.
- Interações com interfaces (repositórios/serviços) usando mocks ou fakes.
- Mapeamento de input/output (DTOs) sem depender de HTTP.
Integração
- Repositórios: salvar, buscar, atualizar, paginação, filtros.
- Transações: atomicidade e rollback em falhas.
- Constraints: unicidade, FKs, not null.
HTTP
- Contrato: status, headers, JSON shape.
- Middlewares: autenticação/autorização, headers, rate limit (se houver).
- Error handler: mapeamento de exceções e consistência do payload.