documentation
Kohinos v3 Doc générée le commit 8d60b1904

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 %)
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
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.

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

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 :

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égorieStratégiePourquoi
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.
⚠ Sonata Admin 4.x — classes finales

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

⚠ Quatre invariants à couvrir pour tout code financier
  1. 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(…));.
  2. Idempotence webhook : rejouer 2× le même payload HelloAsso / PayZen produit un seul Flux en base. La clé d'idempotence côté Kohinos est l'helloassoid (HelloAsso) ou l'identifiant transaction PayZen.
  3. Atomicité : une exception levée au milieu d'un transfert rollback la transaction entière ; aucun Operation orphelin n'est écrit. Code de référence : SsaRedistributionService::wrapInTransaction(…).
  4. 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é

OutilCommandeQuand
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).
Boucle de commit recommandée
  1. Mettre à jour CHANGELOG.md + CHANGELOG_USER.md.
  2. Lancer les tests ciblés du code modifié (un fichier, ou --testsuite=<nom>).
  3. vendor/bin/php-cs-fixer fix.
  4. 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

⛔ À ne JAMAIS faire
  • vendor/bin/phpunit sans 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ève LogicException. AbstractFunctionalTestCase::setUp() l'appelle déjà ; en hériter, utiliser $this->client->followRedirects(true) au besoin.
  • .env.test sans SECURE_SCHEME=http : les contrôleurs avec requires_channel renvoient 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.

⚠️ Mémoire / WSL

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

Sources