Introduction aux tests Laravel
| Type | Cible | Vitesse | Commande |
|---|---|---|---|
| Unit | Classe / méthode isolée | Très rapide | php artisan test --filter=UnitTest |
| Feature | Flux complet (HTTP, BDD) | Rapide | php artisan test --filter=FeatureTest |
| Browser | UI navigateur (Dusk) | Lent | php 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.