Test de code OCaml
Table des matières
Préambule

Ce document est une introduction, non pas aux tests en général, mais aux tests de code OCaml à l'aide du logiciel Kaputt.

1. Introduction

La mise en place de tests lors du développement d'un logiciel gagne à être pensée dès le début du développement, afin de développer conjointement les tests et le code de test1. Les tests doivent être automatisés pour pouvoir être relancés régulièrement, par exemple après chaque modification du code.

L'environnement de test n'est pas à négliger. Sa facilité d'utilisation et l'aisance avec laquelle on peut rapidement ajouter un test sont nécessaires pour que cet ajout ne soit pas rebutant. Dans le cas contraire, lorsque l'ajout de tests supplémentaires est pénible, le développeur aura tendance à retarder cette activité.

L'environnement de test doit également être flexible pour pouvoir tester une partie seulement du code, sous différentes conditions (version bytecode ou code natif, ...). Enfin, il doit pouvoir s'inscrire facilement dans l'environnement de développement en général, notamment permettre une exploitation de ses résultats par des outils en aval.

Kaputt fournit une bibliothèque de modules facilitant la définition et l'exécution de tests, ainsi que des fonctions pour afficher les résultats dans divers formats (texte, XML, ...).

Les tests seront donc définis en écrivant du code OCaml, appelant des fonctions de la bibliothèque; de même pour la sortie des résultats.

Cette approche offre une grande expressivité et une grande flexibilité, comme nous allons le voir plus loin.

Kaputt est installable avec Opam ou depuis ses sources.

2. Mise en place de l'environnement de test

Kaputt offre plusieurs possibilités pour définir et exécuter des tests.

La première est l'exécution de code dans le toplevel ocaml:

# #directory "+kaputt";;
# #load "kaputt.cma";;
# open Kaputt.Abbreviations;;
# check Gen.int succ [Spec.is_odd_int ==> Spec.is_even_int]; flush stdout;;
Test 'untitled no 1' ... 100/100 cases passed
- : unit = ()

Pour des tests à lancer régulièrement, on préférera mettre leur code dans des fichiers .ml. On souhaitera souvent définir des tests pour des fonctions d'un module M qui ne sont pas visibles à l'extérieur du module M. Ces fonctions sont présentes dans le fichier .ml mais pas dans le fichier d'interface .mli (cf. 1. Interface et implémentation).

Le code de test doit donc pouvoir accéder à tout le contenu du module M, donc être inclus dans le fichier .ml. Cependant, on ne souhaite évidemment pas que les tests soient exécutés dès qu'un module développé est utilisé. A vrai dire, on ne souhaite même pas que le code de test soit présent dans le code du logiciel ou de la bibliothèque lorsqu'ils sont compilés pour être installés.

Il y a deux possibilités pour résoudre ce problème.

2.1. Utilisation de camlp4oof

La première possibilité consiste en l'utilisation de l'extension de syntaxe Camlp4 camlp4oof, utilisée comme préprocesseur. Cette extension autorise la syntaxe suivante:

let () =
  IFDEF TEST THEN
    print_endline "test mode on"
  ELSE
    print_endline "test mode off"
  ENDIF;;

Il suffit alors de compiler le code en indiquant sur la ligne de commande la commande de préprocesseur à utiliser, définissant ou non TEST:

$ ocaml{c,opt} -pp ’camlp4oof -DTEST’ code.ml

pour le mode TEST ou bien

$ ocaml{c,opt} -pp ’camlp4oof’ code.ml

pour compiler sans mode TEST.

Cette solution permet de mettre le code de test au plus près du code testé. Cependant, si les tests sont nombreux, leur présence diminue la lisibilité du code.

2.2. Utilisation de kaputt_pp.byte

Kaputt propose une solution alternative, consistant à placer le code des tests du module M dans le fichier m.mlt. Kaputt fournit un préprocesseur, kaputt_pp.byte, permettant, à la compilation d'un fichier .ml, d'ajouter à la fin le contenu du fichier .mlt correspondant. Si nous voulons définir des tests pour des fonctions du fichier foo.ml, nous plaçons notre code de test dans le fichier foo.mlt. Il suffit ensuite que le process de compilation utilise le préprocesseur fourni par Kaputt lorsqu'on souhaite compiler les tests:

$ ocamlc -c -pp 'kaputt_pp.byte on camlp4o' foo.ml

L'argument on indique d'ajouter le contenu du fichier .mlt correspondant pour fournir le tout au compilateur. Si on ne souhaite pas intégrer le code de test, on remplacera on par off.

Avertissement 1: Chemin de kaputt_pp.byte

Il est probable que kaputt_pp.byte ne soit pas dans notre PATH. Il est par défaut installé dans le même répertoire que les bibliothèques de Kaputt. On pourra donc utiliser la commande suivante, s'appuyant sur ocamlfind pour appeler kaputt_pp.byte du paquet kaputt:

$ ocamlc -c -pp 'ocamlfind kaputt/kaputt_pp.byte on camlp4o' foo.ml
3. Tests d'assertions

Un test d'assertion consiste à vérifier qu'une condition est remplie, c'est-à-dire qu'une expression booléenne vaut true. C'est le cas notamment quand on teste que le retour d'un appel de fonction est bien égal à une valeur de référence.

Le module Assertion de Kaputt fournit des fonctions facilitant l'écriture de tels tests, avec notamment des fonctions et modules pour comparer des structures de données usuelles (piles, tables de hachages, ensembles, ...), pouvant prendre en paramètre une fonction de comparaison. De plus, les messages d'erreur peuvent être assez précis, indiquant la valeur attendue, la valeur testée et un message explicatif.

Ainsi, on trouve par exemple une fonction pour créer une fonction de comparaison de deux piles de valeurs:

# Kaputt.Assertion.make_equal_stack;;
- : ('a -> 'a -> bool) ->
    ('a -> string) -> ?msg:string -> 'a Stack.t -> 'a Stack.t -> unit
= <fun>

Utilisons cette fonction pour créer une fonction de comparaison de piles de valeurs d'un type point que nous définissons également.

Auparavant, nous ouvrons le module Kaputt.Assertion pour rendre visible tout son contenu dans l'environnement:

# open Kaputt.Assertion;;
# type point = { x : int ; y : int };;
type point = { x : int; y : int; }
# let string_of_point { x ; y } = Printf.sprintf "(%d, %d)" x y ;;
val string_of_point : point -> string = <fun>
# let equal_point p1 p2 = p1.x = p2.x && p1.y = p2. y ;;
val equal_point : point -> point -> bool = <fun>
# let equal_stack_point = make_equal_stack equal_point string_of_point ;;
val equal_stack_point : ?msg:string -> point Stack.t -> point Stack.t -> unit =
  <fun>

A l'aide de cette fonction, nous pouvons comparer deux piles de points. Nous définissons une pile "de référence" et une autre pile, différente. Nous utilisons notre fonction de comparaison avec un message pour qu'en cas d'inégalité l'exception levée comporte, en plus des valeurs qui diffèrent, le message explicatif:

# let add_point stack x = Stack.push { x ; y = 0} stack;;
val add_point : point Stack.t -> int -> unit = <fun>
# let pile_ref = Stack.create ();;
val pile_ref : '_a Stack.t = <abstr>
# List.iter (add_point pile_ref) [ 1 ; 2 ; 3 ; 4 ];;
- : unit = ()
# let pile_2 = Stack.create ();;
val pile_2 : '_a Stack.t = <abstr>
# List.iter (add_point pile_2) [ 1 ; 2 ; 42 ; 4 ];;
- : unit = ()
# equal_stack_point ~msg: "premier test" pile_ref pile_ref;;
- : unit = ()
# try equal_stack_point ~msg: "deuxieme test" pile_ref pile_2
  with Failed f ->
    print_endline
      (Printf.sprintf "Failed: %s\nexpected value: %s\nactual_value: %s"
        f.message f.expected_value f.actual_value
      );;
Failed: deuxieme test (at index 1)
expected value: (3, 0)
actual_value: (42, 0)
- : unit = ()

Constatons au passage qu'en cas d'erreur, la fonction créée par make_equal_stack ajoute la position dans la pile où les valeurs ont différé.

En pratique, nous n'écrirons pas le code qui rattrapera les exceptions levées lors des comparaisons des tests d'assertions. Nous utiliserons les fonctions du module Test pour définir des tests de différentes sortes (tests d'assertions, comme ci-dessus, ou tests d'autres types, voir plus bas), et les fonctions de ce même module pour exécuter les tests. Ces fonctions se chargeront de collecter les résultats des tests pour ensuite en faire un rapport.

Voyons un exemple de définition et d'exécution de tests sur la fonction is_prefix ci-dessous, qui prend deux chaînes de caractères et renvoie true si la première chaîne est un préfixe de la seconde (i.e. la seconde chaîne commence par exactement la première chaîne) ou false sinon.

Pour les besoins de la cause, nous introduisons un bug, consistant à vérifier un caractère de moins (nous ignorons le dernier caractère de la première chaîne):

# let is_prefix s1 s2 =
  (* required bug here: ignore last char of s1 *)
  let s1 = String.sub s1 0 (max 0 (String.length s1 - 1)) in
  (* /bug *)
  let len1 = String.length s1 in
  let len2 = String.length s2 in
  (len1 <= len2) &&
    (String.sub s2 0 len1) = s1;;
val is_prefix : string -> string -> bool = <fun>

Nous commençons par écrire une fonction qui crée un test à partir de deux chaînes et du résultat attendu, en utilisant la fonction de test Assertion.is_true et la fonction de création de test d'assertion Test.make_simple_test:

# let mk_test title s1 s2 res =
  let f () = is_true ~msg: "duh!" (is_prefix s1 s2 = res) in
  Kaputt.Test.make_simple_test ~title f;;
val mk_test : string -> string -> string -> bool -> Kaputt.Test.t = <fun>

Nous pouvons définir nos tests et les exécuter à l'aide de la fonction Test.run_tests:

# let tests =
  [
    mk_test "empty prefix" "" "foo" true ;
    mk_test "empty strings" "" "" true ;
    mk_test "empty second string" "foo" "" false ;
    mk_test "empty second string and one-char prefix" "x" "" false ;
    mk_test "different strings" "abc" "xyz" false ;
    mk_test "almost prefix" "abc" "abd" false ;
  ];;
val tests : Kaputt.Test.t list =
  [<abstr>; <abstr>; <abstr>; <abstr>; <abstr>; <abstr>]
# Test.run_tests tests;;
- : unit = ()
# flush stdout;;
Test 'empty prefix' ... passed
Test 'empty strings' ... passed
Test 'empty second string' ... passed
Test 'empty second string and one-char prefix' ... failed
  duh! (expected `true` but received `false`)
Test 'different strings' ... passed
Test 'almost prefix' ... failed
  duh! (expected `true` but received `false`)
- : unit = ()

L'affichage par défaut est en mode texte sur la sortie standard. Nous pouvons passer un paramètre à Test.run_tests pour obtenir par exemple du XML sur la sortie d'erreur:

# Test.run_tests ~output: (Test.Xml_output stderr) tests;;
- : unit = ()
# flush stderr;;
<kaputt-report>
  <passed-test name="empty prefix"/>
  <passed-test name="empty strings"/>
  <passed-test name="empty second string"/>
  <failed-test name="empty second string and one-char prefix" expected="true" actual="false" message="duh!"/>
  <passed-test name="different strings"/>
  <failed-test name="almost prefix" expected="true" actual="false" message="duh!"/>
</kaputt-report>
- : unit = ()

Ce format de sortie est surtout utile pour alimenter d'autres outils, notamment d'intégration continue. La variante Test.Xml_junit_output permet la sortie dans un format compatible JUnit, pour s'interfacer avec Jenkins.

Il est possible d'avoir une structure de données en retour de l'exécution des tests, en utilisant par exemple Test.exec_tests:

# Test.exec_tests tests;;
- : Kaputt.Test.result list =
[Kaputt.Test.Passed; Kaputt.Test.Passed; Kaputt.Test.Passed;
 Kaputt.Test.Failed
  {expected_value = "true"; actual_value = "false"; message = "duh!"};
 Kaputt.Test.Passed;
 Kaputt.Test.Failed
  {expected_value = "true"; actual_value = "false"; message = "duh!"}]
4. Tests de spécifications

Un test de spécification est défini par une pré-condition et une post-condition. Ces types de tests utilisent des générateurs de valeurs aléatoires à tester. Quand une valeur vérifie une pré-condition, la post-condition associée doit être vérifiée, sinon le test échoue pour cette valeur et cette spécification.

Les spécifications sont exprimées à l'aide du module Specification, qui offre également des facilités pour construire des prédicats (pré- et post-conditions) sur différentes structures de données (piles, listes, ...) ainsi que des combinateurs de prédicats.

Les générateurs de valeurs aléatoires sont définis à l'aide du module Generator, qui contient également des générateurs pour les types de base et les structures de données courantes.

La fonction Test.run_random_test permet de créer un test de spécification, en prenant un générateur, la fonction à tester et une spécification.

Plutôt que décrire toutes les possibilités offertes par Kaputt, prenons comme exemple le test d'une fonction to_roman convertissant un entier en sa représentation en chiffres romains (le code est ici mais il n'est pas utile de le voir pour la suite).

# to_roman;;
- : int -> string = <fun>
# to_roman 142;;
- : string = "CXLII"
# to_roman 4999;;
- : string = "MMMMCMXCIX"

Nous pouvons bien sûr utiliser des tests d'assertions pour tester la représentation retournée pour certaines valeurs. Comme il n'est pas possible de tester la fonction pour toutes les valeurs possibles en entrée, nous allons utiliser des tests de spécifications.

Le générateur aléatoire à utiliser devra générer des valeurs entières strictement supérieures à 0 et inférieures à 6000 (valeur choisie pour éviter d'allouer des méga-octets de chaînes de caractères, car il est peu probable que nous souhaitions convertir de tels nombres). Nous le créons en utilisant la fonction Generator.make_int:

# let generator = Kaputt.Generator.make_int 1 6_000;;
val generator : int Kaputt.Generator.t = (<fun>, <fun>)

L'opérateur infixe (==>) permet de définir une spécification à partir de deux prédicats, l'un sur les valeurs d'entrée de la fonction, l'autre sur les valeurs de sortie:

# open Kaputt.Specification;;
# (==>);;
- : 'a Kaputt.Specification.predicate ->
    'b Kaputt.Specification.predicate -> ('a, 'b) Kaputt.Specification.t
= <fun>

Nous définissons maintenant quelques spécifications:

  • Quand une valeur se termine par un 9, alors la représentation en chiffres romains se termine par "IX":
    # let end_by_9 =
      (fun x -> x mod 10 = 9) ==>
      (fun s ->
        let len = String.length s in
        String.sub s (len - 2) 2 = "IX");;
    val end_by_9 : (int, string) Kaputt.Specification.t =
      {precond = <fun>; postcond = <fun>}
  • Quand une valeur est un multiple de 10, sa représentation en chiffres romains ne peut pas comporter les caractères 'I' et 'V':
    # let mult_of_10 =
      (fun x -> x mod 10 = 0) ==>
      (Kaputt.Specification.for_all_string (fun c -> c <> 'I' && c <> 'V')) ;;
    val mult_of_10 : (int, string) Kaputt.Specification.t =
      {precond = <fun>; postcond = <fun>}
  • Quand une valeur est un multiple de 5, sa représentation en chiffres romains ne peut se terminer par 'I' ni avoir 'I' en avant-dernière position:
    # let mult_of_5 =
      (fun x -> x mod 5 = 0) ==>
      (fun s ->
        let len = String.length s in
        String.get s (len-1) <> 'I' &&
        (len < 2 || String.get s (len - 2) <> 'I')
      );;
    val mult_of_5 : (int, string) Kaputt.Specification.t =
      {precond = <fun>; postcond = <fun>}

Nous pouvons maintenant utiliser ces spécifications pour créer un test de notre fonction. Un test peut utiliser plusieurs spécifications, chaque valeur générée étant utilisée par la première spécification de la liste dont elle vérifie la pré-condition:

# let test_to_roman = Kaputt.Test.make_random_test
  ~title: "Test de la conversion en chiffres romains"
  generator to_roman
  [ end_by_9 ; mult_of_10 ; mult_of_5 ] ;;
val test_to_roman : Kaputt.Test.t = <abstr>

Finalement, il nous reste à lancer le test

# Kaputt.Test.run_test test_to_roman;;
- : unit = ()
# flush stdout ;;
Test 'Test de la conversion en chiffres romains' ... 100/100 cases passed
- : unit = ()

Le nombre de valeurs générées et le nombre d'essais de génération pour trouver une spécification dont la pré-condition est vérifiée peuvent être passés en paramètre à Test.make_random_test:

# let test_to_roman = Kaputt.Test.make_random_test
  ~title: "Test de la conversion en chiffres romains"
  ~nb_runs: 1000 ~nb_tries: 2
  generator to_roman
  [ end_by_9 ; mult_of_5 ; mult_of_10 ] ;;
val test_to_roman : Kaputt.Test.t = <abstr>
# Kaputt.Test.run_test test_to_roman;;
- : unit = ()
# flush stdout ;;
Test 'Test de la conversion en chiffres romains' ... 644/644 cases passed
- : unit = ()
5. Exercice

Ecrire une fonction of_roman : string -> int prenant une chaîne de caractères représentant un nombre romain et retournant ce nombre sous forme d'un entier (ou bien utiliser la fonction of_roman ici sans regarder le code de trop près pour faire un vrai test «boîte noire»).

# of_roman "IX";;
- : int = 9
# of_roman "XIV";;
- : int = 14
# of_roman "MMCM";;
- : int = 2900

La fonction comporte un bogue. Réaliser des tests d'assertions et des tests de spécifications pour le trouver.

6. Conclusion

Nous avons vu comment utiliser Kaputt et la souplesse qu'il permet pour s'insérer dans l'environnement de compilation d'un logiciel OCaml.

Quelques exemples d'utilisation ont donné un aperçu des possibilités de Kaputt, qui en offre bien d'autres (possibilités de classification des tests de spécifications selon la valeur d'entrée, choix de la source aléatoire, réduction des contre-exemples à des valeurs plus petites, ...) que nous n'avons pas explorées ici mais qui sont indiquées dans le manuel de Kaputt et dans la documentation des modules.

Kaputt n'est pas le seul cadriciel pour effectuer des tests de code en OCaml. Il existe également OUnit, mais ce dernier est moins bien documenté et offre moins de souplesse que Kaputt quant aux possibilités d'articuler le code de test et le code testé. Il ne semble pas non plus offrir la richesse des facilités de Kaputt pour effectuer des tests de spécifications.

Il existe également ppx_inline_test, offrant une extension de syntaxe pour définir facilement des tests et créer un exécutable pour les lancer. Cependant, cet outil n'offre pas le même niveau de fonctionnalités que Kaputt: pas de génération de valeurs, pas de tests de spécifications. Le format de sortie est cependant analysable grammaticalement en aval.

Enfin, ppx_expect est une autre extension de syntaxe permettant de spécifier la sortie attendue (une chaîne de caractères). Si elle n'est pas fournie, l'outil est capable après une première exécution d'ajouter dans le code source les chaînes de résultat attendues, afin de faciliter l'écriture de tests. La sortie se présente sous forme de différences (format diff) entre la sortie et la sortie attendue.


1 Sans pour autant forcément tomber dans le Test Driven Development.