Stratégie de tests par couche
Pas de test = pas de merge. C'est la règle dure du projet
(.claude/rules/global.md) : tout nouveau code métier vient avec son test.
La pyramide PHPUnit Kohinos suit l'ordre canonique Unit > Integration > Functional,
jamais l'inverse : on écrit beaucoup de tests rapides à la base, et très peu de tests
de bout en bout au sommet. La cible n'est pas la couverture absolue : c'est l'isolation
des invariants financiers et la garantie qu'une régression sur la monnaie locale est
détectée par un test avant de toucher la prod.
La pyramide
flowchart TB F["Functional (5–10 %)Plus on monte, plus c'est lent et fragile : 10 ms pour un Unit, 100 ms pour un Integration, 1 s+ pour un Functional. Les Functional servent à câbler ; les Unit à prouver.
WebTestCase · KernelBrowser
fixtures réduites · 1 createClient()"] I["Integration (20–25 %)
Container Symfony · SQLite in-memory
entités réelles · UnitOfWork réel"] U["Unit (65–75 %)
Pure PHP · tout mocké
pas de container · < 10 ms / test"] F --> I --> U
Couches en pratique
| Couche | Dossier | Framework | DB | Mocké | Réel | Cible vitesse |
|---|---|---|---|---|---|---|
| Unit | tests/Unit/ |
TestCase |
aucune | tout (EM, repos, mailer, HTTP) | SUT + value objects | < 10 ms |
| Service | tests/Service/ |
TestCase |
aucune (ou SQLite) | collaborateurs externes | entités domaine | < 50 ms |
| Repository | tests/Repository/ |
KernelTestCase |
SQLite in-memory | — | Doctrine UnitOfWork, requêtes | < 100 ms |
| Integration | tests/Integration/ |
KernelTestCase |
SQLite in-memory | HTTP externes (HelloAsso, PayZen) | container, entités, listeners, events | < 500 ms |
| Functional | tests/Functional/, tests/Role/, tests/Controller/ |
WebTestCase |
SQLite in-memory | passerelles paiement | routing, sécurité, twig, formulaires | < 2 s |
| Command | tests/Command/ |
CommandTester |
SQLite in-memory | cron, calendrier (SsaDateService) |
orchestrateur, services | < 1 s |
| Admin (Sonata) | tests/Admin/ |
AbstractAdminTestCase |
SQLite in-memory | — | admin Sonata réel via le container (adminServiceId()) |
< 500 ms |
Les testsuites définies dans phpunit.xml.dist permettent de cibler
rapidement : ssa, sepa, mlc, unit,
functional, command, profile. Préférer un filtre
par testsuite à une exécution complète.
Deux outils dédiés aux admins Sonata :
tests/Functional/Admin/SonataAdminCoverageTest.php (filet global —
toutes les routes list/create des ~46 admins du pool doivent
répondre 200 en super_admin, ~90 routes vérifiées) et
tests/Admin/AbstractAdminTestCase.php (classe de base des tests fins par
admin : adminServiceId() + helper actAs(roles)).
Détails : Sonata Admin.
Setup de la base de test
Tests sur SQLite in-memory en CI et par défaut. Le fichier
config/packages/test/doctrine.yaml surcharge la configuration et désactive
logging / profiling. La DATABASE_URL de test vient
de .env.test.local (non commité).
# Setup rapide (recommandé)
./scripts/setup-test-environment.sh
# Profils disponibles
./scripts/setup-test-environment.sh full # MLC + SSA + SEPA (défaut)
./scripts/setup-test-environment.sh mlc_only # MLC sans SSA
./scripts/setup-test-environment.sh ssa_only # SSA isolé
./scripts/setup-test-environment.sh minimal # MLC sans SSA ni SEPA
./scripts/setup-test-environment.sh sepa_disabled
# Setup manuel
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --env=test --no-interaction
php bin/console app:init-test-database --env=test --purge
php bin/console cache:clear --env=test
# Réinitialiser
php bin/console doctrine:database:drop --env=test --force
php bin/console doctrine:database:create --env=test
php bin/console doctrine:migrations:migrate --env=test --no-interaction
Le bootstrap tests/bootstrap.php émet un avertissement si la base utilisée
ne contient pas _test dans son nom — garde-fou contre une mauvaise
DATABASE_URL.
Conventions Kohinos
Nommage
-
Style préféré :
it_refuses_transfer_when_balance_is_insufficient— anglais lisible, descriptif, lu par#[TestDox]. -
Style legacy accepté :
test{Action}{ExpectedResult}[Context], par ex.testProcessRefusesPurchaseWhenBalanceInsufficient. - Toujours préciser la condition d'échec : pas de
testHappyPath().
Attributs PHPUnit 10.5
<?php
declare(strict_types=1);
namespace App\Tests\Service\Sepa;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class SepaMandateValidatorTest extends TestCase
{
#[Test]
#[TestDox('rejette un IBAN dont la clé de contrôle est invalide')]
public function rejects_iban_with_invalid_checksum(): void
{
// ...
}
#[Test]
#[DataProvider('provideAmountsThatMustRoundToTwoDecimals')]
public function rounds_amount_to_two_decimals(string $input, string $expected): void
{
// ...
}
public static function provideAmountsThatMustRoundToTwoDecimals(): iterable
{
yield 'half-up' => ['12.345', '12.35'];
yield 'half-down' => ['12.344', '12.34'];
}
}
declare(strict_types=1); est obligatoire en tête de chaque fichier
de test, comme pour src/. Préférer final class sur les
classes de test (PHPUnit le tolère, et empêche un héritage accidentel).
DataProviders pour les propriétés
Trois propriétés se prêtent particulièrement bien au data-provider :
- Conservation :
sumBefore === sumAfterpour tout couple émetteur/récepteur. - Idempotence : rejouer N fois la même opération ⇒ effet d'une seule.
- Non-négativité : aucun solde <
découvertAutorisé.
Voir invariants financiers pour les property-based tests couvrant ces propriétés sur un échantillon généré.
Mocking — qui mocker, qui garder réel
| Catégorie | Stratégie | Pourquoi |
|---|---|---|
EntityManagerInterface |
mock en Unit, réel en Integration | vérifier l'API en Unit, le comportement transactionnel en Integration. |
MailerInterface |
toujours mock | aucun appel SMTP réel — MAILER_DSN=null://null en test. |
HttpClientInterface |
toujours mock (MockHttpClient) |
HelloAsso, PayZen, banques : pas de réseau en CI. |
| Repositories Doctrine | mock en Unit, réel en Repository/Integration | contrats en Unit, requêtes DQL en Repository. |
| Entités domaine | jamais mockées | User, Flux, SolidoumeParameter… sont des records de valeur. |
Value objects (IBAN, Money) |
jamais mockés | déjà immuables et sans dépendances. |
| Doctrine UnitOfWork | jamais mocké | en Integration on veut tester les vraies cascades / events. |
DatagridMapper et ProxyQueryInterface sont final /
n'exposent plus getRootAlias() · andWhere(). Les mocker
directement échoue. Deux options :
- Test integration qui passe par le container Sonata réel.
- Mock partiel d'admin :
getMockBuilder(Foo::class)->disableOriginalConstructor()->onlyMethods([...]).
Et toujours : $this->getTranslator()->trans('key', [], $this->getTranslationDomain())
à la place de $this->trans(...) qui n'existe plus.
Tests financiers spécifiques
-
Conservation de masse : pour toute opération qui déplace de
l'eMLc, la somme des soldes avant et après l'opération est identique. Vérifié par un
helper :
assertConserved($em, fn () => $service->transfer(…));. -
Idempotence webhook : rejouer 2× le même payload
HelloAsso / PayZen produit un seul
Fluxen base. La clé d'idempotence côté Kohinos est l'helloassoid(HelloAsso) ou l'identifiant transaction PayZen. -
Atomicité : une exception levée au milieu d'un transfert
rollback la transaction entière ; aucun
Operationorphelin n'est écrit. Code de référence :SsaRedistributionService::wrapInTransaction(…). -
Non-négativité : aucun solde ne descend sous le découvert
autorisé déclaré dans
GlobalParameter.
Ces quatre propriétés sont les meilleures candidates pour le property-based testing (cf. invariants).
// Exemple — conservation de masse sur un transfert
#[Test]
public function transfer_preserves_total_mass(): void
{
$alice = $this->makeAccount('100.00');
$bob = $this->makeAccount('50.00');
$totalBefore = bcadd($alice->getBalance(), $bob->getBalance(), 2);
$this->transferService->transfer($alice, $bob, '12.34');
$totalAfter = bcadd($alice->getBalance(), $bob->getBalance(), 2);
self::assertSame($totalBefore, $totalAfter);
}
Tests de webhook
Les webhooks HelloAsso et PayZen sont la principale surface
d'entrée non maîtrisée par Kohinos : il faut tester signature, idempotence, et
rejeu :
// tests/Service/HelloAssoPaymentServiceTest.php (structure type)
#[Test]
public function processing_same_helloassoid_twice_creates_one_flux_only(): void
{
$payload = $this->loadFixture('helloasso/payment_success.json');
$signature = $this->sign($payload, $this->sharedSecret);
$this->service->handle($payload, $signature);
$this->service->handle($payload, $signature); // rejeu
$fluxCount = $this->em->getRepository(Flux::class)
->countByHelloAssoId($payload['data']['id']);
self::assertSame(1, $fluxCount, 'webhook must be idempotent on helloassoid');
}
Pour la signature, mocker HelloAssoSignatureVerifier en Unit, garder réel
en Integration. Voir webhooks & idempotence.
Outils qualité
| Outil | Commande | Quand |
|---|---|---|
| PHP-CS-Fixer | vendor/bin/php-cs-fixer fix |
Avant chaque commit (hook GrumPHP). |
| PHPStan | vendor/bin/phpstan analyze src tests |
Cible niveau 9 — pas de nouvelle suppression. |
| GrumPHP | vendor/bin/grumphp run |
Méta-hook : lance fixer + phpstan + lint + tests ciblés. |
| Twig / YAML linter | php bin/console lint:twig templates · lint:yaml config |
Avant commit sur templates / configs. |
| Infection (mutation testing) | vendor/bin/infection --threads=4 --filter=Sepa |
Sur le code financier critique uniquement (lent). |
| Routes multi-rôles | python3 scripts/test-routes.py --only-errors |
Détecte les 500 sur les routes par rôle (smoke test). |
- Mettre à jour
CHANGELOG.md+CHANGELOG_USER.md. - Lancer les tests ciblés du code modifié (un fichier, ou
--testsuite=<nom>). vendor/bin/php-cs-fixer fix.vendor/bin/grumphp run(qui relance tout).
Patron pour un nouveau test
<?php
declare(strict_types=1);
namespace App\Tests\Service\Sepa;
use App\Service\Sepa\SepaCancellationService;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
final class SepaCancellationServiceTest extends TestCase
{
private EntityManagerInterface $em;
private MailerInterface $mailer;
private SepaCancellationService $service;
protected function setUp(): void
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->mailer = $this->createMock(MailerInterface::class);
$this->service = new SepaCancellationService($this->em, $this->mailer);
}
#[Test]
#[TestDox('annule atomiquement toutes les requêtes récurrentes liées au mandat')]
public function cancels_all_recurring_requests_linked_to_mandate(): void
{
// arrange — un mandat avec 3 requêtes récurrentes
// act — $this->service->cancel($mandate)
// assert — wrapInTransaction appelé 1x, toutes requêtes archivées
}
}
Règles dures
-
vendor/bin/phpunitsans filtre — bloqué par.claude/settings.json. Trop long, sature la machine. Toujours cibler :--testsuite=ssa, un fichier, ou--filter=NomTest. -
Appeler
static::createClient()deux fois dans un même test fonctionnel SF7 : lèveLogicException.AbstractFunctionalTestCase::setUp()l'appelle déjà ; en hériter, utiliser$this->client->followRedirects(true)au besoin. -
.env.testsansSECURE_SCHEME=http: les contrôleurs avecrequires_channelrenvoient 301 et les tests partent en boucle de redirection. -
Pas de test = pas de merge. Tout nouveau service métier,
toute modification de logique financière vient avec son test ciblé. Un PR sans
test sur du code
src/Service/est refusé. -
Pas de mock d'entité domaine (
User,Flux,SolidoumeParameter…) : ce sont des records de valeur, on les instancie réellement. -
Pas de docblock Doctrine / Gedmo dans les fixtures de test :
attributes PHP 8 uniquement, comme partout dans
src/.
Tests E2E (Playwright)
Les tests E2E tapent un vrai serveur HTTP (≠ PHPUnit in-process). Ils tournent
dans un environnement dédié e2e avec une base SQLite fichier isolée
(var/kohinos_test_e2e.sqlite) et un jeu de fixtures minimal et média-free
(fixtures_e2e/). Doc complète : docs/testing/E2E_ENVIRONMENT.md.
# 1. Seeder la base e2e (idempotent, ~96 Mo de pic mémoire)
bash bin/e2e/seed.sh
# 2. Démarrer le serveur e2e (port 8044, base SQLite dédiée)
bash bin/e2e/serve.sh
# 3. Lancer les tests (toutes les suites)
APP_URL=http://127.0.0.1:8044 npx playwright test \
--project=chromium-e2e --project=a11y-e2e --project=mobile-e2e --project=tablet-e2e
Un front-controller public/e2e-router.php force APP_ENV/
DATABASE_URL/SECURE_SCHEME à chaque requête : le SAPI
cli-server du serveur PHP intégré n'expose pas de façon fiable l'environnement
de l'OS à l'application, sans quoi l'app retombe en dev (mauvaise base + redirection
HTTPS canonique).
56 tests verts, 0 échec, 0 fixme : authentification 8 rôles,
smoke, role-smoke (anti-500 par rôle), les 8 journeys J1–J8, webhooks
(retours paiement), visual (capture artefact 9 pages), a11y
(axe-core WCAG, 0 violation critical) et responsive (mobile/tablet,
anti-overflow). Bout-en-bout : J2 (paiement, conservation prouvée) et
J3 (création réelle de mandat SEPA pending + UMR).
J1/J5/J7/J8 vérifient l'accessibilité page/formulaire : leur flux complet a un blocage
d'infrastructure (J1 gateway de paiement, J5 commande CLI kohinos:ssa:execute,
J7 form Sonata financier à dropdowns dépendants, J8 API BOffice externe) et reste couvert par
les golden paths PHPUnit. Mailpit (faux SMTP local) est
installé et câblé via MAILER_DSN. Détails et findings :
docs/testing/E2E_ENVIRONMENT.md.
Ne jamais charger tout fixtures/ pour l'E2E : le graphe combiné
(dev+pro+media+ssa) explose (>4 Go) et, avec memory_limit=-1, déclenche
l'OOM-killer (crash WSL). Le jeu fixtures_e2e/ tient en ~96 Mo. Plafonner
memory_limit (ex. 2G) côté PHP CLI comme filet de sécurité.
Pour aller plus loin
tests/README.md,tests/TESTING_GUIDE.mdtests/AbstractFunctionalTestCase.php,tests/AbstractKernelTestCase.php,tests/bootstrap.phptests/TestDataProvider.php,tests/Fixtures/phpunit.xml.dist— testsuites et exclusionsconfig/packages/test/doctrine.yaml— DB de testscripts/setup-test-environment.sh— bootstrap automatique- Conventions de code PHP
- Invariants financiers & property-based testing
- Webhooks & idempotence