Tests unitaires Hibernate sous H2 au lieu d’Oracle

Contexte : développement d’applications Java/Spring/Hibernate, avec données en bases Oracle. Build géré par Maven, exécutant des tests unitaires (voire tests d’intégration) sur un schema Oracle dédié à chaque développeur. Plateforme d’intégration continue (PIC, gérée par Jenkins) exécutant les TUs sur un schema Oracle par application.

Objectifs :

  • réduire le temps de build sur les postes de dev
  • permettre de basculer d’un projet à un autre plus facilement (sans avoir à recréer la structure de son schema Oracle)
  • alléger l’installation/configuration des postes de dev

Idée : faire passer les tests unitaires sur une base en mémoire (H2) plutôt que sur Oracle

H2_logo

Spoiler : on a réussi à le mettre en place… mais on ne s’en est finalement quasiment pas servi.

Sommaire

Pourquoi H2?

C’est une base de données Java, donc pas besoin d’installer/paramétrer un serveur en plus (et c’est 100% gratuit et sous licence libre). En restant en Java, on évite tout l’overhead de passage par la couche réseau.

C’est une base de données qui peut fonctionner en mémoire : on peut ainsi éviter également les I/Os disque.

H2 possède un mode de compatibilité avec d’autres bases de données : ce mode est loin d’être parfait, mais traite déjà un certain nombre de différences. Pour activer le mode de compatibilité Oracle de H2, il suffit de préciser MODE=Oracle dans la chaine de connexion jdbc :

jdbc:h2:emplacement;MODE=Oracle

Quel schema H2 utiliser?

Par défaut, une base H2 a un seul schema nommé PUBLIC.

On peut tout à fait spécifier un schema dans la chaîne de connexion, et même le créer à la volée s’il n’existe pas :

jdbc:h2:emplacement;MODE=Oracle;init=create schema if not exists monschema\;set schema monschema

(attention à l’antislash qu’il faut parfois doubler suivant les couches applicatives qu’on traverse).

Dans notre cas, j’ai trouvé plus simple d’utiliser systématiquement le schema PUBLIC. En utilisant ce même username lors de la connexion JDBC, cela permet d’avoir le même comportement qu’une base Oracle : nom du schema=nom du user de connexion. En effet, certaines couches applicatives partent parfois de ce postulat.

Utiliser H2 sur la PIC également?

C’est tentant puisque ça permettrait de lancer plusieurs builds en parallèle sur une même application, éviterait la dépendance à la base Oracle, et accélérerait les builds.

Hélas, puisque le déploiement reste sur Oracle, on a besoin de se sécuriser en passant à un moment donné les TUs sur Oracle. Donc, au moins dans un premier temps, on va garder les TUs de la PIC sur Oracle. Quand on aura un peu plus de recul, peut-être qu’on pourra passer la PIC sur H2 pour certains projets (s’il n’y a aucun risque qu’ils exploitent une spécificité Oracle à un moment donné). Ou alors jouer sur les profils Maven pour utiliser H2 la plupart du temps, et Oracle à des moments précis comme une release.

Script de création du schema H2

Nettoyage « quick and dirty » des scripts PL/SQL de création de schema pour qu’ils fonctionnent sur H2

Dans notre contexte, le schema Oracle est créé avec des scripts PL/SQL (contrainte posée par les DBAs, notamment pour gérer les logs et reprise sur erreur). Dans la grande majorité des cas, il n’y a pourtant dedans que des scripts SQL. En faisant une passe de nettoyage « quick and dirty » (à coup d’expressions régulières), on peut convertir le PL/SQL en SQL qui fonctionne sur H2 :

  • Pour lancer un sous-script SQL, il faut remplacer les « start script.sql » par des « runscript from ‘script.sql' »
  • default sys_context(…) ne fonctionne pas sous H2 : j’ai viré car cette valeur par défaut ne servait en fait pas
  • supprimer toutes les références aux tablespaces, et au paramètre deferrable pour les index
  • supprimer toutes les commandes PL/SQL : set echo on/off, spool, whenever, créations de packages, commentaires etc
  • supprimer le paramètre noorder des séquences
  • remplacer les champs au format DATE par le format DATETIME (sinon il perd l’heure)
  • Remplacer les MAXVALUE de séquence de valeur 999999999999999999999999999 à la valeur maximum d’une séquence dans H2 : 9223372036854775807

Dans notre contexte, il y avait aussi un problème avec le type java boolean, que nos DBAs veulent qu’on stocke en varchar2(1) sur Oracle (« 0″=false, « 1 »=true). On surcharge donc ça dans la « columndefinition » des annotations Hibernate. Mais lors de l’exécution des TUs, on a des erreurs du type :

Value too long for column "COLONNE VARCHAR(1)": "'TRUE' (4)"; SQL statement:...

Solution : remplacer le type varchar2(1) par du boolean dans le SQL pour H2. Cf https://groups.google.com/forum/#!msg/h2-database/me_teu3Shbc/mQ962FG3W30J

Faut-il changer le dialecte Hibernate?

Vaut-il mieux utiliser le dialecte « org.hibernate.dialect.Oracle10gDialect » dans la configuration d’Hibernate (qui a l’avantage d’être le même que celui utilisé en production), ou le dialecte H2 (qui collera mieux aux particularités de H2)?

D’autre part, dans notre contexte, les scripts PL/SQL cités ci-dessus sont générés depuis la configuration Hibernate. Plutôt que de les générer pour Oracle, puis les bidouiller pour les rendre compatibles H2, il serait peut-être plus intelligent de les générer directement pour H2? Il suffit d’utiliser la classe H2Dialect au lieu de l’Oracle10gDialect lors de la génération. Hibernate utilise alors des types plus proches du standard H2. Exemples :

  • varchar(255) au lieu de varchar2(255 char)
  • integer au lieu de number(10,0)
  • bigint au lieu de number(19,0)
  • etc

… Mais comme H2 accepte aussi la syntaxe Oracle, je n’y ai pas trouvé grand intérêt.

Dans tous les cas, j’ai quand même besoin de reprendre les VARCHAR2(1) pour les boolean (puisqu’il s’agit d’une surcharge manuelle dans l’annotation), ainsi que toutes les autres modifications citées ci-dessus. En effet, ce n’est pas Hibernate qui génère du SQL incompatible H2, mais plutôt les ajouts spécifiques de chaque projet.

Au final, le choix s’est donc porté sur le fait de conserver les rechercher/remplacer via expression régulière, et le dialecte Oracle. C’est clairement imparfait, mais n’a jamais posé de problèmes sur les projets concernés, et est beaucoup plus simple à maintenir. D’autre part, ça ne me gêne pas trop de prendre ce risque dans la mesure où on garde le « filet de sécurité » de la PIC (qui passe les TUs sur Oracle).

Parallélisation des tests unitaires

Sans parallélisation, on passe de 5min22 à 5min04. Bof…

Idée : paralléliser les tests unitaires, en recréant la base en mémoire pour chaque classe de test

Comment recréer la base pour chaque TU? Spring permettrait probablement de le faire, mais il y a plus simple : utiliser un runscript dans la chaine de connexion JDBC, qui se charge d’initialiser le schema :

jdbc:h2:mem:;MODE=Oracle;init=runscript from 'classpath:path/to/script_creation.sql'

Dans Maven, on peut configurer le plugin Surefire pour lui dire de lancer un thread de test unitaire par processeur/coeur de la machine :

<plugin>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <reuseForks>false</reuseForks>
        <forkCount>1C</forkCount>
    </configuration>
</plugin>

Résultat : sur certains projets, avec une machine avec 4 coeurs, on gagne 30 à 50% de temps (2min 21s). Avec 6 coeurs, on gagne 60 à 70% (1min 32s). Par contre, attention à avoir suffisamment de RAM pour lancer tout ça en parallèle : si 6 Go suffisent avec 4 threads en parallèle, il faut 10Go avec 6 thread en parallèle. A noter que ces gains semblent beaucoup moins importants avec le JDK8 (qui, je suppose, permet nativement de mieux utiliser les cœurs multiples).

Le mieux est de paramétrer (au sens de Maven) le forkCount, en mettant des valeurs différentes suivant le profil : Chaque développeur peut ainsi choisir de lancer plus ou moins de tests unitaires en parallèle (suivant sa quantité de mémoire vive, par exemple). Et surtout, ça permet de mettre un forkCount à 1 sur la PIC (qui ne peut pas paralléliser les TUs puisqu’elle reste sur une base Oracle).

Problèmes rencontrés

Scripts SQL

Sur les scripts SQL générés depuis Hibernate, il n’y a pas de problèmes (une fois les regex mises au point). Par contre, ceux qui ont été créés à la main peuvent ne pas être acceptés par H2 :

  • Il faut s’assurer que chaque instruction SQL se termine par un point-virgule
  • Il faut supprimer les éventuels paramètres de tablespace ou d’index qui seraient spécifiques Oracle, en s’assurant que ça n’a pas d’impact sur le bon fonctionnement (ex : LOGGING/NOLOGGING)

Datasets DBUnit

On a dû faire quelques modifications mineures sur certains tests DBUnit : s’il y a dans les datasets XML des indicateurs (booléens) renseignés avec les valeurs O/N, il faut les remplacer par les valeurs 1/0 (1/0 fonctionne sur Oracle et H2, alors que O/N ne fonctionne pas sur H2).

Si on a configuré DBUnit pour utiliser une org.dbunit.ext.oracle.OracleConnection, il fait parfois des cast de la connection jdbc en oracle.jdbc.OracleConnection (et on ne peut pas le lui reprocher), ce qui plante si on utilise H2. Cela dit, dans les cas que j’ai pu rencontrer, ça ne concernait que le type Clob (et probablement Blob).

Tests unitaires

Les tests unitaires eux-mêmes ont parfois besoin d’être retouchés (ça s’est avéré très rare) : sans s’en rendre compte on y fait parfois des suppositions sur le fonctionnement de la base (exemple : ordre des éléments renvoyés par une requête qui ne fait pas de tri).

D’autre part, Oracle et H2 n’ont pas le même fonctionnement sur les tris vis-à-vis des valeurs Null : Oracle est par défaut en « nulls last », H2 le contraire. Pour les mettre d’accord, il vaut mieux spécifier cet ordre dans la requête : en mettant un « nulls last » en SQL, ou avec du code du type Order.asc(« ordre »).nulls(NullPrecedence.LAST) en Hibernate.

BigDecimal

Les deux bases ne renvoient pas toujours la même chose en BigDecimal. Je tombe par exemple sur un cas où Oracle et H2 renvoient la même valeur, mais avec un « scale » différent (sérialisé en String, l’un renvoie « 1 », l’autre « 1.0 »). Suivant la manière dont le test de comparaison est codé, cela peut poser problème.

Ce qu’il faut retenir : il faut comparer les BigDecimal avec des compareTo() plutôt que equals(). Cf https://stackoverflow.com/questions/6787142/bigdecimal-equals-versus-compareto

Si vous utilisez AssertJ, il faut utiliser la méthode isEqualByComparingTo() plutôt que isEqualTo() : https://joel-costigliola.github.io/assertj/core/api/org/assertj/core/api/AbstractComparableAssert.html#isEqualByComparingTo%28A%29

D’autre part, le type retourné par les séquences (exemple : « select sequence.nextval from dual ») n’est pas le même entre les 2 bases : Oracle renvoie du BigDecimal quand H2 renvoie du BigInteger. Si on laisse Hibernate gérer les séquences, il s’en débrouille parfaitement bien, mais c’est à prendre en compte si on lance des requêtes SQL natives.

Fonction soundex()

Elle est case-insensitive en Oracle, et case-sensitive avec H2, mais semble bien donner le même résultat dans les deux bases.

Suspendre temporairement les contraintes

Il arrive qu’on ait besoin de suspendre les contraintes, le temps de faire certaines opérations. Cas typiques : insérer des données qui ont des dépendances cycliques, ou vider toutes les tables sans se préoccuper de l’ordre.

Sur Oracle, une solution consiste à lui demander de ne vérifier les contraintes qu’à la fin de la transaction, avec un « SET CONSTRAINTS ALL DEFERRED ».

Sur H2, cette commande n’existe pas. Une solution de contournement consiste à suspendre complètement l’intégrité référentielle (« SET REFERENTIAL_INTEGRITY FALSE »), puis la réactiver (« SET REFERENTIAL_INTEGRITY TRUE »). Attention dans ce cas au fait que ça n’a pas exactement le même comportement :

  • d’après la doc H2, la portée est la base complète (et non uniquement la transaction)
  • il ne faut pas oublier de réactiver l’intégrité référentielle à la fin (quitte à utiliser un try/catch/finally pour prendre en compte le cas où une exception surviendrait)

Rapidité de build vs réactivité du poste de dev

Quand les tests unitaires prennent plusieurs minutes, le développeur peut faire autre chose en attendant le résultat.

Oui, sauf que, pour ça, il faut que sa machine ne soit pas complètement submergée par les TUs en cours. Il est donc fréquent que le paramétrage forkCount=1C ne soit pas l’idéal, bien que ce soit celui qui réduise le plus le temps de build. Nous avons fréquemment utilisé plutôt un forkCount=0.5C (voire 0.75C si vous avez au moins 6 coeurs) : la machine est bien plus réactive, et ça ne ralentit pas le build tant que ça (on passe de 2min20s à 2min25s).

Capacité à consulter le contenu de la base H2 lors d’un point d’arrêt dans un test unitaire

Une problématique est rapidement apparue, qui gênait la mise au point des tests unitaires : pour diagnostiquer un problème dans un test unitaire, on a souvent besoin de regarder le contenu de la base de données pendant son exécution (en mettant un point d’arrêt).

La solution que j’ai mise en place consiste à démarrer un serveur H2 au début du test unitaire, qui permet d’accéder à la base en jdbc. Pour cela, comme le test unitaire est lancé avec JUnit dans un environnement Spring, j’ai surchargé le SpringJUnit4ClassRunner avec une classe dont le constructeur lance le serveur :

Server.createTcpServer("-tcp", "-tcpAllowOthers").start();

Pour qu’on puisse y accéder depuis un client jdbc, il faut également nommer la base H2, en utilisant une chaine jdbc du type :

jdbc:h2:mem:tus-h2;MODE=Oracle;DB_CLOSE_DELAY=-1;init=runscript from 'classpath:sql-create-h2/CREAT_TOUS_H2.sql'

(on l’a ici nommée « tus-h2 », mais n’importe quel nom conviendrait)

Le paramètre DB_CLOSE_DELAY=-1 permet en outre de conserver la base de données tant que le process Java qui fait tourner le TU n’est pas terminé.

De cette manière, on peut accéder à la base avec un client JDBC quelconque (type Squirrel SQL) en utilisant la chaîne de connexion :

jdbc:h2:tcp://localhost:9092/mem:tus-h2;MODE=Oracle

En utilisant le username « PUBLIC », et sans mot de passe.

Oui mais… comment ça se passe quand on lance plusieurs tests unitaires en parallèle? Ils ne peuvent pas ouvrir plusieurs serveurs sur le même port, donc cela va générer des exceptions. La solution a pour nous été de mettre un try/catch autour de la création du serveur (dans le Runner) : dans le cas d’une exception de ce type, on l’ignore avec un simple warning dans la log. De toutes façons, on n’est pas intéressés par le serveur H2 quand on lance plusieurs tests unitaires en parallèle.

En termes de performances, le lancement du serveur H2 semble prendre autour de 800ms sur nos postes de développement. Cela ajoute donc un léger overhead, qu’on a mesuré à 2% sur le temps de build complet, et à 4% sur une méthode de TU seule, dans notre cas de figure (évidemment, cela dépendra beaucoup de ce qu’on fait en test unitaire).

Conclusion

La mise en œuvre a fonctionné sur la majorité des applications concernées. Mais le bilan est plus mitigé que je ne l’avais espéré au départ.

Concernant les performances, le passage au JDK8 semble avoir largement réduit l’intérêt de lancer plusieurs tests unitaires en parallèle. Dans notre cas de figure, on gagne toujours un peu (à cause de certaines étapes qui ne savent pas exploiter le multi-thread) : environ 10% sur un build complet (même en lançant le serveur H2 pour debug).

On est tombé sur des cas où le temps de build était dégradé, à cause d’une grosse lenteur sur certaines requêtes (alors qu’elles passent très rapidement sur Oracle). On n’a pas eu le temps de creuser pourquoi : on a laissé tombé H2 sur ces 2 cas.

Il est toujours confortable de ne pas avoir de dépendance à une base Oracle, cela rend parfois grand service (développement en mode déconnecté, par exemple). D’un autre côté, quand les développeurs ont un problème de test unitaire, ils se demandent parfois si ça ne viendrait pas de H2, et re-basculent en Oracle pour en avoir le cœur net.

Un point positif que nous avons découvert par la suite est que H2 donne plus de détail quand il y a une erreur d’intégrité référentielle (ex : dans le setUp ou tearDown DBUnit) : il donne l’identifiant de l’enregistrement concerné, alors qu’Oracle ne donne aucune information. C’est précieux en debugging des tests unitaires.

Malgré tout, l’équipe de développement n’a quasiment pas utilisé cette possibilité de basculer sur H2 : ils y voyaient peu d’intérêt pour leur travail quotidien, et quelques contraintes supplémentaires. Je ne peux pas leur donner tort dans ce contexte.

J’ai quand même décidé de publier cet article, car ça pourrait rendre service à d’autres, pour qui le contexte serait plus favorable.

2 réflexions sur « Tests unitaires Hibernate sous H2 au lieu d’Oracle »

  1. Bonjour,
    Merci pour cette article. Je suis en train de réaliser le même travail il m’a bien aidé.
    Avez-vous trouvé un moyen pour que la base H2 supporte complètement la syntaxe oracle sur les SELECT, notamment les demi-jointures avec (+)?

    1. Content que ça ait rendu service.
      Non, je n’ai pas eu besoin de supporter cette syntaxe. Pas sûr que ce soit possible de le faire…

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *