Vous pensez que l'herbe est plus verte ailleurs ? Allez donc faire un tour sur The Daily WTF

Passer de Java à Scala – Partie 8 – Tests unitaires

Passer de Java à Scala – Partie 8 – Tests unitaires

Après avoir vu ma vidéo « Pourquoi les tests unitaires sont-ils importants ? », vous ne serez probablement pas surpris de trouver ce chapitre dans cette série 🙂

Retrouvez le code source des exemples ici : https://github.com/alexandrelanglais/scala-tutorial-8

Retrouvez tous les articles de la série « Passer de Java à Scala »

Article adapté de la documentation officielle de ScalaTest : http://www.scalatest.org/user_guide

Assertions

En Scala, on retrouve l’instruction assert déjà présente en Java qui permet de valider ou non une expression.

Mais également, nous disposons de l’instruction ensuring qui permet de faire un assert à la sortie d’un bloc de code, sans avoir à stocker le retour du bloc dans une variable temporaire et appeler assert dessus :

object TestEnsuring extends App {
  def evenIt(x:Int) = {
    if(x % 2 == 0)
      x + 1 // on va faire péter ensuring !
    else
      x + 1
  } ensuring( _ % 2 == 0)

  evenIt(2)
}

Tests unitaires

On dispose de beaucoup de frameworks pour effectuer les tests unitaires en Scala : les frameworks Java (JUnit et TestNG) et les frameworks Scala (Scala test, Specs et Scala check).

Dans cet article, je ne présenterai que Scala test car c’est celui sur lequel vous aurez le plus de chances de tomber dans le futur. Cela dit je vous encourage à étudier les 2 autres frameworks Scala qui sont également très intéressants.

Installation de ScalaTest

Pour ajouter ScalaTest à votre projet, modifiez les dépendances de votre fichier sbt comme ceci :

libraryDependencies += "org.scalactic" %% "scalactic" % "3.0.1"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.1" % "test"

Rafraichissez votre projet et voilà ! Vous êtes prêts à écrire vos tests.

Mais avant cela, quelques concepts de base concernant Scala Test :

  • Le concept principal de ScalaTest est la suite, un ensemble allant de zéro à n tests.
  • Un test peut être n’importe quoi à partir du moment où il a un nom, qu’il peut démarrer et, au choix : réussir, échouer, être en attente ou annulé.
  • L’unité centrale de composition de tests est Suite, qui représente une suite de tests.
  • Le trait Suite déclare la méthode run et d’autres méthodes appartenant au cycle de vie du test définissant une manière d’écrire et de lancer les tests.
  • Ces méthodes du cycle de vie du test peuvent être surchargées pour personnaliser la manière dont les tests sont écrits et lancés.
  • ScalaTest offre des traits de style qui étendent Suite et permettent différents styles de tests.
  • On définit des classes de tests en composant une Suite et en mixant des traits.
  • On définit des suites de tests en composant des instances de Suite.

Sélection d’un style de test

ScalaTest propose différents styles de tests , chaque style correspondant à un type de tests bien particulier. Il est recommandé de s’accorder sur 2 styles de tests et de les respecter tout au long du projet :

  • un style pour les tests unitaires
  • et un style pour les UAT (User Acceptance Tests)

Dans cet article nous verrons l’utilisation du style FlatSpec, adapté pour les tests unitaires et d’intégration.

En FlatSpec, on peut écrire un test très simple comme ceci :

class SetSpec extends FlatSpec {

  "An empty Set" should "have size 0" in {
    assert(Set.empty.size == 0)
  }

  it should "produce NoSuchElementException when head is invoked" in {
    assertThrows[NoSuchElementException] {
      Set.empty.head
    }
  }
}

Pour lancer ce test, dans IntelliJ faire clic droit sur le test puis Run … nous donne la sortie suivante :


Création d’une classe de base

ScalaTest est constitué de plusieurs traits que l’on peut mixer pour répondre à la problématique qui nous intéresse.

Selon nos besoins, il est conseillé de créer une classe de base qu’étendront le reste des tests afin d’éviter la duplication de code dans chaque test :

import org.scalatest._

abstract class UnitSpec
  extends FlatSpec
    with Matchers
    with OptionValues
    with Inside
    with Inspectors

On peut ainsi étendre la classe UnitSpec dans les autres tests pour bénéficier de tous ses traits.


Utilisation des assertions

Scala Test fournit 3 assertions différentes pour nos tests. Celles-ci sont disponibles quelque soit le style utilisé et définies dans le trait Assertions :

  • assert pour les assertions communes
  • assertResult pour différencier les valeurs espérées de celles obtenues
  • assertThrows pour vérifier que le bout de code testé renvoie bien une exception

Assertions met également à notre disposition les méthodes suivantes :

  • assume() pour annuler un test sous condition,
  • fail(),
  • cancel(),
  • succeed(),
  • intercept() pour s’assurer qu’une exception levée est bien celle espérée
  • assertDoesNotCompile, pour verifier qu’un bout de code ne compile pas
  • assertTypeError, pour s’assurer qu’un code ne compile pas à cause d’une erreur de typographie
  • withClue, pour ajouter du détail à un échec

Taguer ses tests

Il est  possible de taguer ses tests de manière à répertorier ceux-ci en catégories.

Les tests à ignorer

Pour ignorer un test, il suffit de remplacer « it » ou « in » par « ignore » :

class IgnoredTest extends FlatSpec {

  "An empty Set" should "have size 0" ignore {
    assert(Set.empty.size == 0)
  }

  ignore should "produce NoSuchElementException when head is invoked" in {
    assertThrows[NoSuchElementException] {
      Set.empty.head
    }
  }
}

Les tests ignorés apparaitront ainsi dans IntelliJ :

Utiliser des tags personnalisés

ScalaTest fournit la méthode taggedAs() qui permet de spécifier une catégorie pour chaque test. Il est possible de créer des classes personnalisées pour taguer les tests par catégorie. Par exemple, pour des tests attaquant une base de données, on peut écrire un tag personnalisé comme ceci :

package fr.demandeatonton.tests.tags

import org.scalatest.Tag

object DbTest extends Tag("fr.demandeatonton.tests.tags.DbTest")

Et l’utiliser dans le test comme cela :

import fr.demandeatonton.tests.tags.DbTest
import org.scalatest.tagobjects.Slow
import org.scalatest.{FlatSpec, Tag}

class CustomTagsTest extends FlatSpec {

  "The Scala language" must "add correctly" taggedAs(Slow) in {
    val sum = 1 + 1
    assert(sum === 2)
  }

  it must "subtract correctly" taggedAs(Slow, DbTest) in {
    val diff = 4 - 1
    assert(diff === 3)
  }
}

Il est ensuite possible de lancer les catégories de tests qui nous intéressent grace aux paramètres du runner. Par exemple, pour lancer les tests « Slow », on utilisera le paramètre -n Slow.


Partage de ressources

Il est possible que lors de l’écriture d’un test, une même variable soit nécessaire à plusieurs reprises. ScalaTest propose une technique pour partager ce qu’il appelle des fixtures entre les différentes parties d’un test.

La méthode get-fixture

Si l’on a besoin d’une variable dans plusieurs tests sans que cette variable ait besoin d’un traitement particulier à la fermeture du test (libération d’une connexion sql par exemple), le moyen le plus simple est d’écrire une ou plusieurs méthodes get-fixture :

class GetFixtureTest extends FlatSpec {
  def fixture =
    new {
      val builder = new StringBuilder("ScalaTest is ")
      val buffer = new ListBuffer[String]
    }

  "Testing" should "be easy" in {
    val f = fixture
    f.builder.append("easy!")
    assert(f.builder.toString === "ScalaTest is easy!")
    assert(f.buffer.isEmpty)
    f.buffer += "sweet"
  }

  it should "be fun" in {
    val f = fixture
    f.builder.append("fun!")
    assert(f.builder.toString === "ScalaTest is fun!")
    assert(f.buffer.isEmpty)
  }
}

En déclarant val f = fixture au début des tests, on peut accéder aux variables mises à disposition par celle-ci.

Instancier des objets fixture

Une manière similaire à la précédente de partager les variable est de créer des objets les contenant dont le corps représente le contenu du test :

class FixtureContextTest extends FlatSpec {

  trait Builder {
    val builder = new StringBuilder("ScalaTest is ")
  }

  trait Buffer {
    val buffer = ListBuffer("ScalaTest", "is")
  }

  // Ce test a besoin de la fixture StringBuilder
  "Testing" should "be productive" in new Builder {
    builder.append("productive!")
    assert(builder.toString === "ScalaTest is productive!")
  }

  // Ce test a besoin de la fixture ListBuffer[String]
  "Test code" should "be readable" in new Buffer {
    buffer += ("readable!")
    assert(buffer === List("ScalaTest", "is", "readable!"))
  }

  // Ce test a besoin de StringBuilder and ListBuffer
  it should "be clear and concise" in new Builder with Buffer {
    builder.append("clear!")
    buffer += ("concise!")
    assert(builder.toString === "ScalaTest is clear!")
    assert(buffer === List("ScalaTest", "is", "concise!"))
  }
}

En déclarant les traits Builder et Buffer, on sépare les besoins pour pouvoir ensuite les utiliser indépendamment dans les autres tests, voire même les combiner comme dans le dernier test avec l’instruction in new Builder with Buffer

Surcharger withFixture

Les exemples précédents sont adaptés pour des fixture ne nécessitant pas de libération en fin de tests. Cependant si l’on souhaite implémenter un effet de bord en début et en fin de test (@Before et @After de JUnit), on peut surcharger la méthode withFixture qui fait partie du cycle de vie de ScalaTest et appartient au trait Suite.

L’implémentation par défaut est celle-ci :

protected def withFixture(test: NoArgTest) = {
  test()
}

On peut la redéfinir ainsi :

override def withFixture(test: NoArgTest) = {
  // Faire le setup de ses variables
  try super.withFixture(test) // Invoquer la fonction test
  finally {
    // Nettoyer les ressources
  }
}

Un exemple concret pour prendre une photo du répertoire courant si le test échoue et envoyer cette information au test :

class WithFixtureOverrideTest extends FlatSpec {

  override def withFixture(test: NoArgTest) = {
    super.withFixture(test) match {
      case failed: Failed =>
        val currDir = new File(".")
        val fileNames = currDir.list()
        info("Dir snapshot: " + fileNames.mkString(", "))
        failed
      case other => other
    }
  }

  "This test" should "succeed" in {
    assert(1 + 1 === 2)
  }

  it should "fail" in {
    assert(1 + 1 === 3)
  }
}

Ce test produira cette sortie :

Testing started at 09:39 ...
Dir snapshot: .git, .gitignore, .idea, build.sbt, project, README.md, src, target

2 did not equal 3
ScalaTestFailureLocation: fr.demandeatonton.tests.WithFixtureOverrideTest at (WithFixtureOverrideTest.scala:25)
Expected :3
Actual   :2

Utilisation de BeforeAndAfter

En utilisant le trait BeforeAndAfter, on peut exécuter des instructions avant et après le test, arrêtant les tests si ces instructions échouent :

class BeforeAndAfterTest extends FlatSpec with BeforeAndAfter {

  val builder = new StringBuilder
  val buffer = new ListBuffer[String]

  before {
    builder.append("ScalaTest is ")
  }

  after {
    builder.clear()
    buffer.clear()
  }

  "Testing" should "be easy" in {
    builder.append("easy!")
    assert(builder.toString === "ScalaTest is easy!")
    assert(buffer.isEmpty)
    buffer += "sweet"
  }

  it should "be fun" in {
    builder.append("fun!")
    assert(builder.toString === "ScalaTest is fun!")
    assert(buffer.isEmpty)
  }
}

Notez que ces fonctions s’exécutent avant et après chaque test.

Utilisation des Matchers

ScalaTest founit un DSL (Domain Specific Language) avec lequel les tests prennent une allure plus lisible. Le mot should fait partie de ce DSL mais il y en a bien d’autres.

Pour les utiliser, il suffit d’importer le trait Matcher à notre test :

class MatchersTest extends FlatSpec with Matchers {

Les matchers étant très nombreux, je vous laisse vous référer à la documentation officielle pour en avoir le détail.

Cependant voici un exemple de code source commenté avec quelques matchers pour que vous ayiez une idée de leur utilisation :

class MatchersTest extends FlatSpec with Matchers {

  "A Matcher" should "allow to test equallity" in {
    val res = 3
    res should equal(3) // égalité personnalisable, permet de tester l'égalité
    res should === (3) // égalité personnalisable, permet de tester l'égalité et le type
    res should be (3) // égalité non-personnalisable, plus rapide
    res shouldEqual 3 // égalité personnalisable, pas de parenthèses requises
    res shouldBe 3 // égalité non-personnalisable, pas de parenthèses requises
  }

  it should "allow to test size and length" in {
    val test = "Un test"
    val list = List(1, 2, 3)

    test should have length 7
    list should have size 3
  }

  it should "allow to test Strings" in {
    val string = "Hello seven world"
    val email = "contact@demandeatonton.fr"

    string should startWith ("Hello")
    string should endWith ("world")
    string should include ("seven")

    // Utilisation de regex
    string should startWith regex "Hel*o"
    string should endWith regex "wo.ld"
    string should include regex "wo.ld"

    // Full match d'une regex
    email should fullyMatch regex """^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$"""

    // Regex avec groupes
    "abbccxxx" should startWith regex ("a(b*)(c*)" withGroups ("bb", "cc"))
    "xxxabbcc" should endWith regex ("a(b*)(c*)" withGroups ("bb", "cc"))
    "xxxabbccxxx" should include regex ("a(b*)(c*)" withGroups ("bb", "cc"))
    "abbcc" should fullyMatch regex ("a(b*)(c*)" withGroups ("bb", "cc"))
  }

  it should "allow to test greater and less than" in {
    val one = 1

    one should be < 7
    one should be > 0
    one should be <= 7
    one should be >= 0
  }
}

Conclusion

J’espère que cet article vous aura donné des bases solides pour commencer l’écriture de tests unitaires avec Scala. Je vous invite à parcourir la documentation officielle de ScalaTest pour approfondir ce que j’ai présenté ici.

Dans le prochain (et dernier) article de cette série, nous verrons ce que sont les case classes et le pattern matching.

 

Laisser un commentaire