PHP Testes com PHPUnit

Toda vez que a Ana muda o carrinho da lojinha, ela abre o site, coloca produtos, aplica um cupom e confere o total na calculadora. Funciona, mas cansa, e um dia ela vai esquecer de conferir alguma coisa. Um teste automatizado é um pequeno programa que faz essa conferência por ela, em segundos, quantas vezes ela quiser. O PHPUnit é a ferramenta padrão do PHP para escrever e rodar esses testes.

Você vai aprender
  • O que é um teste automatizado e por que ele vale o trabalho.
  • Instalar o PHPUnit com o Composer, só para o desenvolvimento.
  • Escrever uma classe de teste (TestCase) com assertSame.
  • Rodar com vendor/bin/phpunit e ler a saída de sucesso e de falha.
  • Testar exceções com expectException.
  • Testar vários casos de uma vez com data providers (#[DataProvider]).
  • Configurar o projeto com phpunit.xml e ver os testes como frases com --testdox.
O PHPUnit não roda no editor do guia. Ele é instalado pelo Composer, que precisa de internet e de um terminal de verdade. Todas as saídas de composer e vendor/bin/phpunit desta aula vieram do computador da Ana (PHPUnit 13.4.3, PHP 8.5). Nas caixas "Experimente", você vai treinar a mesma ideia com um "mini PHPUnit" de poucas linhas, escrito em PHP puro.

A ideia: a lista de conferência do confeiteiro

Um confeiteiro que muda a receita do bolo não serve o bolo novo direto ao cliente: ele prova. E um confeiteiro organizado tem uma lista do que provar: "está doce?", "a massa cresceu?", "o recheio não vazou?". Se mudar o fermento, ele passa a lista inteira de novo, porque às vezes mexer numa coisa estraga outra.

Um teste é um item dessa lista, escrito em PHP: "um carrinho novo tem 0 itens", "2 cadernos de R$ 25,50 dão R$ 51,00", "o cupom PROMO10 tira 10%". O PHPUnit passa a lista inteira e responde com um relatório. Quase todo teste segue três passos, conhecidos como Preparar, Agir, Conferir:

  1. Preparar: criar o que o teste precisa (um carrinho, uns produtos).
  2. Agir: chamar o método que está sendo testado.
  3. Conferir: comparar o resultado com o esperado (uma assertion, "afirmação").

1. O projeto e a classe a testar

A Ana organizou a lojinha como um projeto Composer (veja Composer e Autoload e PSR-4): o código fica em src/, os testes em tests/.

lojinha/
├── composer.json
├── src/
│   └── Carrinho.php       « o código da lojinha (namespace Loja)
└── tests/
    └── CarrinhoTest.php   « os testes
{
    "name": "ana/lojinha",
    "autoload": {
        "psr-4": {
            "Loja\\": "src/"
        }
    }
}

Esta é a classe que vamos testar. Ela guarda os itens num array (produto → preço e quantidade), soma as quantidades, calcula o total e aplica cupons:

<?php

declare(strict_types=1);

namespace Loja;

use InvalidArgumentException;

final class Carrinho
{
    /** @var array<string, array{preco: float, quantidade: int}> */
    private array $itens = [];

    public function adicionar(string $produto, float $preco, int $quantidade = 1): void
    {
        if ($quantidade < 1) {
            throw new InvalidArgumentException("Quantidade inválida: $quantidade");
        }

        if (isset($this->itens[$produto])) {
            $this->itens[$produto]['quantidade'] += $quantidade;
        } else {
            $this->itens[$produto] = ['preco' => $preco, 'quantidade' => $quantidade];
        }
    }

    public function quantidadeDeItens(): int
    {
        return array_sum(array_column($this->itens, 'quantidade'));
    }

    public function total(): float
    {
        $total = 0.0;
        foreach ($this->itens as $item) {
            $total += $item['preco'] * $item['quantidade'];
        }
        return round($total, 2);
    }

    public function totalComCupom(string $cupom): float
    {
        $desconto = match ($cupom) {
            'PROMO10' => 0.10,
            'METADE'  => 0.50,
            ''        => 0.0,
            default   => throw new InvalidArgumentException("Cupom desconhecido: $cupom"),
        };
        return round($this->total() * (1 - $desconto), 2);
    }
}

2. Instalar o PHPUnit

O PHPUnit é um pacote do Composer. Como ele só é usado por quem desenvolve (o site no ar não precisa dele), instale-o como dependência de desenvolvimento, com --dev:

$ cd lojinha
$ composer require --dev phpunit/phpunit
Composer could not detect the root package (ana/lojinha) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version
./composer.json has been updated
Composer could not detect the root package (ana/lojinha) version, defaulting to '1.0.0'. See https://getcomposer.org/root-version
Running composer update phpunit/phpunit
Loading composer repositories with package information
Updating dependencies
Lock file operations: 27 installs, 0 updates, 0 removals
  - Locking myclabs/deep-copy (1.14.0)
  - Locking nikic/php-parser (v5.9.0)
  «… mais 23 linhas "Locking", uma por pacote …»
  - Locking phpunit/phpunit (13.4.3)
  - Locking theseer/tokenizer (2.0.1)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 27 installs, 0 updates, 0 removals
  - Downloading staabm/side-effects-detector (1.0.5)
  «… um "Downloading" e um "Installing" por pacote, com barras de progresso …»
  - Installing phpunit/phpunit (13.4.3): Extracting archive
2 package suggestions were added by new dependencies, use `composer suggest` to see details.
Generating autoload files
25 packages you are using are looking for funding.
Use the `composer fund` command to find out more!
No security vulnerability advisories found.
Using version ^13.4 for phpunit/phpunit
--dev
Grava o pacote em "require-dev" no composer.json. No servidor de produção, composer install --no-dev deixa esses pacotes de fora.
27 installs
O PHPUnit trouxe 26 pacotes que ele usa (comparar valores, mostrar diferenças, medir tempo…). Todos vão para a pasta vendor/.
Using version ^13.4
A versão escolhida: PHPUnit 13 (exige PHP 8.4 ou mais novo). O ^ aceita atualizações 13.x.
Composer could not detect the root package version
Um aviso inofensivo: o projeto da Ana não está num repositório Git nem tem o campo "version", então o Composer supôs 1.0.0. Pode ignorar.

O composer.json ganhou a seção nova:

{
    "name": "ana/lojinha",
    "autoload": {
        "psr-4": {
            "Loja\\": "src/"
        }
    },
    "require-dev": {
        "phpunit/phpunit": "^13.4"
    }
}

E o programa do PHPUnit ficou em vendor/bin/phpunit. Ele não fica "instalado no sistema": cada projeto tem o seu, na versão que o projeto pediu.


3. O primeiro teste

<?php

declare(strict_types=1);

use Loja\Carrinho;
use PHPUnit\Framework\TestCase;

final class CarrinhoTest extends TestCase
{
    public function testCarrinhoNovoEstaVazio(): void
    {
        $carrinho = new Carrinho();

        $this->assertSame(0, $carrinho->quantidadeDeItens());
        $this->assertSame(0.0, $carrinho->total());
    }

    public function testTotalSomaPrecoVezesQuantidade(): void
    {
        $carrinho = new Carrinho();
        $carrinho->adicionar('Caderno', 25.50, 2);
        $carrinho->adicionar('Caneta', 3.90, 10);

        $this->assertSame(90.0, $carrinho->total());
    }
}
use PHPUnit\Framework\TestCase;
Importa a classe base dos testes (veja Namespaces).
final class CarrinhoTest extends TestCase
Uma classe de teste herda de TestCase. Por convenção, o nome é o da classe testada + Test, e o arquivo tem o mesmo nome: CarrinhoTest.php.
public function testCarrinhoNovoEstaVazio(): void
Cada método público cujo nome começa com test é um teste. O resto do nome descreve o que se confere, como uma frase.
$carrinho = new Carrinho();
Preparar. Cada teste cria o seu carrinho do zero: um teste nunca depende de outro.
$carrinho->adicionar(…)
Agir.
$this->assertSame(esperado, obtido)
Conferir. "Afirme que são idênticos": passa se esperado === obtido (mesmo valor e mesmo tipo). Primeiro o valor esperado, depois o obtido.

Rodando: vendor/bin/phpunit

$ vendor/bin/phpunit tests/CarrinhoTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8

..                                                                  2 / 2 (100%)

Time: 00:00.001, Memory: 20.00 MB

OK (2 tests, 3 assertions)
$ echo $?
0
LinhaSignifica
Runtime: PHP 8.5.8A versão do PHP que rodou os testes.
..Um caractere por teste: . passou, F falhou (failure, a conferência deu diferente), E erro (error, o código lançou uma exceção inesperada).
2 / 2 (100%)Quantos testes já rodaram, do total.
OK (2 tests, 3 assertions)Tudo passou: 2 testes, 3 conferências (o primeiro teste tem duas).
echo $? → 0O código de saída 0 significa "tudo certo". Ferramentas de publicação automática usam isso para bloquear uma versão com testes quebrados.

No terminal de verdade, o OK aparece com fundo verde.

Quando um teste falha

A Ana acrescentou um teste para um caso que ela tinha medo de quebrar: adicionar o mesmo produto duas vezes deve somar as quantidades.

    public function testAdicionarOMesmoProdutoSomaAsQuantidades(): void
    {
        $carrinho = new Carrinho();
        $carrinho->adicionar('Caneta', 3.90, 2);
        $carrinho->adicionar('Caneta', 3.90, 3);

        $this->assertSame(5, $carrinho->quantidadeDeItens());
    }

Para ver o que acontece numa falha, ela "quebrou" o carrinho de propósito, trocando += por = no adicionar (a segunda vez substitui a quantidade em vez de somar):

            $this->itens[$produto]['quantidade'] = $quantidade;
$ vendor/bin/phpunit tests/CarrinhoTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8

..F                                                                 3 / 3 (100%)

Time: 00:00.003, Memory: 20.00 MB

There was 1 failure:

1) CarrinhoTest::testAdicionarOMesmoProdutoSomaAsQuantidades
Failed asserting that 3 is identical to 5.

/home/ana/lojinha/tests/CarrinhoTest.php:33

FAILURES!
Tests: 3, Assertions: 4, Failures: 1.
$ echo $?
1
..F
Os dois primeiros passaram; o terceiro falhou.
1) CarrinhoTest::testAdicionarOMesmoProdutoSomaAsQuantidades
Qual teste falhou. Um nome descritivo já diz o que quebrou, antes mesmo de ler o código.
Failed asserting that 3 is identical to 5.
"Falhou ao afirmar que 3 é idêntico a 5": o carrinho disse 3 (só a última quantidade), o teste esperava 5.
CarrinhoTest.php:33
A linha do assertSame que falhou.
FAILURES! / echo $? → 1
Fundo vermelho no terminal e código de saída 1.

Com o += de volta, os três passam: OK (3 tests, 4 assertions). É esse o valor do teste: se daqui a seis meses alguém mexer no adicionar e cometer esse engano, o PHPUnit avisa na hora.

Missão: como o PHPUnit não roda no editor, esta caixa tem um "mini PHPUnit": a função verificarIgual compara com ===, como o assertSame, e mostra ✔ ou ✘. O carrinho está com o defeito do =. Rode, leia a mensagem do teste que falhou e conserte o método adicionar até aparecer 3 de 3 testes passaram.

<?php

declare(strict_types=1);

final class Carrinho
{
    private array $itens = [];

    public function adicionar(string $produto, float $preco, int $quantidade = 1): void
    {
        if ($quantidade < 1) {
            throw new InvalidArgumentException("Quantidade inválida: $quantidade");
        }
        if (isset($this->itens[$produto])) {
            $this->itens[$produto]['quantidade'] = $quantidade;
        } else {
            $this->itens[$produto] = ['preco' => $preco, 'quantidade' => $quantidade];
        }
    }

    public function quantidadeDeItens(): int
    {
        return array_sum(array_column($this->itens, 'quantidade'));
    }

    public function total(): float
    {
        $total = 0.0;
        foreach ($this->itens as $item) {
            $total += $item['preco'] * $item['quantidade'];
        }
        return round($total, 2);
    }
}

// Um "mini PHPUnit": compara com === e conta os resultados
$resultados = [];

function verificarIgual(mixed $esperado, mixed $obtido, string $teste): bool
{
    if ($esperado === $obtido) {
        echo "✔ $teste\n";
        return true;
    }
    echo "✘ $teste: esperava ", var_export($esperado, true), ', veio ', var_export($obtido, true), "\n";
    return false;
}

// Teste 1
$carrinho = new Carrinho();
$resultados[] = verificarIgual(0, $carrinho->quantidadeDeItens(), 'carrinho novo está vazio');

// Teste 2
$carrinho = new Carrinho();
$carrinho->adicionar('Caderno', 25.50, 2);
$carrinho->adicionar('Caneta', 3.90, 10);
$resultados[] = verificarIgual(90.0, $carrinho->total(), 'total soma preço × quantidade');

// Teste 3
$carrinho = new Carrinho();
$carrinho->adicionar('Caneta', 3.90, 2);
$carrinho->adicionar('Caneta', 3.90, 3);
$resultados[] = verificarIgual(5, $carrinho->quantidadeDeItens(), 'adicionar o mesmo produto soma as quantidades');

$passaram = count(array_filter($resultados));
echo "\n$passaram de ", count($resultados), " testes passaram\n";
<?php

declare(strict_types=1);

final class Carrinho
{
    private array $itens = [];

    public function adicionar(string $produto, float $preco, int $quantidade = 1): void
    {
        if ($quantidade < 1) {
            throw new InvalidArgumentException("Quantidade inválida: $quantidade");
        }
        if (isset($this->itens[$produto])) {
            $this->itens[$produto]['quantidade'] += $quantidade;
        } else {
            $this->itens[$produto] = ['preco' => $preco, 'quantidade' => $quantidade];
        }
    }

    public function quantidadeDeItens(): int
    {
        return array_sum(array_column($this->itens, 'quantidade'));
    }

    public function total(): float
    {
        $total = 0.0;
        foreach ($this->itens as $item) {
            $total += $item['preco'] * $item['quantidade'];
        }
        return round($total, 2);
    }
}

// Um "mini PHPUnit": compara com === e conta os resultados
$resultados = [];

function verificarIgual(mixed $esperado, mixed $obtido, string $teste): bool
{
    if ($esperado === $obtido) {
        echo "✔ $teste\n";
        return true;
    }
    echo "✘ $teste: esperava ", var_export($esperado, true), ', veio ', var_export($obtido, true), "\n";
    return false;
}

// Teste 1
$carrinho = new Carrinho();
$resultados[] = verificarIgual(0, $carrinho->quantidadeDeItens(), 'carrinho novo está vazio');

// Teste 2
$carrinho = new Carrinho();
$carrinho->adicionar('Caderno', 25.50, 2);
$carrinho->adicionar('Caneta', 3.90, 10);
$resultados[] = verificarIgual(90.0, $carrinho->total(), 'total soma preço × quantidade');

// Teste 3
$carrinho = new Carrinho();
$carrinho->adicionar('Caneta', 3.90, 2);
$carrinho->adicionar('Caneta', 3.90, 3);
$resultados[] = verificarIgual(5, $carrinho->quantidadeDeItens(), 'adicionar o mesmo produto soma as quantidades');

$passaram = count(array_filter($resultados));
echo "\n$passaram de ", count($resultados), " testes passaram\n";

No computador da Ana, a versão com defeito mostra:

$ php mini.php
✔ carrinho novo está vazio
✔ total soma preço × quantidade
✘ adicionar o mesmo produto soma as quantidades: esperava 5, veio 3

2 de 3 testes passaram

4. Testando exceções: expectException

Às vezes o comportamento certo é dar erro: adicionar 0 canetas deve lançar uma exceção. Para testar isso, avise o PHPUnit antes de agir:

    public function testQuantidadeZeroLancaExcecao(): void
    {
        $carrinho = new Carrinho();

        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Quantidade inválida: 0');

        $carrinho->adicionar('Caneta', 3.90, 0);
    }

    public function testPrecoNegativoLancaExcecao(): void
    {
        $carrinho = new Carrinho();

        $this->expectException(InvalidArgumentException::class);

        $carrinho->adicionar('Caneta', -3.90);
    }
expectException(InvalidArgumentException::class)
"Espere uma exceção deste tipo." O teste passa se ela for lançada e falha se o código terminar normalmente. ::class dá o nome completo da classe como texto.
expectExceptionMessage('…')
Opcional: confere também se a mensagem contém esse texto.
a última linha
A ação que deve lançar a exceção vem depois do expect. O que estiver depois dela no método não roda (a exceção interrompe).

O primeiro passa: o Carrinho já recusa quantidade 0. E o segundo? O --filter roda só os testes cujo nome contém o texto dado:

$ vendor/bin/phpunit --filter testPrecoNegativo tests/CarrinhoTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8

F                                                                   1 / 1 (100%)

Time: 00:00.003, Memory: 20.00 MB

There was 1 failure:

1) CarrinhoTest::testPrecoNegativoLancaExcecao
Failed asserting that exception of type "InvalidArgumentException" is thrown.

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

"Falhou ao afirmar que uma exceção do tipo InvalidArgumentException é lançada." O teste encontrou um defeito de verdade: o carrinho aceita preço negativo! A Ana acrescentou a validação no adicionar, logo depois da conferência da quantidade:

        if ($preco < 0) {
            throw new InvalidArgumentException("Preço inválido: $preco");
        }

Repare na ordem em que isso aconteceu: primeiro o teste (que falhou), depois o código (que fez o teste passar). Escrever o teste antes do código tem nome: TDD (Test-Driven Development, "desenvolvimento guiado por testes"). Não é obrigatório, mas é uma ótima forma de pensar no que o código deve fazer antes de fazê-lo.


5. Vários casos de uma vez: data providers

Para testar o cupom, a Ana quer conferir três situações: sem cupom, PROMO10 e METADE. Escrever três métodos quase iguais seria repetitivo. Um data provider ("fornecedor de dados") é um método que devolve uma lista de casos; o PHPUnit roda o mesmo teste uma vez para cada caso.

<?php

declare(strict_types=1);

use Loja\Carrinho;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class CarrinhoTest extends TestCase
{
    // … os testes anteriores …

    #[DataProvider('cupons')]
    public function testTotalComCupom(string $cupom, float $esperado): void
    {
        $carrinho = new Carrinho();
        $carrinho->adicionar('Caderno', 25.50, 2);
        $carrinho->adicionar('Caneta', 3.90, 10);

        $this->assertSame($esperado, $carrinho->totalComCupom($cupom));
    }

    public static function cupons(): array
    {
        return [
            'sem cupom'       => ['', 90.0],
            'dez por cento'   => ['PROMO10', 81.0],
            'metade do preço' => ['METADE', 40.0],
        ];
    }
}
use PHPUnit\Framework\Attributes\DataProvider;
Importa o atributo DataProvider.
#[DataProvider('cupons')]
Diz: "os dados deste teste vêm do método cupons". Em versões antigas do PHPUnit, isso era um comentário @dataProvider; no PHPUnit 12 e 13, use o atributo.
testTotalComCupom(string $cupom, float $esperado)
O teste agora recebe parâmetros: um caso por vez.
public static function cupons(): array
O provider precisa ser public e static. Cada item é um caso: a chave é o nome do caso (aparece nos relatórios) e o valor é a lista de argumentos, na ordem dos parâmetros.

Repare no último caso: a Ana fez a conta de cabeça e escreveu 40,00 para o cupom METADE. Rodando só esse teste:

$ vendor/bin/phpunit --filter testTotalComCupom tests/CarrinhoTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8

..F                                                                 3 / 3 (100%)

Time: 00:00.003, Memory: 20.00 MB

There was 1 failure:

1) CarrinhoTest::testTotalComCupom@metade do preço with data ('METADE', 40.0)
Failed asserting that 45.0 is identical to 40.0.

/home/ana/lojinha/tests/CarrinhoTest.php:63

FAILURES!
Tests: 3, Assertions: 3, Failures: 1.

Um método, três testes (3 / 3). A falha diz exatamente qual caso quebrou (@metade do preço) e com quais dados. Metade de R$ 90,00 é R$ 45,00: dessa vez o erro estava no teste, não no código. Acontece; o PHPUnit só compara, quem decide quem está certo é você. Com 45.0 no lugar de 40.0, tudo passa.


6. O phpunit.xml e o --testdox

Digitar tests/CarrinhoTest.php toda vez cansa, e um projeto real tem dezenas de arquivos de teste. Um arquivo phpunit.xml na raiz do projeto diz ao PHPUnit onde procurar:

<?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">
    <testsuites>
        <testsuite name="lojinha">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
bootstrap="vendor/autoload.php"
Carrega o autoload do Composer antes dos testes (é o que faz new Carrinho() encontrar src/Carrinho.php).
colors="true"
Verde para sucesso, vermelho para falha.
<directory>tests</directory>
Roda todos os arquivos *Test.php da pasta tests.

Agora basta vendor/bin/phpunit. E com --testdox, o PHPUnit transforma os nomes dos métodos em frases, uma lista de conferência legível até por quem não programa:

$ vendor/bin/phpunit
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8
Configuration: /home/ana/lojinha/phpunit.xml

........                                                            8 / 8 (100%)

Time: 00:00.003, Memory: 20.00 MB

OK (8 tests, 10 assertions)
$ vendor/bin/phpunit --testdox
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8
Configuration: /home/ana/lojinha/phpunit.xml

........                                                            8 / 8 (100%)

Time: 00:00.003, Memory: 20.00 MB

Carrinho
 ✔ Carrinho novo esta vazio
 ✔ Total soma preco vezes quantidade
 ✔ Adicionar o mesmo produto soma as quantidades
 ✔ Quantidade zero lanca excecao
 ✔ Preco negativo lanca excecao
 ✔ Total com cupom with sem·cupom
 ✔ Total com cupom with dez·por·cento
 ✔ Total com cupom with metade·do·preço

OK (8 tests, 10 assertions)

Oito testes (5 métodos comuns + 3 casos do provider) e 10 conferências. A linha Configuration: mostra que o phpunit.xml foi usado. Nos casos do provider, o PHPUnit mostra os espaços do nome como ·. Por isso vale a pena nomear os testes como frases: testCarrinhoNovoEstaVazio vira "Carrinho novo esta vazio" (os nomes de métodos não podem ter acento, então a frase também fica sem).


As assertions mais usadas

AssertionPassa quando…
assertSame($esperado, $obtido)$esperado === $obtido. A que você mais vai usar.
assertEquals($esperado, $obtido)$esperado == $obtido (aceita tipos diferentes: 51 e 51.0).
assertEqualsWithDelta(0.3, $obtido, 0.0001)A diferença é menor que a margem. Para contas com float (0.1 + 0.2 não é exatamente 0.3).
assertTrue($x) / assertFalse($x)$x === true / $x === false.
assertNull($x)$x === null.
assertCount(3, $lista)A lista tem 3 elementos.
assertContains('Caneta', $lista)O valor está na lista.
assertArrayHasKey('preco', $item)O array tem a chave.
assertStringContainsString('R$', $texto)O texto contém o pedaço.
assertInstanceOf(Carrinho::class, $obj)O objeto é daquela classe.
expectException(Classe::class)(Antes da ação.) A ação lança essa exceção.

Formas diferentes de organizar

  • Um teste, uma ideia. Prefira vários testes pequenos com nomes claros a um teste gigante: quando falhar, o nome já diz o que quebrou.
  • setUp(): um método protected function setUp(): void roda antes de cada teste. Use para a preparação repetida (por exemplo, $this->carrinho = new Carrinho();, guardado numa propriedade).
  • Data provider quando o teste é o mesmo e só mudam os dados (como os cupons).
  • O que testar: o caso comum, os limites (0, 1, vazio, o maior valor permitido) e os erros esperados (exceções). Não precisa testar o PHP (que array_sum soma, por exemplo); teste as suas regras.

Quando dá errado

"bash: phpunit: comando não encontrado"
$ phpunit
bash: phpunit: comando não encontrado

O que significa: o PHPUnit não está instalado no sistema; ele mora dentro do projeto.

Como consertar: rode vendor/bin/phpunit, de dentro da pasta do projeto. Se a pasta vendor não existir (projeto recém-baixado), rode antes composer install.

"Failed asserting that 51.0 is identical to 51."
        $carrinho->adicionar('Caderno', 25.50, 2);

        $this->assertSame(51, $carrinho->total());
$ vendor/bin/phpunit tests/TiposTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8
Configuration: /home/ana/lojinha/phpunit.xml

F                                                                   1 / 1 (100%)

Time: 00:00.002, Memory: 20.00 MB

There was 1 failure:

1) TiposTest::testTotalDeDoisCadernos
Failed asserting that 51.0 is identical to 51.

/home/ana/lojinha/tests/TiposTest.php:15

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

O que significa: o valor está "certo", mas o tipo não: total() devolve float (51.0) e o teste esperava int (51). O assertSame usa ===, então tipo conta.

Como consertar: escreva o valor esperado com o tipo certo: 51.0. (O assertEquals(51, …) passaria, mas esconderia o tipo; prefira acertar o tipo.)

"Failed asserting that exception of type … is thrown"

Você viu esse acima. O que significa: o teste esperava uma exceção (expectException) e o código terminou sem lançar nenhuma. Ou o código não valida aquele caso (foi o preço negativo), ou a ação veio antes do expectException no método.

Como consertar: confira a ordem (expect… primeiro, ação por último) e, se a ordem estiver certa, implemente a validação que falta.

"There was 1 error" (E em vez de F)
        $this->assertSame(45.9, $carrinho->totalComCupom('promo10'));
$ vendor/bin/phpunit tests/ErroTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8
Configuration: /home/ana/lojinha/phpunit.xml

E                                                                   1 / 1 (100%)

Time: 00:00.002, Memory: 20.00 MB

There was 1 error:

1) ErroTest::testCupomEmMinusculas
InvalidArgumentException: Cupom desconhecido: promo10

/home/ana/lojinha/src/Carrinho.php:50
/home/ana/lojinha/tests/ErroTest.php:15

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

O que significa: E é diferente de F. O teste nem chegou a conferir (Assertions: 0): o código lançou uma exceção que ninguém esperava (promo10 em minúsculas não é um cupom conhecido). As duas últimas linhas mostram onde a exceção nasceu (Carrinho.php:50) e de onde veio a chamada (ErroTest.php:15).

Como consertar: decida o comportamento certo. Se cupom em minúsculas deve funcionar, mude o código (por exemplo, strtoupper($cupom)). Se deve ser recusado, mude o teste para expectException.

"No tests found in class"
    public function carrinhoNovoEstaVazio(): void
$ vendor/bin/phpunit tests/NomeTest.php
PHPUnit 13.4.3 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.8
Configuration: /home/ana/lojinha/phpunit.xml

There was 1 PHPUnit test runner warning:

1) No tests found in class "NomeTest".

No tests executed!

O que significa: o método não começa com test, então o PHPUnit não o reconhece como teste. O mesmo acontece se ele for private ou protected.

Como consertar: renomeie para testCarrinhoNovoEstaVazio (ou marque o método com o atributo #[Test], de PHPUnit\Framework\Attributes\Test).

"Class "TestCase" not found"
$ vendor/bin/phpunit tests/SemUseTest.php


An error occurred inside PHPUnit.

Message:  Class "TestCase" not found
Location: /home/ana/lojinha/tests/SemUseTest.php:7

#0 /home/ana/lojinha/vendor/phpunit/phpunit/src/Runner/TestSuiteLoader.php(134): require_once()
«… mais 10 linhas do rastro, todas dentro de vendor/phpunit …»

O que significa: faltou a linha use PHPUnit\Framework\TestCase;. Sem ela, o PHP procura uma classe chamada só TestCase, que não existe.

Como consertar: acrescente o use no topo do arquivo de teste. O mesmo vale para use Loja\Carrinho; (o erro seria Class "Carrinho" not found).


Referência rápida

Comando / códigoPara quê
composer require --dev phpunit/phpunitInstalar o PHPUnit no projeto
vendor/bin/phpunitRodar todos os testes (com phpunit.xml)
vendor/bin/phpunit tests/CarrinhoTest.phpRodar um arquivo
vendor/bin/phpunit --filter nomeDoTesteRodar só os testes cujo nome contém o texto
vendor/bin/phpunit --testdoxRelatório em frases
final class XTest extends TestCaseClasse de teste
public function testAlgumaCoisa(): voidUm teste
$this->assertSame($esperado, $obtido)Conferir (com ===)
$this->expectException(X::class)Esperar uma exceção (antes da ação)
#[DataProvider('metodo')] + public static function metodo(): arrayMesmo teste, vários casos
protected function setUp(): voidPreparação que roda antes de cada teste
. / F / EPassou / falhou a conferência / exceção inesperada

Exercícios

1. Onde mora o PHPUnit

Complete o comando que roda os testes do projeto:

2. A classe base

Complete a declaração da classe de teste:

final class CarrinhoTest extends

3. Conferir com ===

Complete a assertion que compara valor e tipo:

$this->(5, $carrinho->quantidadeDeItens());

4. Na saída ..F.E, o que aconteceu?

5. Onde fica o $this->expectException(...) num teste de exceção?

6. Por que o PHPUnit é instalado com --dev?

Missão final: o carrinho já está consertado e tem o método totalComCupom. Escreva mais dois testes no lugar indicado: (4) com 2 cadernos de 25,50 e 10 canetas de 3,90, o cupom PROMO10 deve dar 81.0; (5) adicionar uma caneta com quantidade 0 deve lançar InvalidArgumentException com a mensagem Quantidade inválida: 0. Sem PHPUnit, o "expectException" se faz com try/catch: chame o método no try e, no catch (InvalidArgumentException $e), confira $e->getMessage(). A meta é 5 de 5 testes passaram.

<?php

declare(strict_types=1);

final class Carrinho
{
    private array $itens = [];

    public function adicionar(string $produto, float $preco, int $quantidade = 1): void
    {
        if ($quantidade < 1) {
            throw new InvalidArgumentException("Quantidade inválida: $quantidade");
        }
        if (isset($this->itens[$produto])) {
            $this->itens[$produto]['quantidade'] += $quantidade;
        } else {
            $this->itens[$produto] = ['preco' => $preco, 'quantidade' => $quantidade];
        }
    }

    public function quantidadeDeItens(): int
    {
        return array_sum(array_column($this->itens, 'quantidade'));
    }

    public function total(): float
    {
        $total = 0.0;
        foreach ($this->itens as $item) {
            $total += $item['preco'] * $item['quantidade'];
        }
        return round($total, 2);
    }

    public function totalComCupom(string $cupom): float
    {
        $desconto = match ($cupom) {
            'PROMO10' => 0.10,
            'METADE'  => 0.50,
            ''        => 0.0,
            default   => throw new InvalidArgumentException("Cupom desconhecido: $cupom"),
        };
        return round($this->total() * (1 - $desconto), 2);
    }
}

// Um "mini PHPUnit": compara com === e conta os resultados
$resultados = [];

function verificarIgual(mixed $esperado, mixed $obtido, string $teste): bool
{
    if ($esperado === $obtido) {
        echo "✔ $teste\n";
        return true;
    }
    echo "✘ $teste: esperava ", var_export($esperado, true), ', veio ', var_export($obtido, true), "\n";
    return false;
}

// Teste 1
$carrinho = new Carrinho();
$resultados[] = verificarIgual(0, $carrinho->quantidadeDeItens(), 'carrinho novo está vazio');

// Teste 2
$carrinho = new Carrinho();
$carrinho->adicionar('Caderno', 25.50, 2);
$carrinho->adicionar('Caneta', 3.90, 10);
$resultados[] = verificarIgual(90.0, $carrinho->total(), 'total soma preço × quantidade');

// Teste 3
$carrinho = new Carrinho();
$carrinho->adicionar('Caneta', 3.90, 2);
$carrinho->adicionar('Caneta', 3.90, 3);
$resultados[] = verificarIgual(5, $carrinho->quantidadeDeItens(), 'adicionar o mesmo produto soma as quantidades');

// Escreva aqui os testes 4 e 5

$passaram = count(array_filter($resultados));
echo "\n$passaram de ", count($resultados), " testes passaram\n";
<?php

declare(strict_types=1);

final class Carrinho
{
    private array $itens = [];

    public function adicionar(string $produto, float $preco, int $quantidade = 1): void
    {
        if ($quantidade < 1) {
            throw new InvalidArgumentException("Quantidade inválida: $quantidade");
        }
        if (isset($this->itens[$produto])) {
            $this->itens[$produto]['quantidade'] += $quantidade;
        } else {
            $this->itens[$produto] = ['preco' => $preco, 'quantidade' => $quantidade];
        }
    }

    public function quantidadeDeItens(): int
    {
        return array_sum(array_column($this->itens, 'quantidade'));
    }

    public function total(): float
    {
        $total = 0.0;
        foreach ($this->itens as $item) {
            $total += $item['preco'] * $item['quantidade'];
        }
        return round($total, 2);
    }

    public function totalComCupom(string $cupom): float
    {
        $desconto = match ($cupom) {
            'PROMO10' => 0.10,
            'METADE'  => 0.50,
            ''        => 0.0,
            default   => throw new InvalidArgumentException("Cupom desconhecido: $cupom"),
        };
        return round($this->total() * (1 - $desconto), 2);
    }
}

// Um "mini PHPUnit": compara com === e conta os resultados
$resultados = [];

function verificarIgual(mixed $esperado, mixed $obtido, string $teste): bool
{
    if ($esperado === $obtido) {
        echo "✔ $teste\n";
        return true;
    }
    echo "✘ $teste: esperava ", var_export($esperado, true), ', veio ', var_export($obtido, true), "\n";
    return false;
}

// Teste 1
$carrinho = new Carrinho();
$resultados[] = verificarIgual(0, $carrinho->quantidadeDeItens(), 'carrinho novo está vazio');

// Teste 2
$carrinho = new Carrinho();
$carrinho->adicionar('Caderno', 25.50, 2);
$carrinho->adicionar('Caneta', 3.90, 10);
$resultados[] = verificarIgual(90.0, $carrinho->total(), 'total soma preço × quantidade');

// Teste 3
$carrinho = new Carrinho();
$carrinho->adicionar('Caneta', 3.90, 2);
$carrinho->adicionar('Caneta', 3.90, 3);
$resultados[] = verificarIgual(5, $carrinho->quantidadeDeItens(), 'adicionar o mesmo produto soma as quantidades');

// Teste 4
$carrinho = new Carrinho();
$carrinho->adicionar('Caderno', 25.50, 2);
$carrinho->adicionar('Caneta', 3.90, 10);
$resultados[] = verificarIgual(81.0, $carrinho->totalComCupom('PROMO10'), 'PROMO10 dá 10% de desconto');

// Teste 5
$carrinho = new Carrinho();
try {
    $carrinho->adicionar('Caneta', 3.90, 0);
    $resultados[] = verificarIgual('exceção', 'nada', 'quantidade zero lança exceção');
} catch (InvalidArgumentException $e) {
    $resultados[] = verificarIgual('Quantidade inválida: 0', $e->getMessage(), 'quantidade zero lança exceção');
}

$passaram = count(array_filter($resultados));
echo "\n$passaram de ", count($resultados), " testes passaram\n";

Resumo

  • Um teste automatizado confere o seu código por você: Preparar, Agir, Conferir.
  • Instale com composer require --dev phpunit/phpunit e rode com vendor/bin/phpunit.
  • Classe XTest extends TestCase; métodos públicos começando com test; $this->assertSame($esperado, $obtido).
  • Saída: . passou, F falhou, E erro; OK (…) ou FAILURES!, com o nome do teste, a diferença e a linha.
  • expectException antes da ação para testar erros esperados; #[DataProvider] + método public static para vários casos.
  • phpunit.xml para rodar tudo com um comando; --testdox para ler os testes como frases; --filter para rodar só alguns.
  • Quando um teste falha, pode ser o código ou o teste que está errado: o PHPUnit só compara, a decisão é sua.