Chapitre 9
09 — Testing PHP
09 — Testing PHP
Course : Testing PHP
1. Introduction aux Tests en PHP
1.1 Pourquoi tester ?
Le test logiciel est une discipline d'ingénierie qui garantit la fiabilité, la maintenabilité et l'évolutivité du code. En PHP, l'écosystème de test est mature avec PHPUnit comme framework historique et Pest comme alternative moderne et expressive.
1.2 Pyramide des tests
La pyramide des tests (Mike Cohn) définit trois niveaux :
- Tests unitaires (base) : testent une unité de code isolée (une classe, une méthode). Rapides, nombreux.
- Tests d'intégration : testent l'interaction entre plusieurs composants (base de données, API externe).
- Tests E2E / navigateur (sommet) : testent l'application de bout en bout via l'interface utilisateur.
1.3 Types de tests en PHP
- Unit tests : PHPUnit, Pest
- Feature tests : Laravel HTTP Tests
- Browser tests : Laravel Dusk, Playwright
- API tests : PHPUnit + Guzzle, Pest
- Database tests : RefreshDatabase, Factories, Seeders
- Snapshot tests : Pest Snapshot Testing
- Architecture tests : Pest Arch Testing
2. PHPUnit 11
2.1 Installation et configuration
PHPUnit 11 s'installe via Composer :
composer require --dev phpunit/phpunit ^11
Créer phpunit.xml :
<?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"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
2.2 Annotations vs Attributs (PHP 8.3)
PHPUnit 11 supporte les attributs PHP 8 :
// Annotations (déprécié)
/**
* @test
* @group unit
*/
public function it_calculates_sum(): void
{
$this->assertEquals(4, 2 + 2);
}
// Attributs (moderne)
#[Test]
#[Group('unit')]
#[Depends('testSomething')]
public function it_calculates_sum(): void
{
$this->assertEquals(4, 2 + 2);
}
Attributs disponibles : #[Test], #[Group], #[Depends], #[DataProvider], #[RequiresPhp], #[RequiresPhpunit], #[RequiresFunction], #[RequiresOperatingSystem], #[RequiresSetting], #[BackupGlobals], #[BackupStaticProperties], #[RunClassInSeparateProcess], #[RunTestsInSeparateProcesses], #[PreserveGlobalState].
2.3 Data Providers
Les data providers permettent de paramétrer un test avec plusieurs jeux de données :
use PHPUnit\Framework\Attributes\DataProvider;
#[Test]
#[DataProvider('additionProvider')]
public function testAdd(int $a, int $b, int $expected): void
{
$this->assertSame($expected, $a + $b);
}
public static function additionProvider(): array
{
return [
'zero plus zero' => [0, 0, 0],
'positive numbers' => [1, 2, 3],
'negative numbers' => [-1, -2, -3],
'mixed' => [5, -3, 2],
];
}
Data providers externes avec #[DataProviderExternal] :
use PHPUnit\Framework\Attributes\DataProviderExternal;
#[Test]
#[DataProviderExternal(ExternalDataProvider::class, 'provider')]
public function testFromExternal(int $input, int $expected): void
{
$this->assertSame($expected, $input * 2);
}
2.4 Test Doubles
PHPUnit propose 5 types de doublures :
// Stub — retourne une valeur contrôlée
$stub = $this->createStub(UserRepository::class);
$stub->method('find')->willReturn(new User());
// Mock — vérifie les interactions
$mock = $this->createMock(Mailer::class);
$mock->expects($this->once())
->method('send')
->with($this->isInstanceOf(Email::class))
->willReturn(true);
// Spy — vérifie après l'action
$spy = $this->createMock(Logger::class);
// action
$spy->expects($this->once())->method('log');
// Dummy — passé mais jamais utilisé
$dummy = $this->createStub(SomeInterface::class);
// Fake — implémentation simplifiée
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function save(User $user): void
{
$this->users[$user->id()] = $user;
}
public function find(int $id): ?User
{
return $this->users[$id] ?? null;
}
}
2.5 Code Coverage
Mesure la proportion de code exercée par les tests :
phpunit --coverage-html coverage/
Options : --coverage-html, --coverage-clover, --coverage-cobertura, --coverage-text, --coverage-xml.
Configuration dans phpunit.xml :
<source>
<include>
<directory>src</directory>
</include>
<exclude>
<directory>src/Exceptions</directory>
</exclude>
</source>
2.6 Assertions avancées
$this->assertArrayHasKey('email', $user);
$this->assertContains($value, $array);
$this->assertCount(3, $items);
$this->assertEmpty($collection);
$this->assertEquals($expected, $actual);
$this->assertSame($expected, $actual); // comparaison stricte ===
$this->assertInstanceOf(User::class, $user);
$this->assertMatchesRegularExpression('/^\w+@\w+\.\w+$/', $email);
$this->assertStringContainsString('Welcome', $response);
$this->assertJsonStringEqualsJsonString($expected, $actual);
3. Pest PHP
3.1 Installation
composer require --dev pestphp/pest ^3
php artisan pest:install
3.2 Syntaxe expressive
// phpunit-style
test('it calculates sum', function () {
$result = 2 + 2;
expect($result)->toBe(4);
});
// closure-style
it('calculates sum', function () {
expect(2 + 2)->toBe(4);
});
// avec dataset
it('performs addition', function ($a, $b, $expected) {
expect($a + $b)->toBe($expected);
})->with([
[1, 2, 3],
[4, 5, 9],
[-1, 1, 0],
]);
3.3 Expectations
expect($value)->toBe('exact');
expect($value)->toEqual('equivalent');
expect($array)->toHaveCount(3);
expect($array)->toHaveKey('name');
expect($collection)->each->toBeString();
expect($exception)->toThrow(\InvalidArgumentException::class);
expect($response)->toBeJson();
expect($response)->toMatchJson(['id' => 1]);
expect(fn() => risky())->toThrow(Throwable::class);
expect(['a', 'b', 'c'])->toContain('b');
expect(new User())->toBeInstanceOf(User::class);
expect($string)->toMatch('/^[a-z]+$/');
expect($number)->toBeGreaterThan(0);
expect($number)->toBeBetween(1, 10);
3.4 Higher Order Tests
it('has a name')
->expect(fn() => new User(name: 'John'))
->name->toBe('John');
it('can be archived')
->expect(new Article())
->archive()
->isArchived->toBeTrue();
3.5 Arch Testing (Pest Arch)
Vérifie l'architecture du code sans l'exécuter :
arch('app')
->expect('App')
->toUseStrictTypes()
->not->toUse(['dd', 'dump', 'var_dump']);
arch('services')
->expect('App\Services')
->toExtendNothing()
->toImplement(ServiceInterface::class)
->toUseOnly(['App\Repositories', 'App\Models']);
arch('controllers')
->expect('App\Http\Controllers')
->toHaveMethod('__invoke')
->not->toUse('App\Models');
arch('globals')
->expect(['dd', 'dump', 'ray'])
->not->toBeUsed();
3.6 Snapshot Testing
it('renders user profile', function () {
$html = view('profile', ['user' => User::factory()->make()])->render();
expect($html)->toMatchSnapshot();
});
Les snapshots sont stockés dans tests/.snapshots/ et doivent être commités.
4. Laravel HTTP Tests
4.1 Configuration
// tests/TestCase.php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
}
4.2 Test d'une API REST
it('creates a new post', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->postJson('/api/posts', [
'title' => 'Modern PHP Engineering',
'content' => 'Chapter about testing',
]);
$response
->assertStatus(201)
->assertJson([
'data' => [
'title' => 'Modern PHP Engineering',
],
])
->assertJsonStructure([
'data' => ['id', 'title', 'content', 'created_at'],
]);
$this->assertDatabaseHas('posts', [
'title' => 'Modern PHP Engineering',
'user_id' => $user->id,
]);
});
4.3 ActingAs — Authentification
// Avec un utilisateur spécifique
$admin = User::factory()->admin()->create();
$this->actingAs($admin)->get('/admin/dashboard')->assertOk();
// Avec des rôles
$editor = User::factory()->create();
$editor->assignRole('editor');
$this->actingAs($editor)->postJson('/api/posts', $data)->assertForbidden();
// Avec Sanctum
$user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken;
$this->withToken($token)->get('/api/user')->assertOk();
4.4 Assertions JSON avancées
$response
->assertJsonCount(3, 'data.items')
->assertJsonMissing(['title' => 'deleted post'])
->assertJsonMissingExact(['deleted_at' => null])
->assertJsonPath('data.0.user.name', 'John')
->assertJsonFragment(['status' => 'published'])
->assertJsonStructure([
'data' => [
'*' => ['id', 'type', 'attributes' => ['title', 'status']],
],
'meta' => ['current_page', 'last_page'],
]);
4.5 Database Assertions
// Assertions directes
$this->assertDatabaseHas('users', ['email' => 'john@example.com']);
$this->assertDatabaseMissing('users', ['email' => 'ghost@example.com']);
$this->assertDatabaseCount('posts', 5);
// Avec modèle supprimé
$this->assertSoftDeleted('posts', ['id' => $post->id]);
$this->assertNotSoftDeleted('posts', ['id' => $activePost->id]);
// Avec modèle
$this->assertModelExists($user);
$this->assertModelMissing($deletedUser);
4.6 Exceptions et validation
it('validates post creation', function () {
$response = $this->actingAs(User::factory()->create())
->postJson('/api/posts', []);
$response
->assertStatus(422)
->assertJsonValidationErrors(['title', 'content'])
->assertInvalid(['title']);
// Vérifier des messages spécifiques
$response->assertSee('Le champ titre est obligatoire');
});
5. Browser Tests
5.1 Laravel Dusk
composer require --dev laravel/dusk
php artisan dusk:install
<?php
namespace Tests\Browser;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
it('allows user to login', function () {
$user = User::factory()->create([
'email' => 'user@example.com',
'password' => bcrypt('password'),
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit('/login')
->type('email', $user->email)
->type('password', 'password')
->press('Login')
->assertPathIs('/dashboard')
->assertSee('Welcome back');
});
});
Assertions Dusk : assertSee, assertDontSee, assertPathIs, assertPathBeginsWith, assertUrlIs, assertSourceHas, assertChecked, assertNotChecked, assertSelected, assertRadioSelected, assertVue, assertPresent, assertMissing, assertDialogOpened, assertFocused, assertAuthenticated, assertGuest.
5.2 Playwright
npm init playwright@latest
import { test, expect } from '@playwright/test';
test('user can create a post', async ({ page }) => {
await page.goto('http://localhost:8000/login');
await page.fill('input[name="email"]', 'admin@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
await page.goto('http://localhost:8000/admin/posts/create');
await page.fill('input[name="title"]', 'Modern PHP');
await page.fill('textarea[name="content"]', 'Testing chapter');
await page.click('button:has-text("Publish")');
await expect(page.locator('.alert-success')).toBeVisible();
});
5.3 Sélecteurs stratégiques
<!-- Éviter les sélecteurs fragiles -->
<button class="btn btn-primary mt-4">Submit</button>
<!-- Préférer data-testid -->
<button data-testid="submit-post">Publier</button>
$browser->press('@submit-post');
// ou
$browser->click('[data-testid="submit-post"]');
6. TDD (Test-Driven Development)
6.1 Cycle Red-Green-Refactor
1. RED : Écrire un test qui échoue
2. GREEN : Écrire le minimum de code pour passer le test
3. REFACTOR : Améliorer le code sans casser les tests
6.2 Exemple TDD complet
// 1. RED — le test échoue car la classe n'existe pas
it('calculates order total with tax', function () {
$order = new Order([
new LineItem(100, 2), // 200
new LineItem(50, 3), // 150
]);
expect($order->totalWithTax(0.20))->toEqual(420.0);
// (200 + 150) * 1.20 = 420
});
// 2. GREEN — implémentation minimale
class Order
{
private array $items;
public function __construct(array $items)
{
$this->items = $items;
}
public function totalWithTax(float $taxRate): float
{
$subtotal = array_reduce($this->items, fn($carry, $item) =>
$carry + $item->subtotal(), 0);
return $subtotal * (1 + $taxRate);
}
}
// 3. REFACTOR — extraire des méthodes
class Order
{
public function totalWithTax(float $taxRate): float
{
return $this->subtotal() * (1 + $taxRate);
}
private function subtotal(): float
{
return array_reduce(
$this->items,
fn(float $total, LineItem $item): float => $total + $item->subtotal(),
0.0
);
}
}
6.3 TDD pour une API
// RED
it('returns 404 for non-existent post', function () {
$this->getJson('/api/posts/999')
->assertStatus(404);
});
// GREEN — dans routes/api.php
Route::get('/posts/{post}', function (Post $post) {
return $post;
});
// RED — test d'autorisation
it('prevents guest from creating posts', function () {
$this->postJson('/api/posts', [])
->assertStatus(401);
});
// GREEN — middleware
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('posts', PostController::class);
});
6.4 Avantages du TDD
- Couverture de test à 100% : chaque ligne est écrite pour passer un test
- Code déterministe : chaque fonction a un comportement prédictible
- Architecture émergente : le design émerge des besoins de testabilité
- Documentation vivante : les tests décrivent le comportement attendu
- Régression zéro : toute régression est immédiatement détectée
7. Bonnes Pratiques
7.1 Nommage des tests
// Structure Given/When/Then
it('throws exception when user is not found', function () { /* ... */ });
// Structure en phrase
it('sends welcome email after registration', function () { /* ... */ });
7.2 Arrange-Act-Assert (AAA)
it('calculates discount', function () {
// Arrange
$order = new Order();
$order->addItem(new Item(100));
// Act
$discounted = $order->applyDiscount(0.10);
// Assert
expect($discounted)->toEqual(90.0);
});
7.3 Tests indépendants et isolés
- Chaque test doit pouvoir s'exécuter seul
- Utiliser
RefreshDatabase,DatabaseTransactionsouDatabaseMigrations - Ne pas partager d'état entre les tests
- Éviter
@dependssauf cas très spécifiques
7.4 Fakes Laravel
// Mail fake
Mail::fake();
$this->post('/register', $userData);
Mail::assertSent(WelcomeEmail::class, fn($mail) => $mail->hasTo('user@example.com'));
// Queue fake
Queue::fake();
Bus::fake();
Notification::fake();
Event::fake();
// Storage fake
Storage::fake('s3');
Storage::disk('s3')->assertExists('photos/photo.jpg');
// HTTP fake
Http::fake();
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.github.com';
});
8. Tests et CI/CD
8.1 GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8
env:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: password
ports: ['3306:3306']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: 8.3
extensions: mbstring, pdo_mysql, bcmath
- run: composer install --no-interaction --prefer-dist
- run: cp .env.testing .env
- run: php artisan key:generate
- run: php artisan migrate --env=testing
- run: php artisan test
8.2 Parallélisation
# Pest parallel
php artisan test --parallel
# PHPUnit parallel
phpunit --parallel=4
9. Résumé
Le testing en PHP repose sur un écosystème mature :
- PHPUnit : le framework historique, complet et configurable
- Pest : l'alternative moderne, expressive et élégante
- Laravel HTTP Tests : test d'API complet avec assertions fluides
- Dusk/Playwright : tests navigateur pour valider l'expérience utilisateur
- TDD : cycle Red-Green-Refactor pour un code robuste dès la conception
Un code bien testé est un code que l'on ose modifier.