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.
- 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) comassertSame. - Rodar com
vendor/bin/phpunite 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.xmle ver os testes como frases com--testdox.
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:
- Preparar: criar o que o teste precisa (um carrinho, uns produtos).
- Agir: chamar o método que está sendo testado.
- 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"nocomposer.json. No servidor de produção,composer install --no-devdeixa 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ôs1.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
| Linha | Significa |
|---|---|
Runtime: PHP 8.5.8 | A 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 $? → 0 | O 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
assertSameque 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.
::classdá 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
publicestatic. 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()encontrarsrc/Carrinho.php). - colors="true"
- Verde para sucesso, vermelho para falha.
- <directory>tests</directory>
- Roda todos os arquivos
*Test.phpda pastatests.
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
| Assertion | Passa 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étodoprotected function setUp(): voidroda 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_sumsoma, por exemplo); teste as suas regras.
Quando dá errado
$ 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.
$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.)
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.
$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.
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).
$ 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ódigo | Para quê |
|---|---|
composer require --dev phpunit/phpunit | Instalar o PHPUnit no projeto |
vendor/bin/phpunit | Rodar todos os testes (com phpunit.xml) |
vendor/bin/phpunit tests/CarrinhoTest.php | Rodar um arquivo |
vendor/bin/phpunit --filter nomeDoTeste | Rodar só os testes cujo nome contém o texto |
vendor/bin/phpunit --testdox | Relatório em frases |
final class XTest extends TestCase | Classe de teste |
public function testAlgumaCoisa(): void | Um 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(): array | Mesmo teste, vários casos |
protected function setUp(): void | Preparação que roda antes de cada teste |
. / F / E | Passou / 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:
3. Conferir com ===
Complete a assertion que compara valor e tipo:
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/phpunite rode comvendor/bin/phpunit. - Classe
XTest extends TestCase; métodos públicos começando comtest;$this->assertSame($esperado, $obtido). - Saída:
.passou,Ffalhou,Eerro;OK (…)ouFAILURES!, com o nome do teste, a diferença e a linha. expectExceptionantes da ação para testar erros esperados;#[DataProvider]+ métodopublic staticpara vários casos.phpunit.xmlpara rodar tudo com um comando;--testdoxpara ler os testes como frases;--filterpara 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.