Cet article fait partie de la série "Introduction à TDD en Swift"

Dans le précédent article de cette série tu as appris les bienfaits de la pratique de TDD :

  • les tests sont fiables et nous permettent de ne rien casser,
  • les tests nous poussent à raisoner plus profondément, à avoir un code plus robuste,
  • les tests sont plus importants que le code de production.

Nous allons continuer aujourd’hui le kata FizzBuzz en implémentant plusieurs nouveaux tests en TDD.

Au programme :

  • pourquoi une suite de tests doit constituer la meilleure documentation possible,
  • je vais déroger au cycle RED-GREEN-REFACTOR (crime crime ! 😂),
  • tu vas avoir un petit aperçu du property-based testing (wat?),
  • et enfin on va utiliser le résultat d’un test pour écrire un test (waaaat?).

Alors, prêt(e) ?

Règle métier : “Pour les nombres non multiples de 3 ou 5, affiche le nombre”

Je viens de me rendre compte que ma checklist ne reflète pas vraiment les règles métiers, je la mets donc à jour.

Pour les nombres non multiples de 3 ou 5, affiche le nombre
3 -> [1, 2, Fizz]
Pour les multiples de 3, affiche Fizz au lieu du nombre
5 -> last == Buzz
Pour les multiples de 5, affiche Buzz au lieu du nombre
15 -> last == FizzBuzz
Pour les multiples de 15, affiche FizzBuzz au lieu du nombre
Afficher les nombres de 1 à 100

Voilà aussi une belle illustration des bienfaits de TDD.

Pour écrire de bons tests, je dois capture fidèlement le besoin.

Mon envie d’avoir une bonne suite de tests, une bonne documentation me pousse à me concentrer sur le besoin.

Souvent, en écrivant des tests, je me rends compte qu’il me manque des informations.

Je découvre des cas d’usage (des cas d’erreur la plupart du temps), pour lesquels j’ai besoin de questionner les utilisateurs & utilisatrices (ou le Product Owner).

Révisons nos deux derniers tests :

  • test_FizzBuzz_up_to_1_is_a_list_containing_1_as_string
  • test_FizzBuzz_up_to_2_is_a_list_containing_1_and_2_as_string

Ce sont deux tests qui sont des exemples vérifiant la première règle métier : “Pour les nombres non multiples de 3 ou 5, affiche le nombre”.

Dans un soucis de bonne documentation (je me répète mais c’est important !), ces deux tests me dérangent car ils ne communiquent pas efficacement la règle métier.

Comment pourrions-nous nommer un test unique qui communique efficacement cette règle métier ?

Hum, que penses-tu de test_Numbers_not_multiple_of_3_or_5_are_displayed_as_is ?

Ça me semble parfait, bien joué !

Quel serait le contenu de cette méthode de test ?

Pourquoi pas ceci :

func test_Numbers_not_multiple_of_3_or_5_are_displayed_as_is() {
  assertThatFizzBuzz(upTo: 2, is: [ "1", "2" ])
}

Pas mal…

Le problème c’est qu’on ne gère que les nombres jusqu’à 2.

C’est un test assez limité.

Ok, alors ceci peut-être ?

func test_Numbers_not_multiple_of_3_or_5_are_displayed_as_is() {
  assertThatFizzBuzz(upTo: 10, is: [ "1", "2", "3", "4", "5", "6", "7", "8", "9", "10" ])
}

Là on teste jusqu’à 10, c’est mieux ; le problème c’est que les prochaines règles métiers vont faire échouer ce test.

Or on ne veut pas écrire de tests “faux”.

Il serait pertinent, dans un premier temps, de vérifier que certains nombres sont bien affichés tels quels.

Que penses-tu ce ceci ?

func test_Numbers_not_multiple_of_3_or_5_are_displayed_as_is() {
  assertThatFizzBuzz(upTo: 1, endsWith: "1")
  assertThatFizzBuzz(upTo: 2, endsWith: "2")
  assertThatFizzBuzz(upTo: 4, endsWith: "4")
  // ...
  assertThatFizzBuzz(upTo: 10213, endsWith: "10213")
}

private func assertThatFizzBuzz(upTo n: UInt, endsWith expected: String, line: UInt = #line) {
  let fizzBuzz = FizzBuzz()
  let result = fizzBuzz.upTo(n)
  XCTAssertEqual(expected, result.last, line: line)
}

Mouais…ça va nous faire un test long comme le bras ça ! 😕 Et tu ne vérifies pas que la liste retournée est bonne, juste le dernier élément.

Tu as raison…

Pour ta deuxième remarque j’ajoute un test à ma liste : FizzBuzz jusqu’à un certain nombre retourne une liste de la taille de ce nombre.

Concernant ta première remarque, nous pouvons généraliser en faisant une boucle avec nos exemples.

Comme ceci :

func test_Numbers_not_multiple_of_3_or_5_are_displayed_as_is() {
  let numbers: [UInt] = [ 1, 2, 4, 10213 ]
  for n in numbers {
    assertThatFizzBuzz(upTo: n, endsWith: "\(n)")
  }
}

Excellent !

Oui ça me plaît assez…

Mais j’ai l’impression de réécrire l’algo dans mes tests !

En effet je transforme un nombre en String ("\(n)") comme dans mon code de prod.

Ce n’est pas top car cela introduit de la duplication et du couplage entre les tests et le code de prod !

Modifions le test pour éviter cela :

func test_Numbers_not_multiple_of_3_or_5_are_displayed_as_is() {
  let expectations: [UInt: String] = [ 1: "1", 2: "2", 4: "4", 10213: "10213" ]
  for (n, expected) in expectations {
    assertThatFizzBuzz(upTo: n, endsWith: expected)
  }
}

Beaucoup mieux !

Passons maintenant au refactoring.

Je vois plusieurs duplications :

  1. il y a des tests redondants,
  2. il y a de la duplication dans les deux méthodes d’assertions.

Commençons par le premier point, on peut supprimer les tests suivants :

  • test_FizzBuzz_up_to_1_is_a_list_containing_1_as_string
  • test_FizzBuzz_up_to_2_is_a_list_containing_1_and_2_as_string

Et enfin le deuxième, on crée une nouvelle méthode pour factoriser le code similaire dans nos deux assertions :

private func assertThatFizzBuzz(upTo n: UInt, is expected: [String], line: UInt = #line) {
  let result = fizzBuzz(upTo: n)
  XCTAssertEqual(expected, result, line: line)
}

private func assertThatFizzBuzz(upTo n: UInt, endsWith expected: String, line: UInt = #line) {
  let result = fizzBuzz(upTo: n)
  XCTAssertEqual(expected, result.last, line: line)
}

private func fizzBuzz(upTo n: UInt) -> [String] {
  let fizzBuzz = FizzBuzz()
  return fizzBuzz.upTo(n)
}

Done !

Le fait de tester le comportement de FizzBuzz avec des valeurs prédéfinies est une approche basée sur l’exemple.

Elle présente l’avantage d’être simple à mettre en œuvre et suffit la plupart du temps.

L’inconvénient est que sur certains algorithmes, nous pouvons difficilement couvrir tous les exemples efficacement. Un de ces algorithmes est celui du kata Roman Numerals où il faut transformer des nombres romains en nombres arabes.

Autre inconvénient : cette approche n’offre pas de “preuve” au sens mathématique. Nos tests prouvent uniquement que notre programme fonctionne pour les exemples choisis.

Pour aller plus loin, tu peux aller fouiller du côté de l’approche basée sur les propriétés : le property-based testing. Pour cela, nous utilisons notamment des outils comme SwiftCheck. Je prévois de réviser FizzBuzz en utilisant SwiftCheck dans un prochain article, pour ne pas le manquer, inscris-toi à la newsletter !

Propriété : la taille de la liste

J’ai ajouté à ma liste le test suivant : FizzBuzz jusqu’à un certain nombre retourne une liste de la taille de ce nombre.

Il est maintenant temps de l’implémenter !

func test_Result_list_is_of_the_same_size_as_requested_upper_bound() {
  let fizzBuzz = FizzBuzz()
  let result = fizzBuzz.upTo(0)
  XCTAssertEqual(0, result.count)
}

Là nous gérons le cas 0, allons plus loin en restant dans l’approche par l’exemple pour tester jusqu’à 100.

func test_Result_list_is_of_the_same_size_as_requested_upper_bound() {
  let fizzBuzz = FizzBuzz()
  for n in 0...100 {
    let result = fizzBuzz.upTo(UInt(n))
    XCTAssertEqual(n, result.count)
  }
}

J’ai quand même une interrogation par rapport à TDD…

Je t’écoute.

En écrivant ce test, nous ne sommes pas passés par la phase RED. C’est encore du TDD du coup ?

Très bonne remarque !

Pourquoi est-il utile de passer par la phase RED ?

Pour vérifier que notre test est utile et s’assurer que son échec donne assez d’informations pour nous aider à le faire passer à nouveau.

Exactement !

Je prends le risque ici d’écrire un test inutile et je ne vérifie pas les informations liées à son éventuel échec.

Les tests servent à vérifier la non-régression et à guider la conception.

Ils servent aussi de documentation des comportements attendus !

Je souhaite spécifier les comportements attendus ! Un des comportements que j’attends est que FizzBuzz me retourne une bonne liste de String donc il me faut un test pour le spécifier.

Cela suffit à justifier l’utilité de ce test.

Concernant le message d’erreur, je peux forcer l’échec de ce test en modifiant le code de production comme ceci :

func upTo(_ n: UInt) -> [String] {
  if n == 0 { return [] }
  return [ "\(n)" ]
}

J’obtiens 98 erreurs de la forme : -[TDDFizzBuzzTests.FizzBuzz_Spec test_Result_list_is_of_the_same_size_as_requested_upper_bound] : XCTAssertEqual failed: ("2") is not equal to ("1").

Je peux facilement déduire, à partir du nom du test et du message, que le problème se situe au niveau de la taille de la liste.

Remettons le code de production en état pour refaire passer le test :

func upTo(_ n: UInt) -> [String] {
  return (0...n).dropFirst(1).map { "\($0)" }
}

Ce n'est pas parcequ'un test ne nous fait pas écrire du code de prod qu'il est inutile. Un test est là pour spécifier un comportement et sert à la documentation du code.

Deuxième règle métier : les multiples de 3 donnent “Fizz”

Au regard des tests existants, j’ai mis à jour ma liste :

Pour les multiples de 3, affiche Fizz au lieu du nombre
Pour les multiples de 5, affiche Buzz au lieu du nombre
Pour les multiples de 15, affiche FizzBuzz au lieu du nombre
Afficher les nombres de 1 à 100

J’ai supprimé des exemples comme “3 -> [1, 2, Fizz]” car ils n’apportent rien en terme de documentation.

J’accélère en passant directement aux règles métiers.

C’est parti pour le test de notre deuxième règle métier :

func test_Multiples_of_3_are_displayed_as_Fizz() {
  let examples: [UInt] = [ 3, 6, 9, 12, 18, 3 * 123 ]
  for n in examples {
    assertThatFizzBuzz(upTo: n, endsWith: "Fizz")
  }
}

Nous pourrions y aller par plus petites étapes en testant d’abord 3, puis 6, etc. Mais je me sens en confiance pour accélérer un peu. Je sais que si jamais je n’y arrive pas, je pourrais ralentir.

Je vais en GREEN le plus vite possible :

func upTo(_ n: UInt) -> [String] {
  return (0...n).dropFirst(1).map {
    if $0 % 3 == 0 {
      return "Fizz"
    }

    return "\($0)"
  }
}

Et je passe au REFACTORING.

La méthode upTo commence à devenir assez longue, c’est le smell Long Method.

Comme traitement j’utilise Extract Method.

func upTo(_ n: UInt) -> [String] {
  return (0...n).dropFirst(1).map(stringFor)
}

private func stringFor(_ n: UInt) -> String {
  if n % 3 == 0 {
    return "Fizz"
  }

  return "\(n)"
}

J’aimerai aussi rendre plus explicite la condition pour vraiment faire ressortir la règle métier “multiple de 3”.

On pourrait ajouter un commentaire au dessus du if mais les commentaires sont le signe que le code peut être amélioré.

Là aussi, un Extract Method à la rescousse !

private func stringFor(_ n: UInt) -> String {
  if isMultipleOf3(n) {
    return "Fizz"
  }

  return "\(n)"
}

private func isMultipleOf3(_ n: UInt) -> Bool {
  return n % 3 == 0
}

Je pense encore pouvoir amélioré la lisibilité du code en utilisant une super feature de Swift : les extensions !

struct FizzBuzz {

  // ...

  private func stringFor(_ n: UInt) -> String {
    if n.isMultipleOf3 {
      return "Fizz"
    }

    return "\(n)"
  }
}

extension UInt {  

  var isMultipleOf3: Bool {
    return self % 3 == 0
  }
}

Là je suis content ! Passons au test suivant !

Troisième règle métier : les multiples de 5 donnent “Buzz”

Pour les multiples de 5, affiche Buzz au lieu du nombre
Pour les multiples de 15, affiche FizzBuzz au lieu du nombre
Afficher les nombres de 1 à 100

Le test :

func test_Multiples_of_5_are_displayed_as_Buzz() {
  let examples: [UInt] = [ 5, 10, 20, 25, 35, 5 * 124 ]
  for n in examples {
    assertThatFizzBuzz(upTo: n, endsWith: "Buzz")
  }
}

RED !

Je le fais passer :

private func stringFor(_ n: UInt) -> String {
  if n.isMultipleOf3 {
    return "Fizz"
  }

  if n % 5 == 0 {
    return "Buzz"
  }

  return "\(n)"
}

GREEN !

Et maintenant REFACTORING !

D’abord le code de prod :

struct FizzBuzz {

  // ...

  private func stringFor(_ n: UInt) -> String {
    if n.isMultipleOf3 { return "Fizz" }
    if n.isMultipleOf5 { return "Buzz" }
    return "\(n)"
  }
}

extension UInt {

  // ...

  var isMultipleOf5: Bool {
    return self % 5 == 0
  }
}

Et enfin les tests, car je vois un pattern qui se répète :

  • une liste d’exemples,
  • je parcours la liste,
  • pour chaque élément je vérifie que le résultat termine par une string donnée.

Voilà ce que ça donne :

func test_Multiples_of_3_are_displayed_as_Fizz() {
  assertThatAllFizzBuzzUpTo([ 3, 6, 9, 12, 18, UInt(3 * 123) ], endsWith: "Fizz")
}

func test_Multiples_of_5_are_displayed_as_Buzz() {
  assertThatAllFizzBuzzUpTo([ 5, 10, 20, 25, 35, UInt(5 * 124) ], endsWith: "Buzz")
}

private func assertThatAllFizzBuzzUpTo(_ examples: [UInt], endsWith expected: String, line: UInt = #line) {
  for n in examples {
    assertThatFizzBuzz(upTo: n, endsWith: expected, line: line)
  }
}

Dernière règle métier : les multiples de 3 et 5 donnent “FizzBuzz”

Pour les multiples de 15, affiche FizzBuzz au lieu du nombre
Afficher les nombres de 1 à 100

Nous y sommes presque !

Ouiii !! Mais ne crions pas victoire trop vite, il nous reste 2 tests à implémenter.

Commençons avec notre dernière règle métier.

func test_Multiples_of_3_and_5_are_displayed_as_FizzBuzz() {
  assertThatAllFizzBuzzUpTo([ 15, 30, 45, 60, 75, 90, UInt(3 * 5 * 125) ], endsWith: "FizzBuzz")
}

En route pour GREEN !

private func stringFor(_ n: UInt) -> String {
  if n.isMultipleOf3 { return "Fizz" }
  if n.isMultipleOf5 { return "Buzz" }
  if n.isMultipleOf3 && n.isMultipleOf5 { return "FizzBuzz" }
  return "\(n)"
}

FAIL

Oups, quoi ?

Héhé, il faut mettre le troisème if avant les deux autres !

Ah mais oui, suis-je bête ? Merci !

Heureusement qu’il y a les tests quand j’ai un coup de mou !

Comme ça c’est mieux :

private func stringFor(_ n: UInt) -> String {
  if n.isMultipleOf3 && n.isMultipleOf5 { return "FizzBuzz" }
  if n.isMultipleOf3 { return "Fizz" }
  if n.isMultipleOf5 { return "Buzz" }
  return "\(n)"
}

REFACTORING !

Je trouve qu’il y a deux tests qui ne sont pas assez explicites.

test_Multiples_of_3_are_displayed_as_Fizz et test_Multiples_of_5_are_displayed_as_Buzz.

En effet, je m’attends à voir des multiples de 3 dans le premier, or je ne vois pas 15, on passe de 12 à 18.

Et je m’attends à voir des multiples de 5 dans le deuxième, or je ne vois pas 15 non plus, on passe de 10 à 20.

Le nom des tests ne reflète pas exactement le besoin, il faut donc les renommer.

Comme ceci :

func test_Multiples_of_3_but_not_5_are_displayed_as_Fizz() {
  // ...
}

func test_Multiples_of_5_but_not_3_are_displayed_as_Buzz() {
  // ...
}

Test d’acceptance : FizzBuzz de 1 à 100

Afficher les nombres de 1 à 100

Bon, je ne sais pas toi mais écrire les valeurs d’exemples pour ce test à la main m’ennuie.

Tu as confiance dans nos tests ?

Oui !

Et si on laissait ce dernier test échouer en nous donnant les valeurs ?

Ensuite on n’aura plus qu’à copier le resultat donnée par le test dans notre test et le tour est joué !

Genius ! 🤯

func test_Print_numbers_from_1_to_100() {
  assertThatFizzBuzz(upTo: 100, is: [])
}

FAIL

Yeah ! Haha ! J’ai beaucoup trop de fun à faire ça. 🤣

Et je copie les valeurs dans mon test :

func test_Print_numbers_from_1_to_100() {
  assertThatFizzBuzz(upTo: 100, is: [ "1", "2", "Fizz", "4", "Buzz", "Fizz", "7", "8", "Fizz", "Buzz", "11", "Fizz", "13", "14", "FizzBuzz", "16", "17", "Fizz", "19", "Buzz", "Fizz", "22", "23", "Fizz", "Buzz", "26", "Fizz", "28", "29", "FizzBuzz", "31", "32", "Fizz", "34", "Buzz", "Fizz", "37", "38", "Fizz", "Buzz", "41", "Fizz", "43", "44", "FizzBuzz", "46", "47", "Fizz", "49", "Buzz", "Fizz", "52", "53", "Fizz", "Buzz", "56", "Fizz", "58", "59", "FizzBuzz", "61", "62", "Fizz", "64", "Buzz", "Fizz", "67", "68", "Fizz", "Buzz", "71", "Fizz", "73", "74", "FizzBuzz", "76", "77", "Fizz", "79", "Buzz", "Fizz", "82", "83", "Fizz", "Buzz", "86", "Fizz", "88", "89", "FizzBuzz", "91", "92", "Fizz", "94", "Buzz", "Fizz", "97", "98", "Fizz", "Buzz" ])
}

SUCCESS

Tadaaaa ! 🎉

Conclusion

Voilà qui achève cette belle introduction à TDD en Swift.

J’espère que cela t’a aidé à apprécier cette pratique qui change mon quotidien depuis 2016 et qui m’apporte une grande sérénité.

Tu as appris dans ce dernier article que :

  • pour écrire de bons tests, il faut capturer fidèlement le besoin,
  • ce n’est pas parcequ’un test ne nous fait pas écrire du code de prod qu’il est inutile,
  • un test est avant tout là pour spécifier un comportement et sert à la documentation du code.

Si tu veux aller plus loin et plus vite dans ta maîtrise de cette pratique tu peux faire appel à moi directement !

Pour télécharger le projet finalisé, c’est par ici !

Si tu as apprécié cette série sur TDD, n’hésite pas à t’inscrire à la newsletter ci-dessous pour ne rien manquer des prochains articles. 👇