PHP

Testes Automatizados com PHPUnit Já leu

11 min de leitura

Testes Automatizados com PHPUnit
Testes automatizados são o mecanismo que permite modificar código com confiança. Sem testes, cada mudança é uma aposta — você só descobre que quebrou algo quando o cliente reclama. Com testes, a suite te avisa em segundo

Testes automatizados são o mecanismo que permite modificar código com confiança. Sem testes, cada mudança é uma aposta — você só descobre que quebrou algo quando o cliente reclama. Com testes, a suite te avisa em segundos se algo regrediu.

PHPUnit é o framework de testes padrão do ecossistema PHP. Ele organiza testes em classes, oferece assertions expressivas, mocks para isolar dependências, e data providers para testar múltiplos cenários sem duplicar código. Neste artigo construímos testes unitários puros, testes de integração com banco real, e aprendemos a usar mocks para testar código que depende de serviços externos — sem precisar que eles existam.


Configuração e estrutura de projeto

<!-- phpunit.xml — configuração do PHPUnit -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
         stopOnFailure="false">

    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Integration">
            <directory>tests/Integration</directory>
        </testsuite>
    </testsuites>

    <coverage>
        <include>
            <directory suffix=".php">src</directory>
        </include>
    </coverage>

    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>
projeto/
├── src/
│   ├── Domain/
│   │   ├── Produto.php
│   │   └── Carrinho.php
│   ├── Service/
│   │   └── CarrinhoService.php
│   └── Repository/
│       └── ProdutoRepositorioInterface.php
├── tests/
│   ├── Unit/
│   │   ├── Domain/
│   │   │   └── CarrinhoTest.php
│   │   └── Service/
│   │       └── CarrinhoServiceTest.php
│   └── Integration/
│       └── Repository/
│           └── ProdutoRepositorioPDOTest.php
├── phpunit.xml
└── composer.json

Primeiro teste — entidade de domínio

Testes unitários testam uma única unidade de código em isolamento, sem banco de dados, sem HTTP, sem arquivos. São os mais rápidos e os mais valiosos.

<?php
declare(strict_types=1);

namespace App\Domain;

// ── Entidade que vamos testar ──────────────────────────────────────────
class Produto
{
    public function __construct(
        public readonly int    $id,
        public readonly string $nome,
        public readonly float  $preco,
        public readonly int    $estoque,
    ) {}

    public function comDesconto(float $percentual): static
    {
        if ($percentual < 0 || $percentual > 100) {
            throw new \InvalidArgumentException(
                "Percentual deve ser entre 0 e 100, recebido: {$percentual}"
            );
        }
        return new static(
            id:      $this->id,
            nome:    $this->nome,
            preco:   round($this->preco * (1 - $percentual / 100), 2),
            estoque: $this->estoque,
        );
    }

    public function temEstoque(int $quantidade = 1): bool
    {
        return $this->estoque >= $quantidade;
    }
}
<?php
declare(strict_types=1);

namespace Tests\Unit\Domain;

use App\Domain\Produto;
use PHPUnit\Framework\TestCase;

class ProdutoTest extends TestCase
{
    // Convenção: método de teste começa com "test" ou usa atributo #[Test]
    // Nome do método descreve o comportamento esperado

    public function test_desconto_calcula_preco_corretamente(): void
    {
        $produto    = new Produto(id: 1, nome: 'Teclado', preco: 200.00, estoque: 10);
        $comDesconto = $produto->comDesconto(10); // 10%

        $this->assertSame(180.00, $comDesconto->preco);
        $this->assertSame(1, $comDesconto->id);    // imutável — mantém o ID
        $this->assertSame('Teclado', $comDesconto->nome);
    }

    public function test_desconto_zero_nao_altera_preco(): void
    {
        $produto = new Produto(id: 1, nome: 'Mouse', preco: 150.00, estoque: 5);
        $this->assertSame(150.00, $produto->comDesconto(0)->preco);
    }

    public function test_desconto_100_resulta_em_preco_zero(): void
    {
        $produto = new Produto(id: 1, nome: 'Fone', preco: 300.00, estoque: 3);
        $this->assertSame(0.00, $produto->comDesconto(100)->preco);
    }

    public function test_desconto_invalido_lanca_excecao(): void
    {
        $produto = new Produto(id: 1, nome: 'Monitor', preco: 1800.00, estoque: 2);

        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Percentual deve ser entre 0 e 100');

        $produto->comDesconto(150); // inválido
    }

    public function test_tem_estoque_retorna_true_quando_suficiente(): void
    {
        $produto = new Produto(id: 1, nome: 'SSD', preco: 400.00, estoque: 5);

        $this->assertTrue($produto->temEstoque(1));
        $this->assertTrue($produto->temEstoque(5));
        $this->assertFalse($produto->temEstoque(6));
    }

    public function test_tem_estoque_retorna_false_quando_zerado(): void
    {
        $produto = new Produto(id: 1, nome: 'RAM', preco: 250.00, estoque: 0);
        $this->assertFalse($produto->temEstoque());
    }
}

Data Providers — testando múltiplos cenários

Data Providers eliminam a repetição de testes similares. Em vez de escrever um teste por caso, você declara os dados e o PHPUnit executa o mesmo método para cada conjunto.

<?php
declare(strict_types=1);

namespace Tests\Unit\Domain;

use App\Domain\Produto;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class ProdutoDescontoTest extends TestCase
{
    // PHP 8.x syntax: atributo #[DataProvider] no lugar de @dataProvider
    #[DataProvider('provedorDescontosValidos')]
    public function test_desconto_valido(float $preco, float $percentual, float $esperado): void
    {
        $produto     = new Produto(id: 1, nome: 'Produto', preco: $preco, estoque: 10);
        $comDesconto = $produto->comDesconto($percentual);
        $this->assertSame($esperado, $comDesconto->preco);
    }

    public static function provedorDescontosValidos(): array
    {
        return [
            'desconto 10% em R$200'   => [200.00, 10,  180.00],
            'desconto 25% em R$100'   => [100.00, 25,   75.00],
            'desconto 50% em R$300'   => [300.00, 50,  150.00],
            'desconto 0% mantém preço'=> [150.00,  0,  150.00],
            'desconto 100% zera preço'=> [500.00, 100,   0.00],
        ];
    }

    #[DataProvider('provedorDescontosInvalidos')]
    public function test_desconto_invalido_lanca_excecao(float $percentual): void
    {
        $produto = new Produto(id: 1, nome: 'Produto', preco: 100.00, estoque: 10);

        $this->expectException(\InvalidArgumentException::class);
        $produto->comDesconto($percentual);
    }

    public static function provedorDescontosInvalidos(): array
    {
        return [
            'negativo'      => [-1.0],
            'maior que 100' => [101.0],
            'muito alto'    => [999.0],
        ];
    }
}

Mocks — testando com dependências isoladas

Mocks substituem dependências reais por objetos controlados. Isso permite testar o comportamento de uma classe sem que ela precise de banco de dados, API externa ou qualquer infraestrutura.

<?php
declare(strict_types=1);

namespace App\Service;

use App\Domain\Produto;
use App\Repository\ProdutoRepositorioInterface;
use App\Service\NotificacaoService;

class CarrinhoService
{
    public function __construct(
        private readonly ProdutoRepositorioInterface $repositorio,
        private readonly NotificacaoService          $notificacao,
    ) {}

    public function adicionarAoCarrinho(int $produtoId, int $quantidade): array
    {
        $produto = $this->repositorio->buscarPorId($produtoId);

        if ($produto === null) {
            throw new \RuntimeException("Produto #{$produtoId} não encontrado.");
        }

        if (!$produto->temEstoque($quantidade)) {
            throw new \RuntimeException(
                "Estoque insuficiente. Disponível: {$produto->estoque}, solicitado: {$quantidade}"
            );
        }

        // Notifica se estoque ficará baixo após a compra
        if ($produto->estoque - $quantidade <= 3) {
            $this->notificacao->alertarEstoqueBaixo($produto);
        }

        return [
            'produto'    => ['id' => $produto->id, 'nome' => $produto->nome],
            'quantidade' => $quantidade,
            'subtotal'   => round($produto->preco * $quantidade, 2),
        ];
    }
}
<?php
declare(strict_types=1);

namespace Tests\Unit\Service;

use App\Domain\Produto;
use App\Repository\ProdutoRepositorioInterface;
use App\Service\CarrinhoService;
use App\Service\NotificacaoService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

class CarrinhoServiceTest extends TestCase
{
    private ProdutoRepositorioInterface&MockObject $repositorio;
    private NotificacaoService&MockObject          $notificacao;
    private CarrinhoService                        $servico;

    // setUp() é executado antes de CADA método de teste
    protected function setUp(): void
    {
        $this->repositorio = $this->createMock(ProdutoRepositorioInterface::class);
        $this->notificacao = $this->createMock(NotificacaoService::class);
        $this->servico     = new CarrinhoService($this->repositorio, $this->notificacao);
    }

    public function test_adicionar_produto_retorna_item_correto(): void
    {
        $produto = new Produto(id: 42, nome: 'Teclado', preco: 200.00, estoque: 10);

        // Configura o mock: quando buscarPorId(42) for chamado, retorna $produto
        $this->repositorio
            ->expects($this->once())       // deve ser chamado exatamente 1 vez
            ->method('buscarPorId')
            ->with(42)                     // com este argumento
            ->willReturn($produto);

        // Notificação NÃO deve ser chamada (estoque 10 - 2 = 8, não é baixo)
        $this->notificacao
            ->expects($this->never())
            ->method('alertarEstoqueBaixo');

        $resultado = $this->servico->adicionarAoCarrinho(produtoId: 42, quantidade: 2);

        $this->assertSame(42, $resultado['produto']['id']);
        $this->assertSame('Teclado', $resultado['produto']['nome']);
        $this->assertSame(2, $resultado['quantidade']);
        $this->assertSame(400.00, $resultado['subtotal']);
    }

    public function test_adicionar_produto_inexistente_lanca_excecao(): void
    {
        $this->repositorio
            ->method('buscarPorId')
            ->willReturn(null);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('não encontrado');

        $this->servico->adicionarAoCarrinho(produtoId: 999, quantidade: 1);
    }

    public function test_adicionar_sem_estoque_suficiente_lanca_excecao(): void
    {
        $produto = new Produto(id: 1, nome: 'SSD', preco: 400.00, estoque: 2);

        $this->repositorio->method('buscarPorId')->willReturn($produto);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Estoque insuficiente');

        $this->servico->adicionarAoCarrinho(produtoId: 1, quantidade: 5); // pede 5, tem 2
    }

    public function test_notificacao_e_disparada_quando_estoque_fica_baixo(): void
    {
        // Estoque = 4, quantidade = 2 → sobrará 2 (≤ 3) → deve notificar
        $produto = new Produto(id: 1, nome: 'Mouse', preco: 150.00, estoque: 4);

        $this->repositorio->method('buscarPorId')->willReturn($produto);

        // Agora o mock DEVE ser chamado — e com o produto correto
        $this->notificacao
            ->expects($this->once())
            ->method('alertarEstoqueBaixo')
            ->with($this->callback(fn($p) => $p->id === 1));

        $this->servico->adicionarAoCarrinho(produtoId: 1, quantidade: 2);
    }
}

Teste de integração — com banco de dados real

Testes de integração verificam que o código funciona com a infraestrutura real. Usamos SQLite em memória para isolar e manter os testes rápidos.

<?php
declare(strict_types=1);

namespace Tests\Integration\Repository;

use App\Domain\Produto;
use App\Repository\ProdutoRepositorioPDO;
use PHPUnit\Framework\TestCase;

class ProdutoRepositorioPDOTest extends TestCase
{
    private \PDO $pdo;
    private ProdutoRepositorioPDO $repositorio;

    protected function setUp(): void
    {
        // Banco em memória — zero custo, zero estado entre testes
        $this->pdo = new \PDO('sqlite::memory:');
        $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);

        // Cria o schema necessário para os testes
        $this->pdo->exec("
            CREATE TABLE produtos (
                id      INTEGER PRIMARY KEY AUTOINCREMENT,
                nome    TEXT    NOT NULL,
                preco   REAL    NOT NULL,
                estoque INTEGER NOT NULL DEFAULT 0,
                ativo   INTEGER NOT NULL DEFAULT 1
            )
        ");

        $this->repositorio = new ProdutoRepositorioPDO($this->pdo);
    }

    protected function tearDown(): void
    {
        // Banco em memória — descartado automaticamente; tearDown() aqui por clareza
        unset($this->pdo, $this->repositorio);
    }

    public function test_salvar_cria_produto_com_id(): void
    {
        $produto = new Produto(id: 0, nome: 'Teclado Mecânico', preco: 350.00, estoque: 10);
        $salvo   = $this->repositorio->salvar($produto);

        $this->assertGreaterThan(0, $salvo->id);
        $this->assertSame('Teclado Mecânico', $salvo->nome);
        $this->assertSame(350.00, $salvo->preco);
    }

    public function test_buscar_por_id_retorna_produto_correto(): void
    {
        $produto = $this->repositorio->salvar(
            new Produto(id: 0, nome: 'Mouse Gamer', preco: 180.00, estoque: 20)
        );

        $encontrado = $this->repositorio->buscarPorId($produto->id);

        $this->assertNotNull($encontrado);
        $this->assertSame($produto->id, $encontrado->id);
        $this->assertSame('Mouse Gamer', $encontrado->nome);
    }

    public function test_buscar_por_id_inexistente_retorna_null(): void
    {
        $this->assertNull($this->repositorio->buscarPorId(99999));
    }

    public function test_deletar_remove_produto(): void
    {
        $produto = $this->repositorio->salvar(
            new Produto(id: 0, nome: 'Fone Bluetooth', preco: 220.00, estoque: 5)
        );

        $this->repositorio->deletar($produto->id);
        $this->assertNull($this->repositorio->buscarPorId($produto->id));
    }

    public function test_buscar_ativos_retorna_apenas_ativos(): void
    {
        // Insere 2 ativos e 1 inativo diretamente no banco
        $this->pdo->exec("INSERT INTO produtos (nome, preco, estoque, ativo) VALUES
            ('Produto A', 100.00, 5, 1),
            ('Produto B', 200.00, 3, 1),
            ('Inativo',   150.00, 8, 0)
        ");

        $ativos = $this->repositorio->buscarAtivos();
        $this->assertCount(2, $ativos);

        $nomes = array_column(array_map(fn($p) => ['nome' => $p->nome], $ativos), 'nome');
        $this->assertContains('Produto A', $nomes);
        $this->assertContains('Produto B', $nomes);
        $this->assertNotContains('Inativo', $nomes);
    }
}

Resumo do artigo

Conceito O que aprendemos
TestCase Classe base do PHPUnit — cada método test_* é um teste independente
setUp() Executado antes de cada teste — prepara estado comum
tearDown() Executado após cada teste — limpa recursos
Assertions assertSame, assertTrue, assertNull, assertCount, expectException
Data Provider #[DataProvider] — testa múltiplos cenários sem duplicar código
Mock createMock() — substitui dependências por objetos controlados
expects() Verifica quantas vezes e com quais argumentos um método do mock foi chamado
Teste de integração SQLite em memória — banco real, sem efeito colateral entre testes

Exercício da semana

  1. Escreva testes unitários para a entidade Carrinho: métodos adicionar(Produto, int), remover(int $produtoId), calcularTotal(): float e estaVazio(): bool. Use Data Providers para testar calcularTotal() com múltiplos produtos.

  2. Crie um mock do ServicoJWT para testar o AuthController sem emitir tokens reais. O mock deve simular cenários de token válido, expirado e inválido — verifique que o controller retorna os status HTTP corretos em cada caso.

  3. Adicione cobertura de testes para o QueryBuilder construído no Artigo 21. Teste cada método (where, whereIn, orderBy, limit, offset) verificando o SQL gerado por build().

  4. Implemente um teste de integração para o MigrationRunner: rode as migrations em SQLite em memória e verifique que as tabelas foram criadas. Rode de novo e verifique que não há erro (idempotência). Execute rollback() e verifique que as tabelas foram removidas.

  5. Desafio: configure cobertura de código com Xdebug ou PCOV e execute vendor/bin/phpunit --coverage-html coverage/. Identifique os caminhos de código não cobertos e escreva testes para atingir 90% de cobertura nos módulos Domain e Service.


Referências

  • PHPUnit — Documentação oficial: https://phpunit.de/documentation.html
  • PHPUnit — Test Doubles (Mocks): https://docs.phpunit.de/en/11.5/test-doubles.html
  • PHP Manual — PDO SQLite: https://www.php.net/manual/pt_BR/ref.pdo-sqlite.php
  • Martin Fowler — Mocks Aren't Stubs: https://martinfowler.com/articles/mocksArentStubs.html
Comentários

Mais em PHP

Artigo 47 — Testes Automatizados com PHPUnit e Pest
Artigo 47 — Testes Automatizados com PHPUnit e Pest

Testes automatizados são o seguro de vida do software. Sem eles, cada mudança...

Artigo 40 — Boas Práticas e Código Limpo em PHP
Artigo 40 — Boas Práticas e Código Limpo em PHP

Boas práticas não são regras arbitrárias inventadas por puristas. São soluçõe...

Upload de Arquivos
Upload de Arquivos

Upload de arquivos parece simples — e na superfície é. Mas quando você consid...