Introduction aux tests Laravel

TypeCibleVitesseCommande
UnitClasse / méthode isoléeTrès rapidephp artisan test --filter=UnitTest
FeatureFlux complet (HTTP, BDD)Rapidephp artisan test --filter=FeatureTest
BrowserUI navigateur (Dusk)Lentphp artisan dusk
# Lancer tous les tests
php artisan test

# Avec couverture de code (nécessite Xdebug ou PCOV)
php artisan test --coverage
php artisan test --coverage --min=80   # échoue si < 80 %

# Filtres
php artisan test --filter=ArticleTest
php artisan test --filter="test_can_create_article"
php artisan test tests/Feature/Api/ArticleTest.php

# En parallèle (plus rapide)
php artisan test --parallel
// Structure d'un test PHPUnit
namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    // setUp : exécuté avant chaque test
    protected function setUp(): void {
        parent::setUp();
        // initialiser les fixtures
    }

    // tearDown : exécuté après chaque test
    protected function tearDown(): void {
        parent::tearDown();
    }

    // Méthode de test : préfixe test_ OU annotation @test
    public function test_addition_returns_correct_result(): void {
        $result = 2 + 2;
        $this->assertSame(4, $result);
    }

    /** @test */
    public function it_subtracts_correctly(): void {
        $this->assertSame(1, 3 - 2);
    }
}

Tests unitaires

php artisan make:test ArticleTest --unit
// tests/Unit/Models/ArticleTest.php
namespace Tests\Unit\Models;

use App\Models\Article;
use PHPUnit\Framework\TestCase;

class ArticleTest extends TestCase
{
    public function test_reading_time_is_calculated_correctly(): void {
        $article = new Article(['body' => str_repeat('word ', 200)]);

        $this->assertSame('1 min', $article->reading_time);
    }

    public function test_reading_time_rounds_up(): void {
        $article = new Article(['body' => str_repeat('word ', 250)]);

        $this->assertSame('2 min', $article->reading_time);
    }

    public function test_is_published_returns_true_when_status_published(): void {
        $article = new Article(['status' => 'published']);

        $this->assertTrue($article->isPublished());
    }

    public function test_is_published_returns_false_when_draft(): void {
        $article = new Article(['status' => 'draft']);

        $this->assertFalse($article->isPublished());
    }
}

// tests/Unit/Services/PriceCalculatorTest.php
namespace Tests\Unit\Services;

use App\Services\PriceCalculator;
use PHPUnit\Framework\TestCase;

class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void {
        parent::setUp();
        $this->calculator = new PriceCalculator();
    }

    /** @dataProvider priceProvider */
    public function test_calculates_discounted_price(
        float $price, int $discount, float $expected
    ): void {
        $result = $this->calculator->applyDiscount($price, $discount);
        $this->assertSame($expected, $result);
    }

    public static function priceProvider(): array {
        return [
            'no discount'    => [100.0, 0,  100.0],
            '10% discount'   => [100.0, 10, 90.0],
            '50% discount'   => [200.0, 50, 100.0],
            'full discount'  => [100.0, 100, 0.0],
        ];
    }

    public function test_throws_exception_for_invalid_discount(): void {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Discount must be between 0 and 100');

        $this->calculator->applyDiscount(100.0, 150);
    }
}

Tests de feature

php artisan make:test ArticleFeatureTest
// tests/Feature/ArticleTest.php
namespace Tests\Feature;

use App\Models\Article;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class ArticleTest extends TestCase
{
    use RefreshDatabase; // Réinitialise la BDD avant chaque test

    public function test_authenticated_user_can_view_articles(): void {
        $user     = User::factory()->create();
        $articles = Article::factory()->count(3)->published()->create();

        $response = $this->actingAs($user)
            ->getJson('/api/articles');

        $response->assertOk()
            ->assertJsonCount(3, 'data')
            ->assertJsonStructure([
                'data' => [['id', 'title', 'status', 'created_at']],
            ]);
    }

    public function test_unauthenticated_user_cannot_access_articles(): void {
        $this->getJson('/api/articles')
            ->assertUnauthorized();
    }

    public function test_user_can_create_article(): void {
        $user = User::factory()->create();

        $response = $this->actingAs($user)->postJson('/api/articles', [
            'title'  => 'Mon article',
            'body'   => 'Contenu de l\'article',
            'status' => 'draft',
        ]);

        $response->assertCreated()
            ->assertJsonPath('data.title', 'Mon article');

        $this->assertDatabaseHas('articles', [
            'title'     => 'Mon article',
            'author_id' => $user->id,
        ]);
    }

    public function test_validation_fails_with_missing_title(): void {
        $user = User::factory()->create();

        $this->actingAs($user)->postJson('/api/articles', [
            'body' => 'Contenu',
        ])->assertUnprocessable()
          ->assertJsonValidationErrors(['title']);
    }

    public function test_only_author_can_update_article(): void {
        $author  = User::factory()->create();
        $other   = User::factory()->create();
        $article = Article::factory()->create(['author_id' => $author->id]);

        // L'auteur peut modifier
        $this->actingAs($author)->putJson("/api/articles/{$article->id}", [
            'title' => 'Titre modifié',
            'body'  => 'Contenu',
        ])->assertOk();

        // Un autre utilisateur ne peut pas
        $this->actingAs($other)->putJson("/api/articles/{$article->id}", [
            'title' => 'Tentative',
            'body'  => 'Contenu',
        ])->assertForbidden();
    }

    public function test_soft_deleted_article_is_not_in_list(): void {
        $user    = User::factory()->create();
        $article = Article::factory()->create(['author_id' => $user->id]);
        $article->delete();

        $this->actingAs($user)->getJson('/api/articles')
            ->assertOk()
            ->assertJsonMissing(['id' => $article->id]);
    }
}

Tests & base de données

// phpunit.xml — utiliser SQLite en mémoire pour les tests
<php>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>

// RefreshDatabase — rollback après chaque test (recommandé)
use Illuminate\Foundation\Testing\RefreshDatabase;

// DatabaseTransactions — encapsule dans une transaction
use Illuminate\Foundation\Testing\DatabaseTransactions;

// WithoutMiddleware — désactiver tout le middleware
use Illuminate\Foundation\Testing\WithoutMiddleware;
// Assertions sur la base de données
$this->assertDatabaseHas('articles', [
    'title' => 'Mon article',
    'status' => 'published',
]);

$this->assertDatabaseMissing('articles', [
    'id' => $article->id,
]);

$this->assertDatabaseCount('articles', 5);

$this->assertSoftDeleted('articles', ['id' => $article->id]);

// Créer des données de test avec les factories
$user    = User::factory()->create();       // persiste en BDD
$article = Article::factory()->make();      // crée l'objet sans persister

// États combinés
$article = Article::factory()
    ->for($user, 'author')
    ->has(Comment::factory()->count(5))
    ->published()
    ->create();

// Créer plusieurs avec des données spécifiques
$articles = Article::factory()->count(5)->create([
    'author_id' => $user->id,
    'status'    => 'draft',
]);

Tests HTTP / API

// Méthodes HTTP disponibles
$this->get('/route')
$this->post('/route', $data)
$this->put('/route', $data)
$this->patch('/route', $data)
$this->delete('/route')

// JSON (envoie Accept: application/json)
$this->getJson('/api/route')
$this->postJson('/api/route', $data)

// Avec authentification Sanctum
$this->actingAs($user)
$this->actingAs($user, 'sanctum')  // guard explicite

// Token Sanctum pour test
Sanctum::actingAs($user, ['tasks:read', 'tasks:write']);
// Assertions de réponse
$response = $this->getJson('/api/articles');

// Status codes
$response->assertOk();           // 200
$response->assertCreated();      // 201
$response->assertNoContent();    // 204
$response->assertUnauthorized(); // 401
$response->assertForbidden();    // 403
$response->assertNotFound();     // 404
$response->assertUnprocessable(); // 422

// Corps JSON
$response->assertJson(['message' => 'ok']);
$response->assertJsonPath('data.0.title', 'Article 1');
$response->assertJsonCount(10, 'data');
$response->assertJsonMissing(['password']);
$response->assertJsonStructure([
    'data' => [['id', 'title', 'status']],
    'meta' => ['total', 'per_page'],
]);

// Exemple complet : test de pagination
public function test_articles_are_paginated(): void {
    $user = User::factory()->create();
    Article::factory()->count(20)->create();

    $response = $this->actingAs($user)->getJson('/api/articles?per_page=10');

    $response->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonPath('meta.total', 20)
        ->assertJsonPath('meta.per_page', 10)
        ->assertJsonPath('meta.last_page', 2);
}

Mocking

use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Notification;

// ── Mail ──────────────────────────────────────────────
Mail::fake();

$user = User::factory()->create();
$this->actingAs($user)->postJson('/api/articles', [...]);

Mail::assertSent(ArticlePublishedMail::class, function($mail) use ($user) {
    return $mail->hasTo($user->email);
});
Mail::assertNothingSent();
Mail::assertQueued(WelcomeMail::class);

// ── Queue ─────────────────────────────────────────────
Queue::fake();

// ... déclencher une action

Queue::assertPushed(ProcessVideoJob::class);
Queue::assertPushed(ProcessVideoJob::class, 2); // dispatché 2 fois
Queue::assertPushedOn('video', ProcessVideoJob::class);
Queue::assertNotPushed(SendEmailJob::class);

// ── Storage ───────────────────────────────────────────
Storage::fake('public');

$file = UploadedFile::fake()->image('avatar.jpg', 200, 200);
$this->actingAs($user)->post('/api/profile', ['avatar' => $file]);

Storage::disk('public')->assertExists('avatars/' . $user->id . '.jpg');

// ── HTTP Client ───────────────────────────────────────
Http::fake([
    'api.example.com/weather' => Http::response([
        'temp' => 22, 'city' => 'Paris',
    ], 200),
    'api.example.com/*' => Http::response(['error' => 'Not found'], 404),
]);

// L'app appelle Http::get('api.example.com/weather') → réponse simulée

// ── Notification ─────────────────────────────────────
Notification::fake();

// ... déclencher une action

Notification::assertSentTo($user, OrderShippedNotification::class);
Notification::assertNotSentTo($admin, OrderShippedNotification::class);

Pest PHP

composer require pestphp/pest --dev --with-all-dependencies
php artisan pest:install

# Plugins utiles
composer require pestphp/pest-plugin-laravel --dev
// tests/Feature/ArticleTest.php (style Pest)
use App\Models\Article;
use App\Models\User;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->user = User::factory()->create();
});

it('lists published articles', function () {
    Article::factory()->count(3)->published()->create();

    $this->actingAs($this->user)
        ->getJson('/api/articles')
        ->assertOk()
        ->assertJsonCount(3, 'data');
});

it('creates an article', function () {
    $this->actingAs($this->user)
        ->postJson('/api/articles', [
            'title' => 'Test Article',
            'body'  => 'Content here',
        ])
        ->assertCreated();

    expect(Article::count())->toBe(1);
    expect(Article::first()->title)->toBe('Test Article');
});

it('returns 422 without a title', function () {
    $this->actingAs($this->user)
        ->postJson('/api/articles', ['body' => 'Content'])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['title']);
});

// Dataset (équivalent de @dataProvider)
it('validates status field', function (string $status, bool $valid) {
    $response = $this->actingAs($this->user)
        ->postJson('/api/articles', [
            'title'  => 'Test',
            'body'   => 'Content',
            'status' => $status,
        ]);

    $valid
        ? $response->assertCreated()
        : $response->assertUnprocessable();

})->with([
    'valid draft'     => ['draft', true],
    'valid published' => ['published', true],
    'invalid status'  => ['invalid', false],
]);

CI avec GitHub Actions

# .github/workflows/tests.yml
name: Tests Laravel

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel_test
        ports: ['3306:3306']
        options: --health-cmd="mysqladmin ping" --health-retries=3

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: dom, curl, libxml, mbstring, zip, pdo, pdo_mysql, pcov
          coverage: pcov

      - name: Install dependencies
        run: composer install --prefer-dist --no-interaction

      - name: Copy .env
        run: cp .env.example .env.testing

      - name: Generate key
        run: php artisan key:generate --env=testing

      - name: Run tests with coverage
        env:
          DB_CONNECTION: mysql
          DB_HOST: 127.0.0.1
          DB_PORT: 3306
          DB_DATABASE: laravel_test
          DB_USERNAME: root
          DB_PASSWORD: password
        run: php artisan test --coverage --min=80 --parallel
Couverture de code : installe pcov (via shivammathur/setup-php) pour une couverture rapide. Xdebug est plus lent mais plus complet (branche coverage). Pour les projets Laravel, viser 80 % de couverture sur les controllers et services.
← Module 06 ▶ Mini-projet 🧠 QCM Module 08 →