Il y a parfois (souvent/toujours) des notions ou des concepts qui semblent nouveaux, mais qui sont en réalité vieux comme le monde. On a toujours l’impression de découvrir de nouvelles choses alors qu’il ne s’agit que de vieux concepts nommés différemment ou markettés.

Pas plus tard qu’hier dans un podcast, j’entendais l’animateur parler du fait qu’il faisait déjà du calcul de similarité cosinus sur Apache Mahout alors que cette technique est encore et toujours plus utilisée pour implémenter les RAG dans les applications qui veulent intégrer des LLM. Par cette occasion, il nous enjoignait à lire ou à relire les vieux papiers de recherche sur ces sujets.

Sans en arriver à déchiffrer une thèse, je re-découvre en 2025 une technique de test nommée Approval Tests qui mérite qu’on s’y penche et qu’on l’expérimente dans son langage favori — évidemment.

Introduction et démystification

En 2011, j’expérimente pendant deux ans le développement et la maintenance d’un logiciel financier dans une équipe XP avec du développement en pair programming. La testabilité des algorithmes de calcul de performances est un gros sujet. Sauf qu’en finance, on a souvent beaucoup de données, pas mal d’exemples et de cas particuliers. De plus, les experts du domaine ne fonctionnent souvent qu’avec Excel. C’est pourquoi cette équipe avait mis au point un système plutôt malin de jeux de tests basés sur les fichiers Excel de référence des experts en calcul de performances financières. On avait donc une sorte de BDD sans fioriture où les assertions ne sont que des comparaisons de résultats entre un fichier Excel et les sorties du moteur de calcul.

C’est à peu près ce mécanisme qui est à l’œuvre dans l’approche Approval Tests : ne plus écrire directement les assertions les unes après les autres, mais utiliser une capture de l’état de sortie du système sous test.

Bien ou bien ?

Bien sûr, on a une liste d’avantages et d’inconvénients à utiliser cette méthode :

  1. :+1: simplifie et accélère la partie then du triplet given, when, then
  2. :-1: moins d’expressivité et de concision dans les assertions
  3. :-1: des fichiers plats qui encombrent les sources
  4. :-1: lors d’évolutions des tests, des mises à jour de ces fichiers au moins aussi fastidieux que les assertions dans le code
  5. :+1: une implémentation directe du principe de golden master pour faire du refacto sur une partie de code qui ne doit pas changer ses contrats d’interface

Implémentation en Scala avec MUnit et la librairie Java

Approval Tests est un site web qui date beaucoup, avec un dernier article de blog (sur blogger !) datant de juillet 2014. Toutefois, la librairie Java qui l’implémente est maintenue activement. Je vais donc l’utiliser pour proposer un Approval Test du kata Gilded Rose.

Mise en route

On crée un projet à partir de zéro :

sbt new scala/scala3.g8

On recopie le kata (vous remarquerez cette belle forêt de ifs :deciduous_tree:) :

package name.lemerdy.sebastian.gildedrose

import name.lemerdy.sebastian.gildedrose.Program.Item

import scala.io.StdIn.readLine

class Program(val items: List[Item]):

  def updateQuality(): Unit =
    for (i <- items.indices)
      if (!items(i).name.equals("Aged Brie") && !items(i).name.equals("Backstage passes to a TAFKAL80ETC concert"))
        if (items(i).quality > 0)
          if (!items(i).name.equals("Sulfuras, Hand of Ragnaros"))
            items(i).quality = items(i).quality - 1
      else if (items(i).quality < 50)
        items(i).quality = items(i).quality + 1

        if (items(i).name.equals("Backstage passes to a TAFKAL80ETC concert"))
          if (items(i).sellIn < 11)
            if (items(i).quality < 50)
              items(i).quality = items(i).quality + 1

          if (items(i).sellIn < 6)
            if (items(i).quality < 50)
              items(i).quality = items(i).quality + 1

      if (!items(i).name.equals("Sulfuras, Hand of Ragnaros"))
        items(i).sellIn = items(i).sellIn - 1

      if (items(i).sellIn < 0)
        if (!items(i).name.equals("Aged Brie"))
          if (!items(i).name.equals("Backstage passes to a TAFKAL80ETC concert"))
            if (items(i).quality > 0)
              if (!items(i).name.equals("Sulfuras, Hand of Ragnaros"))
                items(i).quality = items(i).quality - 1
          else
            items(i).quality = items(i).quality - items(i).quality
        else if (items(i).quality < 50)
          items(i).quality = items(i).quality + 1

object Program:

  class Item(val name: String, var sellIn: Int, var quality: Int)
  
  @main def main(): Unit =
    println("OMGHAI!")

    val app = new Program(
      List(
        new Item("+5 Dexterity Vest", 10, 20),
        new Item("Aged Brie", 2, 0),
        new Item("Elixir of the Mongoose", 5, 7),
        new Item("Sulfuras, Hand of Ragnaros", 0, 80),
        new Item("Backstage passes to a TAFKAL80ETC concert", 15, 20),
        new Item("Conjured Mana Cake", 3, 6)
      )
    )

    app.updateQuality()

    readLine()
    ()

On ajoute un test :

package name.lemerdy.sebastian.gildedrose

import munit.FunSuite
import name.lemerdy.sebastian.gildedrose.Program.Item

class ProgramSuite extends FunSuite:
  test("19 iterations"):
    val dextVest = new Item("+5 Dexterity Vest", 10, 20)
    val agedBrie = new Item("Aged Brie", 2, 0)
    val elixir__ = new Item("Elixir of the Mongoose", 5, 7)
    val sulfuras = new Item("Sulfuras, Hand of Ragnaros", 0, 80)
    val concert_ = new Item("Backstage passes to a TAFKAL80ETC concert", 15, 20)
    val conjured = new Item("Conjured Mana Cake", 3, 6)
    val app = new Program(List(dextVest, agedBrie, elixir__, sulfuras, concert_, conjured))

    for _ <- 1 until 20 do app.updateQuality()

    assertEquals(dextVest.sellIn, -9)
    assertEquals(dextVest.quality, 0)
    assertEquals(agedBrie.sellIn, -17)
    assertEquals(agedBrie.quality, 36)
    assertEquals(elixir__.sellIn, -14)
    assertEquals(elixir__.quality, 0)
    assertEquals(sulfuras.sellIn, 0)
    assertEquals(sulfuras.quality, 80)
    assertEquals(concert_.sellIn, -4)
    assertEquals(concert_.quality, 0)
    assertEquals(conjured.sellIn, -16)
    assertEquals(conjured.quality, 0)

On observe ici que les assertions sont assez longues à écrire et pas très expressives. C’est un bon candidat pour être transformé en Approval Test.

Approval Test avec MUnit : échec

On ajoute la dépendance à la librairie :

libraryDependencies += "com.approvaltests" % "approvaltests" % "24.21.1" % Test

On modifie le test :

  import munit.FunSuite
  import name.lemerdy.sebastian.gildedrose.Program.Item
+ import org.approvaltests.Approvals
  
  class ProgramSuite extends FunSuite:
      for _ <- 1 until 20 do app.updateQuality()
  
-     assertEquals(dextVest.sellIn, -9)
-     assertEquals(dextVest.quality, 0)
-     assertEquals(agedBrie.sellIn, -17)
-     assertEquals(agedBrie.quality, 36)
-     assertEquals(elixir__.sellIn, -14)
-     assertEquals(elixir__.quality, 0)
-     assertEquals(sulfuras.sellIn, 0)
-     assertEquals(sulfuras.quality, 80)
-     assertEquals(concert_.sellIn, -4)
-     assertEquals(concert_.quality, 0)
-     assertEquals(conjured.sellIn, -16)
-     assertEquals(conjured.quality, 0)
+     Approvals.verify(app.items.map(item => s"${item.name} ${item.sellIn} ${item.quality}").mkString("\n"))

Ça nous fait gagner pas mal de lignes. On constate bien qu’on économise des assertions. Je suis content avec ce diff mais malheureusement lorsque je lance le test ça ne va pas se passer comme prévu :

java.lang.RuntimeException: Could not find Junit/TestNg TestCase you are running, supported frameworks: Junit3, Junit4, Junit5, TestNg

	at org.approvaltests.namer.AttributeStackSelector.selectElement(AttributeStackSelector.java:70)
	at com.spun.util.tests.TestUtils.getCurrentFileForMethod(TestUtils.java:181)
	at com.spun.util.tests.TestUtils.getCurrentFileForMethod(TestUtils.java:174)
	at org.approvaltests.namer.StackTraceNamer.<init>(StackTraceNamer.java:17)
	at org.approvaltests.Approvals$1.load(Approvals.java:46)
	at org.approvaltests.Approvals$1.load(Approvals.java:43)
	at org.approvaltests.Approvals.createApprovalNamer(Approvals.java:279)
	at com.spun.util.ArrayUtils.getOrElse(ArrayUtils.java:309)
	at org.approvaltests.core.Options$FileOptions.getNamer(Options.java:120)
	at org.approvaltests.Approvals.verify(Approvals.java:196)
	at org.approvaltests.Approvals.verify(Approvals.java:55)
	at org.approvaltests.Approvals.verify(Approvals.java:51)
	at name.lemerdy.sebastian.gildedrose.ProgramSuite.$init$$$anonfun$1(ProgramSuite.scala:19)

Bien que MUnit se targue d’avoir été construit au dessus de JUnit pour bénéficier de l’écosystème, on voit ici que ce n’est pas suffisant pour nous permettre d’utiliser la librairie Java. Qu’à cela ne tienne, on peut tenter la pull request qui apporte le support de MUnit dans approval ?

Ajout du support de MUnit dans Approval Test : échec

Dans le fichier approvaltests/src/main/java/org/approvaltests/namer/AttributeStackSelector.java :

      if (isJunit3Test(clazz))
      { return true; }
+     if (isMunitSuite(clazz))
+     { return true; }
      if (isTestAttribute(clazz, TestUtils.unrollLambda(element.getMethodName())))
      { return true; }
    private boolean isJunit3Test(Class<?> clazz)
    {
      Class<?> testcase = loadClass("junit.framework.TestCase");
      return testcase != null && ObjectUtils.isThisInstanceOfThat(clazz, testcase);
    }
+   private boolean isMunitSuite(Class<?> clazz)
+   {
+     Class<?> suite = loadClass("munit.PlatformSuite");
+     return suite != null && ObjectUtils.isThisInstanceOfThat(clazz, suite);
+   }

Installation de la version de développement (100.0.0-SNAPSHOT sur la branche par défaut) :

mvn install

Ajout de cette version dans notre projet :

-     libraryDependencies += "com.approvaltests" % "approvaltests" % "24.21.1" % Test,
+     libraryDependencies += "com.approvaltests" % "approvaltests" % "100.0.0-SNAPSHOT" % Test,
+     resolvers += Resolver.mavenLocal,

On passe l’exécution du test, mais malheureusement, on fait face à un problème insoluble : Approvals ne pourra jamais connaitre le nom de notre test, car MUnit déclare tous les tests dans des fonctions anonymes. Le fichier à approuver, bien que son contenu soit correct, a un nom indésirable :

ProgramSuite.$init$$$anonfun$1.received.txt

Intégration de Approval Test dans MUnit sous la forme d’une suite réutilisable

Voici la solution à laquelle j’ai abouti pour ajouter le support de la librairie Java Approval Tests dans MUnit :

package name.lemerdy.sebastian.gildedrose

import munit.{FunSuite, Location, TestOptions}
import name.lemerdy.sebastian.gildedrose.ApprovalsSuite.MunitApprovalNamer
import org.approvaltests.Approvals
import org.approvaltests.core.Options
import org.approvaltests.namer.{ApprovalNamer, NamerFactory}

import java.io.File
import java.nio.file.Paths

trait ApprovalsSuite extends FunSuite:

  override def test(options: TestOptions)(body: => Any)(using loc: Location): Unit =
    super.test(options):
      NamerFactory.setAdditionalInformation(options.name)
      body

  def verify(response: String)(using location: Location): Unit =
    Approvals.verify(response, new Options().forFile().withNamer(MunitApprovalNamer(location)))

object ApprovalsSuite:

  private class MunitApprovalNamer(location: Location) extends ApprovalNamer:
    private val path = Paths.get("/").resolve(location.path)
    override def getApprovalName: String = NamerFactory.getAdditionalInformation.drop(".".length).replaceAll(" ", "_")
    override def getSourceFilePath: String = path.getName(path.getNameCount - 1).toString.dropRight(".scala".length)
    override def getApprovedFile(extensionWithDot: String): File = getFile(extensionWithDot, "approved")
    override def getReceivedFile(extensionWithDot: String): File = getFile(extensionWithDot, "received")
    private def getFile(extensionWithDot: String, status: String): File =
      path.getParent.resolve(s"$getSourceFilePath.$getApprovalName.$status$extensionWithDot").toFile
    override def addAdditionalInformation(info: String): ApprovalNamer = this
    override def getAdditionalInformation: String = ""

On obtient maintenant bien un fichier à approuver de cette forme :

ProgramSuite.19_iterations.received.txt

Voici le contenu du fichier :

+5 Dexterity Vest -9 0
Aged Brie -17 36
Elixir of the Mongoose -14 0
Sulfuras, Hand of Ragnaros 0 80
Backstage passes to a TAFKAL80ETC concert -4 0
Conjured Mana Cake -16 0

Cette solution n’est pas exempte de défauts :

  1. on doit explicitement étendre de ApprovalsSuite, ce qui ne me plaît pas plus que ça
  2. on utilise un ThreadLocal caché derrière NamerFactory.setAdditionalInformation :fearful:

Mais elle permet tout de même de prouver le concept.

Conclusion

Je vous conseille d’utiliser les Approval Tests lorsque l’écriture des assertions n’apporte pas d’intérêt et que vous souhaitez capturer un état brut de sortie du système sous test. Gardez à l’esprit que généraliser ce principe à de nombreux tests pourra s’avérer fastidieux lorsque le comportement et donc la sortie du système change.

Ce principe d’Approval Tests a été largement étendu notamment dans les libraires de composant front par exemple ; par la librairie Snapshot4s ainsi que le Documentation testing qui est abordé dans cet article.