Testes
O PivotPHP é construído com testes em mente. O framework fornece uma suíte de testes poderosa baseada no PHPUnit, com ajudantes e asserções adicionais para tornar os testes de sua aplicação muito fáceis.
Começando
Instalação
O PivotPHP vem com o PHPUnit pré-configurado. Se você precisar instalá-lo manualmente:
composer require --dev phpunit/phpunit
composer require --dev mockery/mockery
composer require --dev fakerphp/faker
Configuração
O arquivo phpunit.xml
na raiz do seu projeto configura o ambiente de teste:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
Executando Testes
# Executar todos os testes
composer test
# Executar suíte de testes específica
composer test -- --testsuite=Unit
# Executar arquivo de teste específico
composer test tests/Unit/UserTest.php
# Executar método de teste específico
composer test -- --filter test_user_can_be_created
# Executar testes com cobertura
composer test:coverage
Escrevendo Testes
Estrutura Básica de Teste
namespace Tests\Unit;
use Tests\TestCase;
use App\Models\User;
class UserTest extends TestCase
{
/**
* @test
*/
public function it_can_create_a_user()
{
$user = new User([
'name' => 'João Silva',
'email' => 'joao@exemplo.com'
]);
$this->assertEquals('João Silva', $user->name);
$this->assertEquals('joao@exemplo.com', $user->email);
}
public function test_user_full_name()
{
$user = new User([
'first_name' => 'João',
'last_name' => 'Silva'
]);
$this->assertEquals('João Silva', $user->getFullName());
}
}
Classe Base de Caso de Teste
namespace Tests;
use PivotPHP\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
protected function setUp(): void
{
parent::setUp();
// Configuração adicional
}
protected function tearDown(): void
{
// Limpeza
parent::tearDown();
}
}
Testes HTTP
Testando Rotas
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
class UserControllerTest extends TestCase
{
public function test_users_index_returns_list()
{
$users = User::factory()->count(3)->create();
$response = $this->get('/api/users');
$response->assertStatus(200)
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [
'*' => ['id', 'name', 'email']
]
]);
}
public function test_can_create_user()
{
$userData = [
'name' => 'João Silva',
'email' => 'joao@exemplo.com',
'password' => 'senha123'
];
$response = $this->postJson('/api/users', $userData);
$response->assertStatus(201)
->assertJsonPath('data.email', 'joao@exemplo.com');
$this->assertDatabaseHas('users', [
'email' => 'joao@exemplo.com'
]);
}
}
Testando Autenticação
class AuthTest extends TestCase
{
public function test_user_can_login()
{
$user = User::factory()->create([
'password' => bcrypt('senha123')
]);
$response = $this->postJson('/api/login', [
'email' => $user->email,
'password' => 'senha123'
]);
$response->assertStatus(200)
->assertJsonStructure(['token']);
}
public function test_authenticated_user_can_access_protected_route()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/api/profile');
$response->assertStatus(200)
->assertJsonPath('data.id', $user->id);
}
public function test_unauthenticated_user_cannot_access_protected_route()
{
$response = $this->get('/api/profile');
$response->assertStatus(401);
}
}
Testando Upload de Arquivos
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class FileUploadTest extends TestCase
{
public function test_user_can_upload_avatar()
{
Storage::fake('avatars');
$user = User::factory()->create();
$file = UploadedFile::fake()->image('avatar.jpg');
$response = $this->actingAs($user)
->post('/api/avatar', [
'avatar' => $file
]);
$response->assertStatus(200);
Storage::disk('avatars')->assertExists($file->hashName());
$this->assertNotNull($user->fresh()->avatar);
}
public function test_validates_avatar_is_image()
{
$user = User::factory()->create();
$file = UploadedFile::fake()->create('document.pdf', 1000);
$response = $this->actingAs($user)
->post('/api/avatar', [
'avatar' => $file
]);
$response->assertStatus(422)
->assertJsonValidationErrors('avatar');
}
}
Testes de Banco de Dados
Transações de Banco de Dados
use PivotPHP\Foundation\Testing\RefreshDatabase;
class DatabaseTest extends TestCase
{
use RefreshDatabase;
public function test_can_create_post()
{
$user = User::factory()->create();
$post = Post::create([
'user_id' => $user->id,
'title' => 'Post de Teste',
'content' => 'Conteúdo de teste'
]);
$this->assertDatabaseHas('posts', [
'title' => 'Post de Teste'
]);
$this->assertDatabaseCount('posts', 1);
}
}
Factories de Modelos
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class UserFactory extends Factory
{
protected $model = User::class;
public function definition()
{
return [
'name' => $this->faker->name(),
'email' => $this->faker->unique()->safeEmail(),
'password' => bcrypt('password'),
'verified_at' => now(),
];
}
public function unverified()
{
return $this->state(function (array $attributes) {
return [
'verified_at' => null,
];
});
}
public function admin()
{
return $this->state(function (array $attributes) {
return [
'role' => 'admin',
];
});
}
}
// Uso
$users = User::factory()->count(5)->create();
$admin = User::factory()->admin()->create();
$unverified = User::factory()->unverified()->create();
Asserções de Banco de Dados
public function test_database_assertions()
{
$user = User::factory()->create();
// Afirma que uma tabela contém um registro
$this->assertDatabaseHas('users', [
'email' => $user->email
]);
// Afirma que uma tabela não contém um registro
$this->assertDatabaseMissing('users', [
'email' => 'inexistente@exemplo.com'
]);
// Afirma que um registro foi deletado
$user->delete();
$this->assertModelMissing($user);
// Afirma contagem
User::factory()->count(3)->create();
$this->assertDatabaseCount('users', 3);
// Afirma exclusão suave
$user->delete();
$this->assertSoftDeleted($user);
}
Mocking
Mocking Básico
use Mockery;
class ServiceTest extends TestCase
{
public function test_service_calls_repository()
{
$mockRepo = Mockery::mock(UserRepository::class);
$mockRepo->shouldReceive('find')
->once()
->with(1)
->andReturn(new User(['id' => 1, 'name' => 'João']));
$this->app->instance(UserRepository::class, $mockRepo);
$service = app(UserService::class);
$user = $service->getUser(1);
$this->assertEquals('João', $user->name);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
Mocking de Facades
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Event;
class NotificationTest extends TestCase
{
public function test_welcome_email_is_sent()
{
Mail::fake();
$user = User::factory()->create();
Mail::assertSent(WelcomeEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email);
});
}
public function test_event_is_fired()
{
Event::fake();
$user = User::factory()->create();
Event::assertDispatched(UserRegistered::class, function ($event) use ($user) {
return $event->user->id === $user->id;
});
}
}
Mocking de Serviços Externos
class PaymentTest extends TestCase
{
public function test_payment_is_processed()
{
$mockGateway = Mockery::mock(PaymentGateway::class);
$mockGateway->shouldReceive('charge')
->once()
->with(100, 'USD')
->andReturn(['status' => 'success', 'id' => 'ch_123']);
$this->app->instance(PaymentGateway::class, $mockGateway);
$response = $this->postJson('/api/payments', [
'amount' => 100,
'currency' => 'USD'
]);
$response->assertStatus(200)
->assertJsonPath('payment_id', 'ch_123');
}
}
Melhores Práticas de Testes
Organização de Testes
// tests/Unit/Models/UserTest.php
class UserTest extends TestCase
{
/** @test */
public function it_has_fillable_attributes()
{
$user = new User();
$this->assertEquals([
'name', 'email', 'password'
], $user->getFillable());
}
}
// tests/Feature/Api/UserApiTest.php
class UserApiTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_returns_paginated_users()
{
User::factory()->count(25)->create();
$response = $this->getJson('/api/users');
$response->assertStatus(200)
->assertJsonCount(20, 'data')
->assertJsonPath('meta.total', 25);
}
}
Construtores de Dados de Teste
class UserBuilder
{
private array $attributes = [];
public function withEmail(string $email): self
{
$this->attributes['email'] = $email;
return $this;
}
public function asAdmin(): self
{
$this->attributes['role'] = 'admin';
return $this;
}
public function verified(): self
{
$this->attributes['verified_at'] = now();
return $this;
}
public function build(): User
{
return User::factory()->create($this->attributes);
}
}
// Uso
$admin = (new UserBuilder())
->withEmail('admin@exemplo.com')
->asAdmin()
->verified()
->build();
Traits de Teste
trait InteractsWithPayments
{
protected function createPayment(array $attributes = []): Payment
{
return Payment::factory()->create($attributes);
}
protected function assertPaymentSuccessful(Payment $payment): void
{
$this->assertEquals('completed', $payment->status);
$this->assertNotNull($payment->completed_at);
}
protected function mockPaymentGateway($shouldSucceed = true): void
{
$mock = Mockery::mock(PaymentGateway::class);
$mock->shouldReceive('charge')
->andReturn($shouldSucceed ?
['status' => 'success'] :
['status' => 'failed']);
$this->app->instance(PaymentGateway::class, $mock);
}
}
Testando Comandos
class CommandTest extends TestCase
{
public function test_import_command()
{
Storage::fake('imports');
Storage::disk('imports')->put('users.csv', "name,email\nJoão,joao@exemplo.com");
$this->artisan('import:users users.csv')
->expectsOutput('Importando usuários...')
->expectsOutput('Importados 1 usuários.')
->assertExitCode(0);
$this->assertDatabaseHas('users', [
'email' => 'joao@exemplo.com'
]);
}
public function test_interactive_command()
{
$this->artisan('make:service')
->expectsQuestion('Qual é o nome do serviço?', 'PaymentService')
->expectsQuestion('Gostaria de criar uma interface?', true)
->expectsOutput('Serviço criado com sucesso!')
->assertExitCode(0);
$this->assertFileExists(app_path('Services/PaymentService.php'));
}
}
Integração Contínua
Exemplo do GitHub Actions
name: Testes
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: test_db
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- uses: actions/checkout@v3
- name: Configurar PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mbstring, pdo, pdo_mysql
coverage: xdebug
- name: Instalar Dependências
run: composer install --no-interaction --prefer-dist
- name: Executar Testes
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_PORT: 3306
DB_DATABASE: test_db
DB_USERNAME: root
DB_PASSWORD: ''
run: |
php artisan migrate --force
composer test:coverage
- name: Enviar Cobertura
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
Melhores Práticas
- Teste comportamento, não implementação: Foque no que o código faz, não como
- Use nomes descritivos para testes: Deixe claro o que está sendo testado
- Siga o padrão AAA: Arrange, Act, Assert (Organizar, Agir, Afirmar)
- Mantenha testes independentes: Cada teste deve executar isoladamente
- Use factories: Não codifique dados de teste diretamente
- Simule dependências externas: Não faça chamadas reais de API
- Teste casos extremos: Inclua condições de contorno e cenários de erro
- Mantenha cobertura de testes: Busque alta cobertura, mas foque na qualidade