Testes Automatizados no Laravel
Testes são a "rede de segurança" do Laravel — executá-los toda vez que você faz uma alteração de código garante que bugs sejam detectados imediatamente e não cheguem ao ambiente de produção.
1. O Que Você Vai Aprender
- Configuração do ambiente de testes: phpunit.xml e .env.testing
- Testes Unitários: Teste isolado de métodos Model e classes Service
- Testes Funcionais: Teste de Requisições HTTP e Trait de Transações de Banco de Dados
- Testes de API: Autenticação Sanctum Token + JSON Assertions
- Cobertura de Testes: Relatórios de Cobertura Integrados com CI
2. Uma História Real de um Desenvolvedor em Plantão Noturno
(1) Dor: Bugs ocorrem toda vez que fazemos deploy
Bob fez deploy de uma nova versão do ShopMetrics na sexta-feira à noite — alterou a lógica de desconto de pedidos, testou manualmente alguns cenários para garantir que tudo estava funcionando e publicou. Acontece que, na segunda-feira de manhã, Alice relatou que descontos negativos haviam causado totais de pedidos negativos, resultando em mais de 100 usuários recebendo seus itens de graça. Charlie disse: "Se você tivesse testes automatizados, esse bug teria sido detectado quando o código fosse submetido."
(2) Soluções para Testes Automatizados
Testes automatizados são executados automaticamente a cada alteração de código — a lógica de desconto é totalmente coberta por testes, e casos de teste para descontos negativos falham imediatamente, garantindo que bugs nunca cheguem à produção.
// Testar que desconto negativo é rejeitado
test('order rejects negative discount', function () {
$response = $this->postJson('/api/v1/orders', [
'items' => [['product_id' => 1, 'quantity' => 1]],
'discount' => -10, // Deve ser rejeitado!
]);
$response->assertJsonValidationErrors('discount');
});
(3) Resultado
Depois que Bob adicionou os testes, o bug envolvendo descontos negativos foi detectado durante o desenvolvimento, e Alice nunca mais enfrentou um incidente de "compras grátis".
3. Configuração do Ambiente de Testes
(1) phpunit.xml
<!-- phpunit.xml -->
<phpunit>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
(2) .env.testing
# .env.testing — ambiente de teste dedicado
APP_ENV=testing
APP_KEY=base64:test-key-for-testing-only
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array
(3) A Pirâmide de Testes
graph TD
A["Testes Unitários<br/>(Rápidos, muitos)<br/>Métodos Model, lógica Service"] --> B["Testes Funcionais<br/>(Médios, alguns)<br/>Requisições HTTP, consultas DB"]
B --> C["Testes API/E2E<br/>(Lentos, poucos)<br/>Ciclo completo de requisição"]
| Nível | Quantidade | Velocidade | Conteúdo do Teste |
|---|---|---|---|
| Unitário | Muitos | Rápido (ms) | Lógica Pura, Métodos Model |
| Funcional | Médio | Médio (100 ms) | Requisições HTTP, operações DB |
| API/E2E | Poucos | Lento (s) | Cadeia completa de requisição |
(1) ▶ Exemplo: Configuração do Ambiente de Testes do ShopMetrics
# Criar .env.testing
cp .env .env.testing
# Editar .env.testing
# DB_CONNECTION=sqlite
# DB_DATABASE=:memory:
# QUEUE_CONNECTION=sync
# Executar testes
php artisan test # Todos os testes
php artisan test --parallel # Paralelo (mais rápido)
php artisan test --coverage # Com relatório de cobertura
Saída:
# Comando executado com sucesso
4. Testes Unitários
(1) PHPUnit vs. Pest
| Dimensão | PHPUnit | Pest |
|---|---|---|
| Sintaxe | Classe + Método | Funcional |
| Código de Exemplo | Mais | Menos |
| Legibilidade | Médio | ✅ Alta |
| Compatibilidade | 100% | Baseado em PHPUnit |
| Adequado para | Testes complexos | Simples e rápido |
(2) Estilo PHPUnit
// tests/Unit/Models/ShopTest.php
class ShopTest extends TestCase
{
public function test_shop_generates_slug(): void
{
$shop = Shop::factory()->make(['name' => 'Alice Store']);
$this->assertEquals('alice-store', Str::slug($shop->name));
}
public function test_active_scope_filters_active_shops(): void
{
Shop::factory()->create(['status' => 'active']);
Shop::factory()->create(['status' => 'suspended']);
$activeShops = Shop::active()->get();
$this->assertCount(1, $activeShops);
$this->assertEquals('active', $activeShops->first()->status);
}
public function test_revenue_formatted_accessor(): void
{
$shop = Shop::factory()->make(['revenue' => 12345.67]);
$this->assertEquals('$12,345.67', $shop->revenue_formatted);
}
}
(3) Estilo Pest
// tests/Unit/Models/ShopTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
it('generates slug from name', function () {
$shop = Shop::factory()->make(['name' => 'Alice Store']);
expect(Str::slug($shop->name))->toBe('alice-store');
});
it('filters active shops via scope', function () {
Shop::factory()->create(['status' => 'active']);
Shop::factory()->create(['status' => 'suspended']);
$active = Shop::active()->get();
expect($active)->toHaveCount(1);
expect($active->first()->status)->toBe('active');
});
it('formats revenue with currency symbol', function () {
$shop = Shop::factory()->make(['revenue' => 12345.67]);
expect($shop->revenue_formatted)->toBe('$12,345.67');
});
(1) ▶ Exemplo: Testes Unitários de Model do ShopMetrics
// tests/Unit/Models/OrderTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
it('calculates order total from items', function () {
$order = Order::factory()->create();
$order->items()->createMany([
['product_id' => 1, 'quantity' => 2, 'price' => 10.00],
['product_id' => 2, 'quantity' => 1, 'price' => 25.00],
]);
$order->updateTotal();
expect($order->fresh()->total)->toBe(45.00);
});
it('rejects invalid status transition', function () {
$order = Order::factory()->create(['status' => 'completed']);
expect(fn () => $order->update(['status' => 'pending']))
->toThrow(InvalidArgumentException::class);
});
it('scope completed returns only completed orders', function () {
Order::factory()->create(['status' => 'completed']);
Order::factory()->create(['status' => 'pending']);
Order::factory()->create(['status' => 'cancelled']);
expect(Order::completed()->count())->toBe(1);
});
Saída:
// Execução bem-sucedida
5. Testes Funcionais
(1) Teste de Requisições HTTP
// tests/Feature/ShopControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
it('displays shops list on index page', function () {
$shops = Shop::factory()->count(3)->create();
$response = $this->get(route('shops.index'));
$response->assertStatus(200);
$response->assertViewIs('shops.index');
foreach ($shops as $shop) {
$response->assertSee($shop->name);
}
});
it('creates a shop with valid data', function () {
$user = User::factory()->create(['role' => 'tenant_owner']);
$response = $this->actingAs($user)->post(route('shops.store'), [
'name' => 'New Shop',
'slug' => 'new-shop',
'description' => 'A test shop',
]);
$response->assertRedirect(route('shops.index'));
$this->assertDatabaseHas('shops', ['name' => 'New Shop']);
});
it('validates required fields on shop creation', function () {
$user = User::factory()->create(['role' => 'tenant_owner']);
$response = $this->actingAs($user)->post(route('shops.store'), []);
$response->assertSessionHasErrors(['name', 'slug']);
});
it('prevents non-owners from creating shops', function () {
$user = User::factory()->create(['role' => 'analyst']);
$response = $this->actingAs($user)->post(route('shops.store'), [
'name' => 'Unauthorized Shop',
'slug' => 'unauthorized',
]);
$response->assertForbidden();
});
(2) Assertions de Banco de Dados
| Método de Assertion | Descrição |
|---|---|
assertDatabaseHas() |
Registros existem no banco de dados |
assertDatabaseMissing() |
Nenhum registro encontrado no banco de dados |
assertDatabaseCount() |
Corresponder pelo número de registros |
assertSoftDeleted() |
Registros com soft-delete existem |
(1) ▶ Exemplo: Teste Funcional CRUD do ShopMetrics
// tests/Feature/ProductControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->owner = User::factory()->create(['role' => 'tenant_owner']);
$this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});
it('lists products for a shop', function () {
Product::factory()->count(5)->create(['shop_id' => $this->shop->id]);
$response = $this->actingAs($this->owner)
->get(route('shops.products.index', $this->shop));
$response->assertOk();
$response->assertViewHas('products');
});
it('stores a new product', function () {
Storage::fake('public');
$response = $this->actingAs($this->owner)
->post(route('products.store'), [
'shop_id' => $this->shop->id,
'name' => 'Widget Pro',
'sku' => 'WP-001',
'price' => 49.99,
'stock' => 100,
]);
$response->assertRedirect();
$this->assertDatabaseHas('products', ['sku' => 'WP-001']);
});
it('rejects negative price', function () {
$response = $this->actingAs($this->owner)
->post(route('products.store'), [
'shop_id' => $this->shop->id,
'name' => 'Bad Product',
'sku' => 'BP-001',
'price' => -10,
'stock' => 50,
]);
$response->assertSessionHasErrors('price');
});
it('deletes a product with soft delete', function () {
$product = Product::factory()->create(['shop_id' => $this->shop->id]);
$this->actingAs($this->owner)
->delete(route('products.destroy', $product));
$this->assertSoftDeleted($product);
});
Saída:
// Execução bem-sucedida
6. Testes de API
(1) Teste de Autenticação Sanctum Token
// tests/Feature/Api/ShopApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
it('requires authentication for API access', function () {
$this->getJson('/api/v1/shops')
->assertUnauthorized();
});
it('authenticates with Sanctum token', function () {
$user = User::factory()->create();
$token = $user->createToken('test-token', ['read'])->plainTextToken;
$this->withHeader('Authorization', "Bearer {$token}")
->getJson('/api/v1/shops')
->assertOk();
});
it('lists shops via API', function () {
$user = User::factory()->create();
$shops = Shop::factory()->count(3)->create(['tenant_id' => $user->tenant_id]);
$response = $this->actingAs($user)
->getJson('/api/v1/shops');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'slug', 'status', 'created_at'],
],
]);
});
it('creates shop via API', function () {
$user = User::factory()->create(['role' => 'tenant_owner']);
$response = $this->actingAs($user)
->postJson('/api/v1/shops', [
'name' => 'API Shop',
'slug' => 'api-shop',
]);
$response->assertCreated()
->assertJsonPath('data.name', 'API Shop');
});
it('rejects duplicate slug', function () {
$user = User::factory()->create(['role' => 'tenant_owner']);
Shop::factory()->create(['tenant_id' => $user->tenant_id, 'slug' => 'existing']);
$response = $this->actingAs($user)
->postJson('/api/v1/shops', [
'name' => 'Duplicate',
'slug' => 'existing',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors('slug');
});
(2) Métodos de JSON Assertion
| Método | Descrição |
|---|---|
assertJson() |
JSON contém o fragmento especificado |
assertJsonPath() |
Corresponder valores em um caminho especificado |
assertJsonStructure() |
Correspondência de Estrutura JSON |
assertJsonValidationErrors() |
Erro de validação: campo incluído |
assertJsonCount() |
Correspondência de Comprimento de Array |
assertJsonFragment() |
JSON contém um trecho |
(1) ▶ Exemplo: Suite de Testes API do ShopMetrics
// tests/Feature/Api/OrderApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->owner = User::factory()->create(['role' => 'tenant_owner']);
$this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});
it('lists orders with pagination', function () {
Order::factory()->count(25)->create([
'tenant_id' => $this->owner->tenant_id,
'shop_id' => $this->shop->id,
]);
$response = $this->actingAs($this->owner)
->getJson('/api/v1/orders?per_page=10');
$response->assertOk()
->assertJsonCount(10, 'data')
->assertJsonStructure(['meta' => ['current_page', 'total']]);
});
it('shows order with items', function () {
$order = Order::factory()->create([
'tenant_id' => $this->owner->tenant_id,
'shop_id' => $this->shop->id,
]);
$order->items()->create(['product_id' => 1, 'quantity' => 2, 'price' => 10]);
$response = $this->actingAs($this->owner)
->getJson("/api/v1/orders/{$order->id}");
$response->assertOk()
->assertJsonPath('data.order_number', $order->order_number)
->assertJsonStructure(['data' => ['items']]);
});
it('updates order status', function () {
$order = Order::factory()->create([
'tenant_id' => $this->owner->tenant_id,
'status' => 'pending',
]);
Event::fake(OrderStatusChanged::class);
$response = $this->actingAs($this->owner)
->patchJson("/api/v1/orders/{$order->id}/status", [
'status' => 'completed',
]);
$response->assertOk();
expect($order->fresh()->status)->toBe('completed');
Event::assertDispatched(OrderStatusChanged::class);
});
Saída:
// Execução bem-sucedida
7. Cobertura de Testes e CI
(1) Relatório de Cobertura
# Gerar relatório de cobertura HTML
php artisan test --coverage-html=coverage
# Limiar mínimo de cobertura
php artisan test --coverage --min=80
# Cobertura para diretório específico
php artisan test --coverage-filter=app/Services
(2) GitHub Actions CI
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: shopmetrics_test
ports:
- 3306:3306
redis:
image: redis:7
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.3
coverage: xdebug
- run: composer install --no-interaction
- run: cp .env.testing .env
- run: php artisan key:generate
- run: php artisan test --coverage --min=80
(1) ▶ Exemplo: Configuração de Testes CI do ShopMetrics
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: [8.2, 8.3]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- run: composer install
- run: php artisan test --parallel
- run: php artisan test --coverage --min=70
if: matrix.php == '8.3'
larastan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.3
- run: composer install
- run: ./vendor/bin/phpstan analyse --level=5
Saída:
CONTAINER ID IMAGE STATUS PORTS
abc123 nginx:latest Up 2 hours 0.0.0.0:80->80/tcp
8. Exemplo Compreensivo: Suite de Testes do ShopMetrics
// ============================================
// Compreensivo: Suite de Testes do ShopMetrics
// Abrange: testes unitários, funcionais, API com Pest
// ============================================
// tests/Unit/Services/OrderServiceTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);
it('calculates order total correctly', function () {
$service = app(OrderService::class);
$order = Order::factory()->create(['subtotal' => 0, 'total' => 0]);
$order->items()->createMany([
['product_id' => 1, 'quantity' => 2, 'price' => 15.00],
['product_id' => 2, 'quantity' => 3, 'price' => 10.00],
]);
$service->calculateTotal($order);
expect($order->fresh()->total)->toBe(60.00);
});
it('applies discount within limits', function () {
$service = app(OrderService::class);
$order = Order::factory()->create(['subtotal' => 100, 'discount' => 0, 'total' => 100]);
$service->applyDiscount($order, 20);
expect($order->fresh()->total)->toBe(80.00);
});
it('rejects discount exceeding subtotal', function () {
$service = app(OrderService::class);
$order = Order::factory()->create(['subtotal' => 100]);
expect(fn () => $service->applyDiscount($order, 150))
->toThrow(InvalidArgumentException::class);
});
// tests/Feature/Api/CompleteOrderFlowTest.php
it('completes full order flow via API', function () {
$user = User::factory()->create(['role' => 'tenant_owner']);
$shop = Shop::factory()->create(['tenant_id' => $user->tenant_id]);
$products = Product::factory()->count(3)->create(['shop_id' => $shop->id, 'stock' => 50]);
Event::fake();
Queue::fake();
// Criar pedido
$response = $this->actingAs($user)->postJson('/api/v1/orders', [
'shop_id' => $shop->id,
'items' => [
['product_id' => $products[0]->id, 'quantity' => 2],
['product_id' => $products[1]->id, 'quantity' => 1],
],
]);
$response->assertCreated();
$orderId = $response->json('data.id');
// Verificar pedido no banco de dados
$this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'pending']);
// Atualizar status para processamento
$this->actingAs($user)
->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'processing'])
->assertOk();
// Atualizar status para concluído
$this->actingAs($user)
->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'completed'])
->assertOk();
expect(Order::find($orderId)->status)->toBe('completed');
});
❓ Perguntas Frequentes
migrate primeiro e depois envolve cada teste em uma transação (para rollback mais rápido); DatabaseMigrations executa migrate antes de cada teste e rollback após cada teste. RefreshDatabase é mais rápido e é recomendado.Http::fake() para simular respostas HTTP: Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])]). Use Mail::fake() para email, Event::fake() para eventos e Queue::fake() para filas.php artisan test --parallel=4 Execute-os em paralelo usando 4 processos. Cada teste deve ser independente (não dependente de dados de outros testes) e use um banco de dados SQLite em memória para evitar contenção de banco de dados.Storage::fake('disk') para simular o sistema de arquivos e UploadedFile::fake()->create('test.jpg') para criar um arquivo fictício. O arquivo fictício é limpo automaticamente após o teste.📖 Resumo
- O ambiente de testes usa um banco de dados SQLite em memória, que oferece a performance mais rápida
- Testes unitários cobrem lógica pura (Model/Service), enquanto testes funcionais cobrem requisições HTTP
- Pest tem uma sintaxe concisa e é compatível com PHPUnit no núcleo
- Testes de API usando
actingAs()/ Bearer Token + JSON Assertions - Event::fake()/Queue::fake() isola efeitos colaterais
- CI executa testes automaticamente, com um limiar de cobertura de código de 70-80%
📝 Exercícios
-
Exercício Básico (⭐): Escreva três testes unitários para o model Shop (active scope, revenue_formatted accessor e geração de slug) usando sintaxe Pest e garanta que todos passem.
-
Exercício Avançado (⭐⭐): Escreva testes funcionais para as operações CRUD do ShopController (index/show/store/update/destroy), incluindo assertions para falhas de validação e verificações de permissão, usando a trait RefreshDatabase.
-
Desafio (⭐⭐⭐): Escreva um teste completo de fluxo de pedido via API — criar pedido → atualizar status → verificar mudanças no banco de dados → mock de eventos e filas → confirmar que Event::assertDispatched e Queue::assertPushed passam, com taxa de cobertura ≥ 80%.



