Dans ce petit document je vais juste présenter comment je fais mes tests unitaires en C. Internet regorge d'articles de ce genre, mais tant pis. Ça fera un de plus, et ça ne peut pas faire de mal. J'ai remarqué que nombreux sont les développeurs qui négligent les tests (unitaires, fonctionnels, etc). Ici, on va se concentrer sur les tests unitaires. Attention, je ne dis pas que les tests unitaires suffisent à garantir la qualité d'un projet. Ils assurent seulement une qualité minimale. Quand on trouve un bug, on écrit un test de non régression, etc. Les tests fonctionnels sont à mon sens plus importants, mais ils contiennent une notion métier, donc j ene vais pas en parler dans de document.
Il y a déjà pas mal de "frameworks" (je mets des guillemets, parce que bon... c'est un bien grand mot) pour les tests unitaires en C. J'en ai testé quelques uns, comme check ou encore cgreen. Une liste assez complète est disponible ici. Ils ont tous quelque chose qui me déplaît (genre autotools), ou en trop, etc. Et comme j'aime bien programmer des choses qui font exactement ce dont j'ai envie, plutôt que d'adapter les autres utilitaires, j'ai fait un petit framework à ma sauce.
Pour comprendre les avantages que l'ont peut tirer d'une bonne utilisation des tests unitaires, se reporter à ce lien. Je me répète, mais il ne faut pas s'appuyer que sur les tests unitaires pour apprécier la qualité et la robustesse d'un programme, mais c'est un des éléments qui fait que ledit programme n'explose en plein vol un peu trop souvent chez des clients.
Je me suis donc programmé quelques macros super simples qui "font le boulot", comme on dit. Le code est sous licence WTFPL.
NOTE : pour des questions de clarté et de lisibilité, les macros peuvent au premier abord être expurgées de lignes de code qui ne prennent leur sens que plus tard dans le document. Donc si le code présenté ici n'est pas exactement le même que dans le tarball, c'est normal, pas la peine de s'affoler.
Commençons par la structure centrale de ce framework :
struct cunit
{
void *expected; /* the expected result */
void *result; /* the computed result */
enum working_type type;
size_t type_size;
char *msg; /* add an information in per-test output */
void (*pprint)(struct cunit *self); /* per-type pretty printer */
void (*teardowns[C_MAX_CALLBACK])(void*);
void *teardowns_ctx[C_MAX_CALLBACK]; /* the context teardown */
int current_teardown;
};
Cette structure est un contexte que je remplis pour chaque assertion à tester. expected est un pointeur vers la valeur attendue, alors que result est le pointeur vers le résultat obtenu dans la fonction à tester. Exemple :
/* ... */
struct cunit c;
int result = successor(41); /* successor(i) should return i+1 */
int expected = 42;
c.result = &result;
c.expected = &expected;
/* ... */
Il faut ensuite dire à notre contexte que le type à tester est un entier. Pour cela on se reporte à l'ensemble des valeurs possibles, définies dans le type enum working_type, par exemple C_INT pour un entier standard, C_UINT pour un entier non signé, C_PTR (pointeur), C_SIZE (type size_t), etc. D'après notre exemple il faut opter pour C_INT.
c.type = C_INT;
Le champ type_size contient la taille mémoire de la valeur à tester. Donc 1 pour un char, sizeof(mon type) autrement, ou bien une longueur arbitraire si on veut vérifier un agrégat ou un type non primitif, i.e. struct in6_addr pour une adresse IPv6. Le champ type_size n'aura pas à être rempli par le programmeur, sauf dans le cas d'un test d'une zone mémoire (structure continue en mémoire, tableau, etc), auquel cas on utiliser le type C_RAW, par exemple :
int my_array[10];
c.type = C_RAW;
S'agissant du champ char *msg de la structure, il s'agit simplement d'une chaîne de caractères aidant à la lecture lorsqu'un erreur se produit, par exemple :
c.msg = "I'm testing a corner case when the argument is exaclty -1";
int expected = -1;
c.expected = &expected;
Ce message apparaîtra si le test échoue. Afin d'afficher lisiblement quelle valeur était attendue et quelle valeur a été testée, on utilisera la fonction pprint (pour pretty print) de la structure cunit. Cette fonction saura quoi afficher grâce au champ type. L'utilisateur n'a pas besoin de l'initialiser "à la main", c'est fait automatiquement avant chaque exécution d'un test.
Dans tout bon système de test, on a envie de nettoyer le contexte après le test à proprement parler : libérer la mémoire allouée, etc. Pour cela on utilise des fonctions de teardown. Pour être le plus générique possible, je définis une fonction de teardown avec le prototype suivant :
void my_teardown(void *context);
On peut avoir besoin de plusieurs appels à des fonctions de nettoyage, après l'exécution d'un test unitaire. Il suffit alors d'avoir une pile de callbacks qu'on appelle, ce qui explique les champs suivant, dans notre structure :
void (*teardowns[C_MAX_CALLBACK])(void*);
void *teardowns_ctx[C_MAX_CALLBACK]; /* the context teardown */
int current_teardown;
On a défini arbitrairement un nombre maximum (C_MAX_CALLBACK) de callbacks que l'on peut enregister pour chaque test unitaire. Le tableau teardowns_ctx correspond aux paramètres associés aux callbacks de nettoyage. Le champ current_teardown correspond à la taille de la pile des callbacks. On l'incrémente donc quand on enregistre une nouvelle callback, et on le décrémente lorsqu'on dépile les callbacks.
Comment appelle-t-on une macro d'assertion, en vrai ? Très simple, voici un exemple :
int result = successor(41);
int expected = 42;
cassert_eq(&expected, &result, C_INT, "trivial case");
Pour tester la non-égalité entre deux valeurs, on appelle cassert_neq(), qui respecte le même prototype. En ajoutant le système de teardown, voici ce qu'on obtient :
static int
test_alloc_and_set_1(void)
{
int *result = alloc_and_set(42); /* allocate memory for an integer and
* sets it to 42 */
int *zero = alloc_and_set(0);
/* First, did the memory allocation succeed? */
cassert_neq(NULL, result, C_PTR, "malloc?");
/* OK, now we check that the value of *result is 42 */
int expected = 42;
int zero_expected = 0;
/* But after the test, we must release the memory allocated... */
cunit_register_teardown(free, result);
cunit_register_teardown(free, zero);
/* OK, let's test! */
cassert_eq(&expected, result, C_INT, "trivial case");
cassert_eq(&zero_expected, zero, C_INT, "another dummy test");
/* Call the teardowns */
cunit_teardown();
return 0;
}
NOTES :
cassert_eq((int[]){42}, result, C_INT, "trivial case");
cassert_eq((int[]){0}, zero, C_INT, "another dummy test");
Ça ne facilite pas forcément la lecture, quand on n'a pas l'habitude, mais ça
a l'avantage de rendre le test plus concis, également. Il s'agit vraiment
d'une histoire de goût ici.alloc_and_set() est une fonction de mon module alloc. Je prends pour habitude de préfixer toute fonction du module foo par foo_. Je pense que c'est une habitude saine à avoir, mais ça n'engage que moi. Bref, revenons à notre macro. Si une assertion est fausse, alors on sort de la fonction affichant la chaîne de caractères décrivant l'erreur, puis on renvoie 1, sinon on renvoie 0.
Pour l'instant, on se met dans le cas où on ne fait pas de "profiling", c'est-à-dire qu'on appelle la macro cunit_run_test_noprofiling, définie comme suit :
#define cunit_run_test_noprofiling(test) do \
{ \
if(test()) { \
printf(KO "%s: %s: %s\n", __func__, #test, str); \
err++; \
} else { \
printf(OK "%s: %s\n", __func__, #test); \
} \
idx++; \
} while(/*CONSTCOND*/0)
/* ... */
#define cunit_run_test(test) do \
{ \
current_function = #test; \
if(profile) \
cunit_run_test_profiling(test); \
else \
cunit_run_test_noprofiling(test); \
} while(0/*CONSTCOND*/)
Maintenant, j'appelle cette macro dans la fonction principale du module testé, de cette manière :
char*
test_foo(void)
{
cunit_run_test(test_foo_function_1);
[...]
cunit_run_test(test_foo_function_N);
return 0;
}
Rien ne vaut un petit exemple pour saisir le fonctionnement. Je vais donc donner quelques tests unitaires écrits pour un module gérant une file. Il n'est pas nécessaire de donner le code source de ce module, les tests unitaires doivent suffire à comprendre les macros présentées ici. Si on veut tester la fonction de reset du module, on a quelque chose dans ce goût :
/*
* We verify that the /reset/ function does the job. The 'idx' field must be
* equal to 0 after the function call
*/
static char*
test_queue_rst_1(void)
{
queue_t q;
q.idx = 42; /* non-zero value */
queue_rst(&q);
cassert_eq((int[]){0}, &q.idx, C_INT, "q.idx after reset()");
return 0;
}
Petite précision, ici. Il s'agit de quelque chose de trivial quand on fait des tests, mais il est bon de le rappeler quand même. Avant de tester si une fonction renvoie les résultats "corrects", il faut vérifier que la fonction échoue si on lui donne des mauvaises valeurs en entrée. C'est un bon moyen de s'assurer que la fonction de test ne va pas toujours nous dire "tout va bien, coco !", même lorsqu'elle ne devrait pas.
Un exemple de test qui va échouer, maintenant :
static char*
test_queue_rst_2(void)
{
queue_t q;
q.idx = 42; /* non-zero value */
queue_rst(&q);
cassert_eq((int[]){42}, &q.idx, C_INT, "q.idx");
return 0;
}
char*
test_queue(void)
{
cunit_run_test(test_queue_rst_1);
cunit_run_test(test_queue_rst_2);
return 0;
}
En lançant les tests, on obtient :
$ ./test_queue [+] test_queue: test_queue_rst_1 [ERROR] test_queue: test_queue_rst_2: q.idx: expected 42, but got 0 2 passes, 1 failure.
Voilà pour la base de ce module. Je pense que c'est commun à tous les frameworks de tests unitaires C classiques.
Maintenant, ajoutons un petit élément sympathique à nos tests unitaires. On va en profiter pour faire une mesure basique des performances et de leur évolution. L'idée est de mesurer le temps d'exécution d'un test unitaire de manière assez précise, afin de stocker le résultat quelque part, avec le numéro de révision du code (on suppose qu'on travaille avec un gestionnaire de code, type git ou hg, pas vrai. Pas vrai ?). De cette manière, on pourra tracer l'évolution des performances de chaque fonction testée, et nous alerter en cas de dégradation notoire.
Un des problèmes lorsqu'on fait des tests, c'est qu'au bout d'un moment, lancer toute la suite de tests peut être assez long (pour un développement en cours, j'en suis à plus de 580 tests, par exemple). Or, la mesure du temps d'exécution du test doit être précise, et ne pas dépendre de la charge de la machine. Il faut donc lancer chaque test plusieurs fois. Afin de tendre rapidement vers un résultat moyen cohérent, on va utiliser la superbe fonction log, qui nous garantira d'obtenir un résultat stable du temps d'exécution de la fonction, en ne la faisant "tourner" que peu de fois. Un bon moyen de savoir si une fonction exécutée deux fois est dans le même ordre de grandeur est de comparer le log de leur temps d'exécution. Une variation faible du temps sera ainsi écrasée, et il faut une amplitude relativement importante pour que les valeurs des logs varient. Mais trève de bavardage, voici le code :
#define cunit_run_test_profiling(test) do \
{ \
int backup = err; \
int i = 0; \
int cmpt = 0; \
uint64_t ret = 0; \
uint64_t sum_ret = 0; \
double mean = 0; \
int log_10 = 0; \
int log_10_backup = 0; \
cunit_run_test_noprofiling(test); \
if(err != backup) { \
fprintf(stderr, "Please fix the error in '%s'.\n", #test); \
exit(EXIT_FAILURE); \
} \
for(i=0; cmpt<MIN_CMPT; i++) { \
ret = rdtsc(); \
test(); \
sum_ret += rdtsc() - ret; \
mean = ((double)sum_ret) / (i+1); \
log_10 = floor(100 * log10(mean)); \
if(log_10 == log_10_backup) \
cmpt++; \
else \
cmpt = 0; \
log_10_backup = log_10; \
} \
fprintf(stderr, "%.2f\t%s\n", \
((double)log_10)/100.0, #test); \
} while(/*CONSTCOND*/0)
Avant de lancer les mesures de temps d'exécution, on vérifie déjà que les tests passent sans erreurs (comparaison if(err != backup)).
La fonctions rdtsc() permet de récupérer le nombre de ticks d'horloge écoulés depuis le démarrage de l'ordinateur. Son code source sera donné plus loin. La valeur MIN_CMPT est une estimation du nombre minimal d'appels à la fonction avant d'avoir un résultat lissé. Ce résultat est empirique, mais doit être au moins supérieur à deux (pour faire une moyenne) : 4 est un bon compromis.
Le fonctionnement est simple : à chaque itération de la boucle, on compte le temps passé à exécuter la fonction de test, et on calcule la moyenne des temps d'exécution (on conserve la somme des temps des précédentes exécutions). Si la différence entre le log de la précédente moyenne et celui de la moyenne courante est nulle, alors on incrémente le compteur cmpt, et tant que ce compteur est inférieur à MIN_CMPT, on continue. Sinon, on remet le compteur à zéro, et on recommence.
Ensuite... Eh bien à vous de faire quelque chose avec ces valeurs, par exemple les sauver dans un fichier avec le numéro de révision courant du code, et ainsi de lever des warnings lorsqu'on push un patch sur un dépôt, si le différentiel de performance pour une fonction est trop important ; ou encore de générer des graphes d'évolution des performances par fonction, avec le numéro de révision en abscisse, etc. Les possibilités sont nombreuses, amusez-vous.
Voici le source de rdtsc() :
/*
* Return the number of cycles since the last boot
*/
uint64_t
rdtsc(void)
{
uint32_t a = 0;
uint32_t d = 0;
__asm__ volatile("rdtsc" : "=a" (a), "=d" (d));
return ((uint64_t)a) | (((uint64_t)d) << 32);;
}
On utilise l'instruction rtdsc (Read Time Stamp Counter), spécifique aux processeurs x86. Elle renvoie le nombre de ticks écoulés depuis le dernier reset du processeur, en remplissant les registres EDX:EAX (la valeur est sur 64 bits).
On peut noter que cette méthode n'est pas forcément la plus pertinente, surtout sur les processeurs multi-cores, pour lesquels on n'a pas l'assurance que les timers soient synchronisés sur les processeurs. Il y a d'autres considérations à prendre en compte, comme les modes hibernate, etc, qui peuvent pousser à choisir une autre mesure que cette commande (comme clock_gettime() en environnement POSIX ou HPET), mais ce n'est pas forcément très intéressant pour nos mesures de tests unitaires.
On va ajouter une autre petite feature sympathique : si un des tests échoue lamentablement à cause d'un SIGSEGV, il faut rattraper l'erreur, restaurer le contexte avant l'exécution de la fonction problématique, et passer à la suivante. C'est juste pratique quand on commence à avoir beaucoup de tests unitaires et qu'on veut avoir une vision d'ensemble avant de s'attaquer aux erreurs. On utilise pour ceci la combinaison magique sigaction() et setjmp.h.
#define SIGNAL_GUARD do \
{ \
if(!allow_crash && sigsetjmp(env, SIGSEGV) != 0) { \
sigsegv_count++; \
idx++; \
run = 0; \
} \
} while(/*CONSTCOND*/0)
#define cunit_run_test(test) do \
{ \
run = 1; \
current_function = #test; \
SIGNAL_GUARD; \
if(run) { \
if(profile) \
cunit_run_test_profiling(test); \
else \
cunit_run_test_noprofiling(test); \
} \
} while(0/*CONSTCOND*/)
Et dans un autre fichier, main.c par exemple, qui se charge de gérer la ligne de commande et le point d'entrée des tests unitaires, on a :
void
sig_handler(int nsig)
{
if(SIGSEGV == nsig) {
fprintf(stderr,
KO
"SIGSEGV caught in function %s. Continue the test suite.\n"
"\tIf you want to let the program crash for debug purpose, use "
"--allow-crash\n"
"\toption in command line\n",
current_function);
set_handler();
siglongjmp(env, 1);
}
}
void
set_handler(void)
{
memset(&sa, 0, sizeof sa);
sa.sa_handler = sig_handler;
sigaction(SIGSEGV, &sa, NULL);
}
[...]
if(!allow_crash)
set_handler();
[...]
Notez que dans le cas présent, on n'utilise pas setjmp() et longjmp(), tout simplement parce qu'ils ne sauvent pas le masque du signal que l'on veut intercepter. Il faut le préciser explicitement dans sigsetjmp(env, SIGSEGV). Si cette manipulation n'est pas faite, seule la première occurrence de SIGSEGV sera interceptée et traitée correctement.
On obtient des alors quelque chose de ce genre, sur des tests volontairement erronés :
[...] [+] test_thread_sniffer: test_thread_sniffer_mirror_drop_10 [+] test_thread_sniffer: test_thread_sniffer_mirror_drop_11 [ERROR] SIGSEGV caught in function test_queue_rst_1. Continue the test suite. If you want to let the program crash for debug purpose, use --allow-crash option in command line [+] test_queue: test_queue_rst_2 [ERROR] SIGSEGV caught in function test_queue_push_1. Continue the test suite. If you want to let the program crash for debug purpose, use --allow-crash option in command line [+] test_queue: test_queue_push_2 [+] test_queue: test_queue_push_3 [+] test_queue: test_queue_push_4 [...] [+] test_ipstats: test_macro_update_generic_tcp_6 526 passes, 0 failure, 2 segfaults
/*
* A C "boolean" is true of false. We know that false is 0, but true
* might be anything else. So, in this test we ensure that true is 1
* to fit we what we expect (STATEMENT_IS_RIGHT/WRONG = 1/0).
*/
static int
test_cunit_dummy(int statement, int ok)
{
int ret = !!statement;
cassert_eq(&ret, &ok, C_INT, "right statement");
return 0;
}
/*
* The two following tests ensure that a cassert_eq() doesn't return
* before the "return 0;", ie. obvious tests are still correct
*/
static int
test_cunit_handle_ok(void)
{
int expected = !0; /* the behaviour is correct */
int ret = (0 == test_cunit_dummy(1 == 1, STATEMENT_IS_RIGHT));
cassert_eq(&expected, &ret, C_INT, "Foo");
return 0;
}
[...]
/*
* Does the signal handler do correctly its job?
*/
static int
test_cunit_handle_sigsegv(void)
{
int foo[1];
foo[4242] = 0x42; /* SIGSEGV here */
return 0;
}
/*
* let's see if the handler is correctly set after the first
* SIGSEGV
*/
static int
test_cunit_handle_multiple_sigsegv(void)
{
test_cunit_handle_sigsegv();
return 0;
}
int
test_cunit(void)
{
cunit_run_test(test_cunit_handle_ok_1);
cunit_run_test(test_cunit_handle_ok_2);
cunit_run_test(test_cunit_handle_error_1);
cunit_run_test(test_cunit_handle_error_2);
mute = 1;
cunit_run_test(test_cunit_handle_sigsegv);
cunit_run_test(test_cunit_handle_multiple_sigsegv);
mute = 0;
return 0;
}
En modifiant légèrement le gestionnaire de signaux pour qu'il n'écrive pas sur stderr si la variable mute est mise à 1, etc. Au final nous obtenons quelque chose comme ceci :
[+] test_cunit: test_cunit_handle_ok_1 [+] test_cunit: test_cunit_handle_ok_2 [+] test_cunit: test_cunit_handle_error_1 [+] test_cunit: test_cunit_handle_error_2 [+] test_cunit: test_cunit_handle_sigsegv [+] test_cunit: test_cunit_handle_multiple_sigsegv [...] 526 tests, 0 failure, 0 segfault
Et là, on se sent un peu plus à l'aise quant à la robustesse de notre code, si nous prenons bien soin d'être méthodique et d'ajouter systématiquement des tests unitaires quand on développe des nouvelles fonctions.
En ce qui concerne les fichiers sources utilisés pour écrire nos tests unitaires, il faut garder à l'esprit 2-3 choses. Si on organise nos tests pour qu'ils vérifient le bon fonctionnement de fonctions visibles depuis un objet extérieur, il faut que les fonctions à tester aient leur prototype dans le .h idoine. Ce header sera inclu par le fichier se chargeant de tester le module associé.
Le problème de ce mode de fonctionnement, c'est qu'on exporte vraiment trop de symboles, et que ça va contre les principes de programmation qui relèvent du bon sens : à savoir, ne donner que les prototypes dont les autres modules ont besoin, etc. Sinon il y a véritablement une pollution des symboles exportés. La solution que j'adopte, et qui est à mon sens un pis-aller, c'est d'inclure le source du fichier de tests directement à la fin du fichier source à tester.
/* Some source code here */ #ifdef TESTING #include "test_mymodule.c" #endif
On peut bien sûr écrire directement les tests unitaires dans le fichier source, mais ça devient vite pénible, quand le fichier grossit démesurément, de browser ses sources. Un petit #include et c'est réglé en quelques lignes.
De cette manière, on peut conserver le mot-clef static devant les fonctions dont on n'exporte pas le prototype, et le code est plus clean !
Voilà, c'est tout.