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

Domain-Driven Design
Domain-Driven Design

Domain-Driven Design (DDD) é uma abordagem para desenvolver software complexo...

MongoDB com PHP
MongoDB com PHP

MongoDB representa uma abordagem radicalmente diferente ao armazenamento de d...

Clean Architecture com PHP
Clean Architecture com PHP

Clean Architecture, formulada por Robert C. Martin, organiza o código em cama...