Hello, World

Pour respecter la tradition, je vais me lancer dans une petite démonstration de ce qui t’attend dans la suite de ce livre, avec le fameux exercice du « hello, world ».

Je t’invite d’ores et déjà à te créer un petit espace de travail (comprendre un dossier) dans lequel tu pourras ranger les codes sources de ce livre. Disons : ~/Workspace/guile-handbook/.

Ainsi, pour l’exercice qui va suivre, on va se placer dans le dossier ~/Workspace/guile-handbook/hello/. Crées-y un fichier hello.scm avec le contenu suivant :

(define-module (hello))

(define-public hi
  (lambda ()
    "hello world\n"))

Pour l’exécuter, lances la commande guile hello.scm.

Comment ça fonctionne

Guile est un langage très permissif. C’est-à-dire qu’une grande liberté est laissée au hacker dans la façon d’écrire ses programmes. Quand j’ai commencé à coder en Guile, j’aurais tout de même apprécié un peu plus de structure (ou un petit guide…).

Dans mon exemple, j’ai choisi de créer un module hello via la procédure define-module. Avec Guile, rien ne m’oblige à commencer avec un module ou une fonction main comme c’est le cas dans d’autres langages. Un module peut être vu comme une collection de procédures, variables et macros que l’on choisit de regrouper ensembles.

La procédure define-public, expose le symbole hi, ce dernier étant lié à une procédure qui ne prend pas d’argument en entrée et retourne la chaine de caractère "hello world\n".

Comment tester

Toujours dans le dossier ~/Workspace/guile-handbook/hello/, créé un fichier hello-test.scm :

(use-modules (srfi srfi-64)
             (hello))

(test-begin "harness")

(test-equal "test-hello"
  "hello world\n"
  (hi))

(test-end "harness")

Pour tester, exécutes la commade suivante :

$ guile -L . hello-test.scm

L'option -L indique que pour cette commande, je veux que le répertoire courant (.) soit ajouté au début du chemin des modules Guile à charger. Autrement, Guile ne saurait pas que le module hello existe.

Je préfère cette façon de faire qui n'altère pas le chemin de manière permanente avec du matériel d'apprentissage.

Ecriture de tests

Pas besoin de chercher, choisir et installer un framework de test. Guile est livré avec un module contenant le srfi-64 (un framework de test pour le langage Scheme).

Pas besoin de nommer les fichiers de tests, ou les tests eux-mêmes d'une manière particulière.

La procédure use-modules permet d'importer les modules nécessaires à nos tests :

  • le module srfi-64
  • le module hello, que l'on souhaite tester.

Pour les besoins du framework de test, les procédures test-begin et test-end indiquent respectivement où la suite de tests commence et où elle fini. Il est nécessaire de la nommer en lui fousnissant une chaine de caractères, ici : "harness".

Un test est un appel à une des procédures mises à disposition par le module srfi-64. Ici, c'est la procédure test-equal qui prend 3 arguments :

  1. le nom du test : "test-hello"
  2. la valeur attendue : "hello world\n"
  3. l’expression que l’on teste : (hi)

Si la valeur attendue est égale à la valeur issue de l’évaluation de l’expression testée, alors le test passe. Sinon, le test échoue.

Lorsque l’on exécute les tests, le résultat fourni est très sommaire. Par défaut, le framework donne plus de détails dans un fichier de log nom-de-la-suite-te-tests.log.

Voir un test échouer est une vérification importante. Ça perment de voir quel est le message d’erreur. On en tire deux choses :

  • est-ce que le test échoue pour la bonne raison ?
  • est-ce que le message d’erreur permet de savoir quel est le problème ?

Avec des tests, rapides et facile à lancer, fini les essais manuels qui cassent la dynamique de développement. Le TDD est un facilitateur dans l’atteinte de l'état de flow.

Hello, You

Dans l’exemple précédent, j’ai écrit le code à tester avant d'écrire son test. Dans la suite de ce livre, je suivrai les étapes de la méthode du Test Driven Development : red — green — refactor.

Avec un test en place que l’on peut exécuter rapidement, je peux itérer sur le programme en toute sécurité. Si je casse une fonctionnalité, je m’en apercevrai aussitôt !

Reprenons maintenant le code précédent et ajoutons un besoin : je veux pouvoir préciser à qui j’adresse mes salutations.

Red

Je commence donc par ajouter, dans ma suite de tests, celui qui traduit ce besoin :

(use-modules (srfi srfi-64)
             (hello))

(test-begin "harness")

(test-equal "test-hello"
  "hello world\n"
  (hi))
  
(test-equal "test-named-hello"
  "hello Jérémy\n"
  (hi "Jérémy"))

(test-end "harness")

Si tu exécutes la suite de tests, tu peux observer que notre premier test passe mais que le nouveau test échoue. Cette étape m'assure que je n'écris pas un test qui serait un faux-positif, car il ne teste pas le bon comportement.

;;; note: source file /home/jeko/Workspace/guile-handbook/hello/hello-test.scm
;;;       newer than compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.3/home/jeko/Workspace/guile-handbook/hello/hello-test.scm.go
;;; note: auto-compilation is enabled, set GUILE_AUTO_COMPILE=0
;;;       or pass the --no-auto-compile argument to disable.
;;; compiling /home/jeko/Workspace/guile-handbook/hello/hello-test.scm
;;; hello-test.scm:12:2: warning: possibly wrong number of arguments to `hi'
;;; compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.3/home/jeko/Workspace/guile-handbook/hello/hello-test.scm.go
%%%% Starting test harness  (Writing full log to "harness.log")
hello-test.scm:10: FAIL test-named-hello
# of expected passes      1
# of unexpected failures  1

De plus, le compilateur est un formidable assistant en expliquant pourquoi un code ne fonctionne pas. Ici, il m’avertit que dans la ligne 12 de mon fichier de test, je fais appel à la procédure hi avec le mauvais nombre d'arguments.

Green

Maintenant, je peux modifier mon code pour faire passer le test.

En premier lieu, je commence par corriger l'avertissement de compilation. Pour ce faire, j’indique que la procédure hi attend un paramètre optionnel :

(define-module (hello))

(define-public hi
  (lambda* (#:optional name)
    "hello world\n"))

Maintenant que le compilateur ne se plaint plus, je regarde dans le fichier de log de ma suite de tests la raison de l’échec cat harness.log :

%%%% Starting test harness
Group begin: harness
Test begin:
  test-name: "test-hello"
  source-file: "hello-test.scm"
  source-line: 6
  source-form: (test-equal "test-hello" "hello world\n" (hi))
Test end:
  result-kind: pass
  actual-value: "hello world\n"
  expected-value: "hello world\n"
Test begin:
  test-name: "test-named-hello"
  source-file: "hello-test.scm"
  source-line: 10
  source-form: (test-equal "test-named-hello" "hello Jérémy\n" (hi "Jérémy"))
Test end:
  result-kind: fail
  actual-value: "hello world\n"
  expected-value: "hello Jérémy\n"
Group end: harness
# of expected passes      1
# of unexpected failures  1

Le test "test-named-hello" échoue, car il attend la valeur "hello Jérémy\n" mais a obtenu "hello world\n". Je corrige ça :

(define-module (hello))

(define-public hi
  (lambda* (#:optional name)
    (if name
        "hello Jérémy\n"
        "hello world\n")))

Cette fois, l’exécution de la suite de tests montre que le nouveau code fait passer le deuxième test, sans casser le premier.

;;; note: source file ./hello.scm
;;;       newer than compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.3/home/jeko/Workspace/guile-handbook/hello/hello.scm.go
;;; note: auto-compilation is enabled, set GUILE_AUTO_COMPILE=0
;;;       or pass the --no-auto-compile argument to disable.
;;; compiling ./hello.scm
;;; compiled /home/jeko/.cache/guile/ccache/3.0-LE-8-4.3/home/jeko/Workspace/guile-handbook/hello/hello.scm.go
%%%% Starting test harness  (Writing full log to "harness.log")
# of expected passes      2

Ma suite de tests peut être vue comme une liste d'exemples de cas d'usage de mon logiciel. C'est plus pragmatique qu'une documentation. Sans connaissance préalable du code opérationnel, je peux rapidement voir que le programme peut être appelé avec zéro ou un paramètre et ce qu'il retourne !

Gestion de version

En l'état, le programme est opérationnel comme et attestent les tests au verts. On recommande de commit cet état du code avant la prochaine étape (ça permettra de revenir à la version opérationnelle).

Cependant, le cycle red-green-refactor n'étant pas bouclé, pas de raison de pousser le commit. Le code est fonctionnel mais pas terminé.

Refactor

Réusiner (refactor) du code revient à éliminer les duplications et rendre le code plus explite. Ce sont pour moi les principes tous puissants, ceux desquels tous les autres découlent.

Et justement, dans le code, on peut voir des duplications, à la fois dans les valeurs et dans la construction des chaînes de caractères suivantes :

"hello Jérémy \n"
"hello world \n"

Que j'éliminerai en extrayant une procédure qui ne sera pas exposée publiquement ainsi que des constantes qui apporteront plus de sens aux valeurs.

(define-module (hello))

(define GREETING_PREFIX "hello ")
(define GREETING_SUFFIX "\n")
(define DEFAULT_ADDRESSEE "world")

(define-public hi
  (lambda* (#:optional name)
    (string-append GREETING_PREFIX (addressee name) GREETING_SUFFIX)))

(define addressee
  (lambda (name)
    (if name
    "Jérémy"
    DEFAULT_ADDRESSEE)))

Retour sur la gestion de version

A cet instant, je peux très volontier amend le précédent commit pour ne conserver que cet impeccable version de notre code et tests.

Hello, You… encore

Dans l'exemple précédent, j'ai volontairement poussé le concept de « petit pas » très loin pour des raisons pédagogiques. Mais cela me permet d'introduire une technique de TDD : la triangulation.

Red

(use-modules (srfi srfi-64)
             (hello))

(test-begin "harness")

(test-equal "test-hello"
  "hello world\n"
  (hi))

(test-equal "test-named-hello"
  "hello Jérémy\n"
  (hi "Jérémy"))

(test-equal "test-named-hello-bis"
  "hello Hacker\n"
  (hi "Hacker"))
  
(test-end "harness")

Je vois bien le test échouer à l'éxécution de la suite de tests.

Green

(define-module (hello))

(define GREETING_PREFIX "hello ")
(define GREETING_SUFFIX "\n")
(define DEFAULT_ADDRESSEE "world")

(define-public hi
  (lambda* (#:optional name)
    (string-append GREETING_PREFIX (addressee name) GREETING_SUFFIX)))

(define addressee
  (lambda (name)
    (if name
        name
        DEFAULT_ADDRESSEE)))

Le changement est minime et évident. C’est un des avantages de se forcer à travailler en « petit pas ».

À présent, tous les tests passent.

Refactor

Dans l’étape de réusinage, il faut travailler tout le code. Cela inclus les tests !

Nos tests sont une spécification claire de ce que le code a besoin de faire. Plus la suite de tests est explicite, plus vite un utilisateur saura comment utiliser le programme.

(use-modules (srfi srfi-64)
             (hello))

(test-begin "harness")

(define (assert-correct-message test-name test-expected test-effective)
  (test-equal test-name test-expected test-effective))

(assert-correct-message "test-default-hello" "hello world\n" (hi))
      
(test-equal "test-named-hello"
  "hello Jérémy\n"
  (hi "Jérémy"))

(test-equal "test-named-hello-bis"
  "hello Hacker\n"
  (hi "Hacker"))
  
(test-end "harness")

Conclusion

  • Introduction au TDD
  • La notion de discipline
  • Intérêt de respecter le cycle Red-Green-Refactor
  • Paramètres optionnels d'une procédure