Aller au contenu

Règles Tests

Copy of sumaris-pod/.agents/rules/unit-testing.md (rule written for AI coding agents, when writing or modifying unit tests, integration tests, or test data fixtures). Do not edit here: run npm run site:sync-rules to update it.

Tests Unitaires

  • Utiliser JUnit 4 (pas JUnit 5) et DBUnit.
  • Base de données : Utiliser DatabaseResource (et non DataResource) pour gérer la base HSQLDB.
  • Données : Les données sont chargées à partir de fichiers XML via DBUnit. Ces fichiers se trouvent dans sumaris-test-shared/src/main/resources.
  • Génération DB : La base de test est générée une fois via InitTests, depuis sumaris-core :
    cd sumaris-core
    mvn -Phsqldb,run test-compile exec:java
    
    Cela crée les fichiers HSQLDB dans target/db/ (et marque target/db/sumaris.properties en readonly=true à la fin). Les tests utilisent ensuite une copie de cette base (writeDb()) ou la base directement (readDb(), voir plus bas).
    • -Phsqldb est indispensable ici : le profil hsqldb est activeByDefault, mais cette activation par défaut est désactivée dès qu'un autre profil (-Prun) est passé explicitement — sans lui, la tâche Ant échoue avec property 'jdbc.driver' must be set.
    • test-compile est nécessaire car InitTests vit dans src/test/java ; exec:java seul n'exécute aucune phase du cycle de vie et ne trouverait pas la classe.
  • Rechargement : La base est regénérée manuellement (pas automatiquement avant chaque test). Si les fixtures XML changent, relancer InitTests.
  • Assertions : Préférer Assert.assertEquals, Assert.assertNotNull, etc. (JUnit 4).

Lancer les tests

# Toute la suite d'un module
mvn test -pl sumaris-server

# Une seule classe (Surefire inclut par défaut **/*Test.java et **/*Tests.java, sans besoin de config particulière)
mvn test -pl sumaris-server -Dtest=MyServiceTest -DfailIfNoTests=false

Sur JDK 23+ (JEP 471), ajouter MAVEN_OPTS="--sun-misc-unsafe-memory-access=allow" pour supprimer un warning sun.misc.Unsafe bruyant émis par la Guava embarquée par Maven lui-même ($MAVEN_HOME/lib/guava.jar), sans rapport avec le projet.

Configuration des tests

  • Chaque module de test doit avoir une TestConfiguration annotée avec @SpringBootApplication et @ComponentScan.
  • Pour les tests de DAO, utiliser DaoTestConfiguration.
  • Pour les tests de Service, utiliser ServiceTestConfiguration.
  • Garder TestConfiguration synchronisée avec Application.java (excludes du @SpringBootApplication, @EnableCaching, etc.) : un décalage fait échouer silencieusement des fonctionnalités entières dans les tests (ex: @EnableCaching absent de TestConfiguration (sumaris-server) → aucun CacheManager jamais disponible dans aucun test du module, sans message d'erreur évident tant qu'aucun test n'en dépendait).

Tester un feature-flag (activé / désactivé)

Pour vérifier qu'une propriété comme spring.jms.enabled ou spring.cache.enabled active/désactive réellement une fonctionnalité, écrire deux classes de test :

  • une utilisant la config de test par défaut (sumaris-{module}-test.properties) pour le cas désactivé ;
  • une avec un fichier de properties dédié, additif, via @TestPropertySource(locations = "classpath:...-enabled.properties") sur la classe de test (les locations s'ajoutent à celles héritées de AbstractServiceTest, sans les remplacer), pour le cas activé.

Chaque combinaison de @TestPropertySource distincte fait démarrer un contexte Spring séparé (pas de partage avec les autres tests) — c'est le comportement attendu, pas un bug.

Gestion de la base de données (DatabaseResource)

  • La DatabaseResource permet de gérer le cycle de vie de la base de données de test (HSQLDB).
  • Elle propose deux modes d'ouverture :
    • readDb() : Ouvre la base de données en lecture seule, directement sur ../sumaris-core/target/db (pas de copie). Nécessite que target/db/sumaris.properties contienne déjà readonly=true — ce que fixe InitTests en fin d'exécution (voir plus haut) ou une exécution complète de la suite AllTests. Si ce fichier/propriété est absent, le test échoue à l'initialisation avec Could not find readonly property on db config.
    • writeDb() : Ouvre la base de données en lecture/écriture. La base est copiée depuis ../sumaris-core/target/db dans un répertoire temporaire pour chaque test afin d'isoler les modifications — ne nécessite pas que la base source soit en readonly. Préférer writeDb() par défaut pour un nouveau test, readDb() uniquement si le besoin de rapidité (pas de copie) est avéré et que l'environnement est déjà initialisé.

Bonnes Pratiques

  • Indépendance : Chaque test doit être indépendant des autres.
  • Nettoyage : Ne pas hésiter à vérifier l'état de la base après une suppression ou une mise à jour.
  • Mocks : Utiliser Mockito si nécessaire, mais privilégier les tests d'intégration avec HSQLDB pour les couches Repository/Service si les données sont complexes.
  • Couvre les cas limites, erreurs et chemins négatifs; privilégie des tests rapides et déterministes.

Accélération des tests avec HSQLDB server externe

Par défaut, les tests utilisent une BDD HSQLDB file-based copiée dans un répertoire temporaire. Pour gagner du temps (pas de copie de DB, pas d'InitTests), on peut pointer sur une BDD HSQLDB server déjà lancée (port 9001) :

# 1. Lancer HSQLDB server (une fois par session)
cd /opt/data/git/sumaris-pod/sumaris-core/src/test/scripts
bash restartServer.sh --force

# 2. Lancer les tests en pointant sur le server
mvn test -pl sumaris-core -Dtest=MyTest -o \
  -Dspring.datasource.url=jdbc:hsqldb:hsql://localhost/sumaris

⚠️ En mode server, les tests partagent la même BDD. Utiliser writeDb() avec précaution (les modifications persistent entre tests). Pour l'isolation, préférer des IDs uniques (timestamp) ou readDb() pour les tests en lecture seule.

Tests GraphQL (sumaris-server)

Les tests GraphQL utilisent graphQLTestTemplate via AbstractGraphQLServiceTest. Le pattern :

1. Créer les fichiers .graphql

Dans sumaris-server/src/test/resources/graphql/ :

Query (ex: myEntity.graphql) :

query MyEntity($id: Int!) {
    myEntity(id: $id) {
        __typename
        id
        label
        comments
        measurementValues
        program { __typename id label }
        recorderDepartment { __typename id label }
    }
}

Mutation (ex: saveMyEntity.graphql) :

mutation SaveMyEntity($entity: MyEntityVOInput!) {
    saveMyEntity(entity: $entity) {
        __typename
        id
        label
        comments
        measurementValues
    }
}

2. Écrire le test

public class MyEntityGraphQLTest extends AbstractGraphQLServiceTest {

    @ClassRule
    public static final DatabaseResource dbResource = DatabaseResource.writeDb();

    @Before
    public void setUp() throws Exception {
        super.setUp();
    }

    @Test
    public void saveMyEntity() throws Exception {
        Assume.assumeTrue(authenticate("admin@sumaris.net", "admin"));

        // 1. Load via GraphQL query
        ObjectNode loadVars = objectMapper.createObjectNode();
        loadVars.put("id", 301);
        MyEntityVO loaded = getResponse("myEntity", MyEntityVO.class, loadVars);
        Assert.assertNotNull(loaded);

        // 2. Modify
        loaded.setComments("New comment #" + System.currentTimeMillis());

        // 3. Save via GraphQL mutation
        ObjectNode saveVars = objectMapper.createObjectNode();
        JsonNode entityNode = objectMapper.valueToTree(loaded);
        removeTypenameFields(entityNode); // GraphQL input types reject __typename
        saveVars.set("entity", entityNode);

        MyEntityVO saved = getResponse("saveMyEntity", MyEntityVO.class, saveVars);
        Assert.assertNotNull(saved);

        // 4. Reload and verify
        MyEntityVO reloaded = getResponse("myEntity", MyEntityVO.class, loadVars);
        Assert.assertEquals(loaded.getComments(), reloaded.getComments());
    }

    private void removeTypenameFields(JsonNode node) {
        if (node == null || !node.isObject()) return;
        ObjectNode obj = (ObjectNode) node;
        obj.remove("__typename");
        obj.fields().forEachRemaining(entry -> {
            JsonNode value = entry.getValue();
            if (value.isObject()) removeTypenameFields(value);
            else if (value.isArray()) value.forEach(this::removeTypenameFields);
        });
    }
}

Points critiques

  • __typename removal : graphql-spqr rejette __typename dans les inputs. Toujours le supprimer avant d'envoyer un VO comme variable de mutation.
  • NOT NULL columns : la query doit demander TOUS les champs NOT NULL de l'entité (rankOrder, matrixId, program.id, etc.) sinon le save échoue avec integrity constraint violation.
  • DatabaseFixtures : le module sumaris-server n'a PAS accès à net.sumaris.core.dao.DatabaseFixtures. Hardcoder les IDs (ex: 301 pour OBSFISH sample #0) ou créer un fixture local dans sumaris-server/src/test/.../DatabaseFixtures.java.
  • getResponse(resourceName, ...) : le resourceName correspond au nom du fichier .graphql (sans extension). Le queryName correspond au nom de la query/mutation dans le fichier.
  • Authentification : authenticate("admin@sumaris.net", "admin") — toujours wraper dans Assume.assumeTrue() pour skip gracieux si l'auth échoue.
  • Lancement rapide : mvn test -pl sumaris-server -Dtest=MyGraphQLTest -o -Dspring.datasource.url=jdbc:hsqldb:hsql://localhost/sumaris