8.3 KiB
name, description, allowed-tools
| name | description | allowed-tools |
|---|---|---|
| testing-strategy | Stratégie de tests du projet Mangarr — pyramide adaptée à l'archi DDD/Hexa. Tests unitaires purs sur le Domain/Application (sans framework), adapters InMemory, tests fonctionnels API. Utiliser quand on crée ou modifie des tests, ou qu'on discute de la couverture à implémenter. | Read, Grep, Glob |
Stratégie de tests — Mangarr
Pyramide
┌─────────────────────────────┐
│ Tests Fonctionnels (API) │ ← peu nombreux, coûteux
│ tests/Functional/ │ zenstruck/browser + BrowserKit
├─────────────────────────────┤
│ Tests d'Intégration │ ← adapters Doctrine, clients HTTP
│ tests/Domain/*/Adapter/ │ zenstruck/foundry + DAMA
├─────────────────────────────┤
│ Tests Unitaires (Domain) │ ← majorité, rapides, sans framework
│ tests/Domain/*/Application/ │ PHPUnit pur, InMemory adapters
└─────────────────────────────┘
1. Tests Unitaires — Application Layer (CommandHandlers, QueryHandlers)
Localisation : tests/Domain/{Domain}/Application/CommandHandler/ et QueryHandler/
Principe : Aucune dépendance au framework. On injecte des adapters InMemory à la place des vraies implémentations Infrastructure.
Structure d'un test CommandHandler
// tests/Domain/Manga/Application/CommandHandler/CreateMangaHandlerTest.php
namespace App\Tests\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\CreateManga;
use App\Domain\Manga\Application\CommandHandler\CreateMangaHandler;
use App\Tests\Domain\Manga\Adapter\InMemoryMangaRepository;
use App\Tests\Domain\Manga\Adapter\InMemoryImageProcessor;
use App\Tests\Shared\Adapter\InMemoryMessageBus;
use PHPUnit\Framework\TestCase;
class CreateMangaHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private CreateMangaHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new CreateMangaHandler(
$this->repository,
new InMemoryImageProcessor(),
new InMemoryMessageBus(),
);
}
public function testHandleSuccess(): void
{
// Arrange
$command = new CreateManga(
title: 'One Piece',
slug: 'one-piece',
// ...
);
// Act
$this->handler->handle($command);
// Assert
$saved = $this->repository->findAll()[0];
$this->assertEquals('One Piece', $saved->getTitle()->getValue());
}
public function testThrowsWhenInvalid(): void
{
$this->expectException(\RuntimeException::class);
$this->handler->handle(new CreateManga(title: '', /* ... */));
}
}
Structure d'un test QueryHandler
// tests/Domain/Manga/Application/QueryHandler/GetMangaByIdHandlerTest.php
class GetMangaByIdHandlerTest extends TestCase
{
private InMemoryMangaRepository $repository;
private GetMangaByIdHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryMangaRepository();
$this->handler = new GetMangaByIdHandler($this->repository);
}
public function testThrowsWhenNotFound(): void
{
$this->expectException(MangaNotFoundException::class);
$this->handler->handle(new GetMangaById('non-existent'));
}
public function testReturnsMappedResponse(): void
{
// Arrange — construire l'Aggregate directement avec Value Objects
$manga = new Manga(
id: new MangaId('123'),
title: new MangaTitle('One Piece'),
// ...
);
$this->repository->save($manga);
// Act
$response = $this->handler->handle(new GetMangaById('123'));
// Assert — vérifier les scalaires du Response
$this->assertEquals('123', $response->id);
$this->assertEquals('One Piece', $response->title);
}
protected function tearDown(): void
{
$this->repository->clear();
}
}
2. Adapters InMemory
Localisation : tests/Domain/{Domain}/Adapter/
Chaque interface de Domain/Contract/ a son adapter InMemory dans les tests. Ces adapters stockent les données en mémoire (array).
Structure d'un InMemory Repository
// tests/Domain/Manga/Adapter/InMemoryMangaRepository.php
namespace App\Tests\Domain\Manga\Adapter;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Model\Manga;
class InMemoryMangaRepository implements MangaRepositoryInterface
{
/** @var array<string, Manga> */
private array $mangas = [];
public function save(Manga $manga): void
{
$this->mangas[$manga->getId()->getValue()] = $manga;
}
public function findById(string $id): ?Manga
{
return $this->mangas[$id] ?? null;
}
public function findAll(): array
{
return array_values($this->mangas);
}
public function clear(): void
{
$this->mangas = [];
}
// ... implémenter toutes les méthodes de l'interface
}
Adapters InMemory disponibles (existants)
| Adapter | Interface implémentée |
|---|---|
InMemoryMangaRepository |
MangaRepositoryInterface |
InMemoryImageProcessor |
ImageProcessorInterface |
InMemoryMangadexClient |
MangadexClientInterface |
InMemoryMangaProvider |
MangaProviderInterface |
InMemoryPathManager |
MangaPathManagerInterface |
InMemoryMessageBus |
MessageBusInterface |
Quand on crée une nouvelle interface dans Domain/Contract/, créer l'adapter InMemory correspondant avant d'écrire les tests.
3. Tests Fonctionnels API
Localisation : tests/Functional/
Utilisent zenstruck/browser + BrowserKitBrowser avec le conteneur Symfony complet. Les données sont gérées par zenstruck/foundry (Factories) et DAMA\DoctrineTestBundle (rollback automatique après chaque test).
// tests/Functional/SomeEndpointTest.php
namespace App\Tests\Functional;
use Zenstruck\Browser\Test\HasBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class SomeEndpointTest extends WebTestCase
{
use HasBrowser;
public function testGetManga(): void
{
$this->browser()
->get('/api/mangas/by-id/some-uuid')
->assertStatus(200)
->assertJson()
->assertJsonMatches('title', 'One Piece');
}
public function testCreateManga(): void
{
$this->browser()
->post('/api/mangas', [
'json' => ['externalId' => 'abc-123'],
])
->assertStatus(204);
}
}
Commandes
make test # tous les tests
make test f="CreateMangaHandlerTest" # un test par nom de classe
make test c="--group unit" # par groupe
make test c="--stop-on-failure" # s'arrêter au premier échec
Checklist par feature
Quand on implémente une nouvelle feature, les tests à écrire dans l'ordre :
-
Test du CommandHandler/QueryHandler (unitaire,
TestCasepur)- Cas nominal (happy path)
- Cas d'erreur (not found, invalide…)
- Vérification que le repository est bien appelé
-
Test de la Value Object si une nouvelle VO est créée
- Validation des invariants (cas invalides)
getValue()retourne la bonne valeur
-
Test fonctionnel de l'endpoint (si API Platform)
- Codes HTTP corrects (200, 201, 204, 404…)
- Structure JSON de la réponse
Ne pas tester les Processors/Providers API Platform en unitaire (trop de couplage framework) — les couvrir via les tests fonctionnels.