Documentation testing en Scala

Dans l’article précédent, on a vu que certains tests étaient éligibles à l’approche Approval Tests. On peut encore aller plus loin et tenter l’approche Documentation testing. La librairie est en Java et écrite spécifiquement pour JUnit. Cela ne nous permet donc pas de l’utiliser directement avec Scala et MUnit. On ne va pas aller aussi loin que l’exemple de documentation généré pour le kata par Sébastien Fauvel avec ses graphes mais on va prouver que ça fonctionne.
Documentation testing : des Approval tests qui font d’une pierre deux coups
Pour justifier l’approche, on pourrait dire que le Documentation testing permet de finalement
trouver une utilité à ces fichiers plats contenant les sorties approuvées. Ils deviennent plus lisibles, mis en forme et
exportables comme documentation vivante. Ça se rapproche également du
plugin sbt que je maintiens et qui génère une documentation à partir des scénarios
décris par le trait GivenWhenThen
de ScalaTest. Ça fera sûrement l’objet d’un prochain article.
L’impact sur le test : en mode verbeux
Qui dit documentation implique forcément un peu plus de choses à écrire. Voici la transformation qui passe d’Approval Tests au Documentation testing :
class ProgramSuite extends ApprovalsSuite:
- test("19 iterations"):
+ test("19 iterations with standard item"):
+ val itemName = "+5 Dexterity Vest"
+ val item = new Item(itemName, 10, 20)
- 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))
+ val app = new Program(List(item))
- for _ <- 1 until 20 do app.updateQuality()
+ val results = for i <- 1 until 20 yield
+ app.updateQuality()
+ i -> new Item("", item.sellIn, item.quality)
- verify(app.items.map(item => s"${item.name} ${item.sellIn} ${item.quality}").mkString("\n"))
+ verify(s"""== Standard item $itemName
+ |
+ |* All items have a SellIn value which denotes the number of days we have to sell the item
+ |* All items have a Quality value which denotes how valuable the item is
+ |* At the end of each day our system lowers both values for every item
+ |* Once the sell by date has passed, Quality degrades twice as fast
+ |* The Quality of an item is never negative
+ |* The Quality of an item is never more than 50
+ |
+ ||===
+ ||Iteration |0 |${results.map((iteration, _) => iteration).mkString("| ")}
+ |
+ ||SellIn |10 |${results.map((_, item) => item.sellIn).mkString("| ")}
+ ||Quality |20 |${results.map((_, item) => item.quality).mkString("| ")}
+ ||===""".stripMargin)
On peut voir qu’on passe du test de tous les types d’item en même temps à un seul qui regroupe toutes les règles communes. Le format de sortie change également, passant d’une simple chaîne de caractères brute à de l’asciidoc.
Voici ce qu’on obtient en utilisant le plugin AsciiDoc de l’IDE par exemple :
Standard item +5 Dexterity Vest
- All items have a SellIn value which denotes the number of days we have to sell the item
- All items have a Quality value which denotes how valuable the item is
- At the end of each day our system lowers both values for every item
- Once the sell by date has passed, Quality degrades twice as fast
- The Quality of an item is never negative
- The Quality of an item is never more than 50
Iteration 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 SellIn 10 9 8 7 6 5 4 3 2 1 0 -1 -2 -3 -4 -5 -6 -7 -8 -9 Quality 20 19 18 17 16 15 14 13 12 11 10 8 6 4 2 0 0 0 0 0
Je trouve ça plutôt cool d’avoir quelque chose d’aussi lisible et compréhensible.
Comment on en est arrivés là ?
Étonnamment peu de diffs sont nécessaires par rapport à la proposition de la suite de base de l’article sur les Approval Tests. On a besoin :
- d’overrider l’extension par défaut
.txt
par.adoc
- de remplacer
,
car l’un de mes intitulés de test contient ce caractère et il n’est pas souhaitable de l’inclure dans le nom du fichier généré
def verify(response: String)(using location: Location): Unit =
- Approvals.verify(response, new Options().forFile().withNamer(MunitApprovalNamer(location)))
+ Approvals.verify(
+ response,
+ new Options().forFile().withExtension(".adoc").forFile().withNamer(MunitApprovalNamer(this, 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 getApprovalName: String =
+ NamerFactory.getAdditionalInformation.drop(".".length).replaceAll("[ ,]", "_")
override def getSourceFilePath: String = path.getName(path.getNameCount - 1).toString.dropRight(".scala".length)
N’aurait-on pas oublié les autres tests ?
Il faut par la suite faire de même pour les autres types d’item.
test("19 iterations with Aged Brie"):
val itemName = "Aged Brie"
val item = new Item(itemName, 2, 0)
val app = new Program(List(item))
val results = for i <- 1 until 20 yield
app.updateQuality()
i -> new Item("", item.sellIn, item.quality)
verify(s"""== $itemName
|
|* "Aged Brie" actually increases in Quality the older it gets
|
||===
||Iteration |0 |${results.map((iteration, _) => iteration).mkString("| ")}
|
||SellIn |2 |${results.map((_, item) => item.sellIn).mkString("| ")}
||Quality |0 |${results.map((_, item) => item.quality).mkString("| ")}
||===""".stripMargin)
test("19 iterations with Sulfuras, Hand of Ragnaros"):
val itemName = "Sulfuras, Hand of Ragnaros"
val item = new Item(itemName, 0, 80)
val app = new Program(List(item))
val results = for i <- 1 until 20 yield
app.updateQuality()
i -> new Item("", item.sellIn, item.quality)
verify(s"""== $itemName
|
|* "Sulfuras", being a legendary item, never has to be sold or decreases in Quality
|
||===
||Iteration |0 |${results.map((iteration, _) => iteration).mkString("| ")}
|
||SellIn |0 |${results.map((_, item) => item.sellIn).mkString("| ")}
||Quality |80 |${results.map((_, item) => item.quality).mkString("| ")}
||===""".stripMargin)
test("19 iterations with Backstage passes"):
val itemName = "Backstage passes to a TAFKAL80ETC concert"
val item = new Item(itemName, 15, 20)
val app = new Program(List(item))
val results = for i <- 1 until 20 yield
app.updateQuality()
i -> new Item("", item.sellIn, item.quality)
verify(s"""== $itemName
|
|* "Backstage passes", like aged brie, increases in Quality as it’s SellIn value approaches; Quality increases by 2 when there
| are 10 days or less and by 3 when there are 5 days or less but Quality drops to 0 after the concert
|
||===
||Iteration |0 |${results.map((iteration, _) => iteration).mkString("| ")}
|
||SellIn |15 |${results.map((_, item) => item.sellIn).mkString("| ")}
||Quality |20 |${results.map((_, item) => item.quality).mkString("| ")}
||===""".stripMargin)
Et ainsi obtenir quatre fichiers approuvés :
ProgramSuite.19_iterations_with_Aged_Brie.approved.adoc
ProgramSuite.19_iterations_with_Backstage_passes.approved.adoc
ProgramSuite.19_iterations_with_Sulfuras__Hand_of_Ragnaros.approved.adoc
ProgramSuite.19_iterations_with_standard_item.approved.adoc
En l’état, on constate toutefois qu’il est fastidieux d’aller ouvrir chaque fichier asciidoc un à un pour comprendre la sortie d’une suite. On va donc avoir besoin d’une adaptation supplémentaire pour faire en sorte qu’il puisse exister un fichier asciidoc par suite et incluant les sorties de chacun des tests.
Une documentation par suite
C’est chose faite en exploitant
- d’une part la capacité d’inclusion d’asciidoc
- d’autre part la modification de la suite commune pour enregister chaque sortie à mesure qu’elles sont produites afin d’itérer à la fin de la suite et produire le fichier qui inclue l’entièreté de la documentation
Voici la documentation produite dans le fichier ProgramSuite.adoc
:
= ProgramSuite
include::ProgramSuite.19_iterations_with_standard_item.approved.adoc[]
include::ProgramSuite.19_iterations_with_Aged_Brie.approved.adoc[]
include::ProgramSuite.19_iterations_with_Sulfuras__Hand_of_Ragnaros.approved.adoc[]
include::ProgramSuite.19_iterations_with_Backstage_passes.approved.adoc[]
Voici l’adaptation nécessaire dans la suite de base :
trait ApprovalsSuite extends FunSuite:
+ private val approvals = mutable.Buffer[String]()
+
+ override def afterAll(): Unit =
+ val location = implicitly[Location]
+ val path = Paths.get("/").resolve(location.path)
+ val suiteName = getClass.getSimpleName
+ val suiteFile = path.getParent.resolve(s"$suiteName.adoc")
+ Files.writeString(suiteFile, s"= $suiteName\n${approvals.map(approved => s"\ninclude::$approved[]\n").mkString}")
+ ()
override def test(options: TestOptions)(body: => Any)(using loc: Location): Unit =
object ApprovalsSuite:
- private class MunitApprovalNamer(location: Location) extends ApprovalNamer:
+ private class MunitApprovalNamer(approvalsSuite: ApprovalsSuite, 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 getApprovedFile(extensionWithDot: String): File =
+ val approvedFile = getFile(extensionWithDot, "approved")
+ approvalsSuite.approvals.addOne(approvedFile.getName)
+ approvedFile
override def getReceivedFile(extensionWithDot: String): File = getFile(extensionWithDot, "received")
Conclusion
La capacité d’un test à devenir une documentation vivante est quelque chose d’assez précieux et selon moi pas assez souvent exploité dans nos projets. Je pense que cette démarche a de la valeur mais que, comme d’habitude, il faut savoir l’employer avec partimonie et ne pas la généraliser partout.
Crédit photo
Inspired EHRs Timeline par Juhan Sonin sous licence CC BY 2.0.