JMockit a longtemps été ma bibliothèque favorite pour des similis ("mocks") parce qu'il y a moyen de vérifier que toutes les invocations souhaitées ont eu lieu, pas une de plus et pas une de moins. Et il est capable de redéfinir des comportements réputés non-redéfinissables, genre ``java.lang.System.currentTimeMillis()``.
Mais sur "Chiron"
, la migration de `JMockit-1.20` vers `1.39` a été un véritable carnage, avec des comportements bizarres pour tous les tests avec des accès concurrents sur les similis.
Je rappelle brièvement le fonctionnement de JMockit. JMockit oriente l'écriture des tests vers une séquence enregistre-rejoue-vérifie. Pour indiquer quelle valeur devra renvoyer un simili ça se fait lors de l'enregistrement.
Pour les exemples nous utiliserons un simili pour le contrat suivant (tout le code exemple est disponible "ici"
) :
<<<
interface Engine {
long addSync( final int term1, final int term2 ) ;
void addAsync(
final int term1,
final int term2,
final LongConsumer resultConsumer
) ;
}
>>>
La phase enregistrement ressemble à ça :
<<<
@Test
public void record( @Injectable final Engine engine ) {
new Expectations() {{
engine.addSync( 3, 2 ) ;
result = 5 ;
}} ;
// System under test + assertion.
assertThat( engine.addSync( 3, 2 ) ).isEqualTo( 5 ) ;
}
>>>
Dans le bloc ``Expectations`` on dit quelle valeurs on attend pour les paramètres de ``addSync(_)``, mais on peut aussi utiliser ``any``. La valeur retournée est 144, indiquée par l'assignation de ``result``.
Notons quelques gracieusetés syntaxiques :
- La déclaration d'un simili comme paramètre de la méthode de test.
- L'initialiseur anonyme dans la classe dérivée de ``Expectations`` qui restreint l'accès à certains symboles magiques comme ``any`` ou ``result`` à la portée de l'initialiseur.
On peut aussi vérifier que certaines opérations ont bien eu lieu :
<<<
@Test
public void verify( @Injectable final Engine engine ) {
engine.addSync( 3, 2 ) ; // Result is 0.
new Verifications() {{
engine.addSync( 3, 2 ) ;
}} ;
}
>>>
Mais là pas moyen de s'intéresser à la valeur de retour, quand on appelle la méthode du simili JMockit fournit une valeur par défaut (0 ou ``null``). On peut alterner les ``Expectations`` et les ``Verifications``, avec une ``FullVerifications`` qui garantit que seul les appels attendus ont eu lieu.
Maintenant nous allons corser le truc avec une fonction de rappel.
<<<
@Test
public void capture( @Injectable final Engine engine ) throws InterruptedException {
final LongConsumer resultConsumer = l -> { } ;
// What System Under Test could do.
final Thread computationRunner = new Thread(
() -> {
LOGGER.info( "Sleeping a bit ..." ) ;
Uninterruptibles.sleepUninterruptibly( 1, TimeUnit.SECONDS ) ;
LOGGER.info( "Now we perform addition ..." ) ;
engine.addAsync( 12, 2, resultConsumer ) ;
LOGGER.info( "Addition complete." ) ;
},
"computation-runner"
) ;
computationRunner.start() ;
new Verifications() {{
final LongConsumer captured ;
LOGGER.info( "Verifying ..." ) ;
engine.addAsync( 12, 2, captured = withCapture() ) ;
LOGGER.info( "Verification complete." ) ;
assertThat( captured ).isSameAs( resultConsumer ) ;
}} ;
computationRunner.join() ;
}
>>>
L'intention est simple : on récupère dans ``captured`` la valeur passée au simili à partir d'un autre fil d'exécution. C'est là que ça foire : JMockit ne sait pas attendre que la méthode ``addAsync`` soit appelée, donc il fournit une valeur par défaut :
<<<
java.lang.AssertionError:
Expecting:
<com.otcdlink.chiron.fixture.demo.JMockitDemo$$Lambda$1/1579526446@1c72da34>
and actual:
<null>
to refer to the same object
>>>
Dans ce cas on voudrait que dans les ``Verifications`` l'appel à ``addAsync`` bloque jusqu'à ce que le véritable appel ait lieu. Mais à la décharge de JMockit il y a des cas où cela générerait des interblocages, notamment si on a des similis interdépendants.
(Oui j'ai aussi essayé le ``withCapture( List )`` avec une liste se comportant comme une ``BlockingQueue``) mais ça ne se passait pas très bien avec plusieurs captures, parce qu'on ne peut définir une capture qu'une seule fois, donc on ne voit plus dans la séquence du test à quel moment elles doivent avoir lieu.)
Je passe d'autres comportements plus obscurs, mais il est clair que JMockit n'est pas adapté à des tests impliquant de la concurrence d'accès sur les similis.
Donc la solution c'est d'écrire sa propre bibliothèque de similis (une de plus) et c'est ce qui me vaut l'honneur de présenter "Chiron-mockster"
.
Cette bibliothèque est destinée à un cas d'utilisation très restreint :
- Tout le code à tester s'exécute dans un autre fil d'exécution que le test unitaire.
- Le test unitaire ne sait faire que des vérifications.
- Les similis sont nécessairement des interfaces.
Comme dirait Linus Torvalds "Fais péter le code au lieu de causer !" ("Talk is cheap, show me the code.")
<<<
@Test
public void capture() throws InterruptedException {
try( final Mockster mockster = new Mockster() ) {
final Engine engine = mockster.mock( Engine.class ) ;
final LongConsumer resultConsumer = l -> { } ;
// What System Under Test could do.
final Thread computationRunner = new Thread(
() -> {
LOGGER.info( "Sleeping a bit ..." ) ;
Uninterruptibles.sleepUninterruptibly( 1, TimeUnit.SECONDS ) ;
LOGGER.info( "Now we perform addition ..." ) ;
engine.addAsync( 12, 2, resultConsumer ) ;
LOGGER.info( "Addition complete." ) ;
},
"computation-runner"
) ;
computationRunner.start() ;
final LongConsumer captured ;
LOGGER.info( "Verifying ..." ) ;
engine.addAsync( exactly( 12 ), exactly( 2 ), captured = withCapture() ) ;
LOGGER.info( "Verification complete." ) ;
assertThat( captured ).isSameAs( resultConsumer ) ;
computationRunner.join() ;
}
}
>>>
Et là ça marche.
On remarque la forte influence de JMockit dans certains choix de syntaxe, notamment le ``withCapture()``.
Bon il y a des petits trucs agaçants. Par exemple il faut dupliquer le type du simili, une fois dans la déclaration et une fois comme paramètre de ``mockster.mock(_)``. Et quand on a un paramètre magique comme ``withCapture()`` pas moyen de mélanger avec des litéraux, il faut utiliser ``exactly(_)`` pour ceux-là. Mais une fois qu'on l'a codé on voit que c'est inévitable. Le ``try()`` pour le Mockster est sympa mais on n'a pas la limitation de portée syntaxique, donc on finit par taper dans des méthodes statiques visibles de partout comme ``withCapture()``, qui échoueront s'il n'y a pas une instance de ``Mockster`` attachée au fil d'exécution en cours.
Oui on fait un peu de magie noire avec les fils d'exécution. Par exemple un appel de ``withCapture()`` va bloquer le fil d'exécution du test jusqu'à ce que la méthode du système à tester l'appelle de son propre fil d'exécution, mais restant bloquée jusqu'à ce que la méthode ``addAsync(_)`` autour du ``withCapture()`` se termine, ce qui implique l'exécution de toutes les méthodes "magiques" représentant des vérifications des arguments.
Autrement dit c'est facile de générer de l'interblocage si on n'a pas une exécution complètement séquentialisable du code à tester. Comment faire si on a un simili pour un client et un autre pour un serveur, et qu'ils s'exécutent concurremment, chacun avec ses fils d'exécution ? Réponse : on est mort. Le truc c'est d'avoir //un// fil d'exécution partagé par le client et le serveur, ce qui est possible avec l'``EventLoop`` de Netty. Je ne suis pas certain que les auteurs de Netty aient imaginé que l'``EventLoop`` partageable allait rendre possible des tests d'intégration séquentialisés mais dans tous les cas chapeau bas pour ces messieurs.
Si on veut voir ce que ça donne en vrai il y a les "tests d'intégration de Chiron"
qui utilisent un niveau d'abstraction par-dessus le ``Mockster`` : le ``ConnectorDrill`` qui instancie tous les composants spécifiés dans le test (similis, upend, downend, proxy HTTP ...).
Je ne vais pas rentrer dans tous les détails du Mockster, parce que les gens avec des besoins aussi pointus ont probablement déjà codé leur solution. En plus vu de loin le Mockster ressemble furieusement à un bloc ``Verifications`` de JMockit donc on peut se demander où est la nouveauté.
Elle se situe juste dans la façon de délimiter les appels au simili pour l'enregistrement-vérification, de ceux effectués par le système à tester. JMockit effectue cette séparation grâce à une utilisation astucieuse de l'initialiseur anonyme. Avec le Mockster la séparation se fait grâce au fil d'exécution en cours. Si le code est dans le fil d'exécution du test c'est qu'on vérifie, autrement c'est un appel à vérifier. Je n'ai pas trop regardé mais à ma connaissance le Mockster est le premier à faire cela.
Bon c'est pas une grande révolution non plus mais ça me fait du bien d'en parler, après avoir transpiré comme un âne à essayer de faire fonctionner un JMockit qui n'est manifestement pas fait pour.