Un mini framework pour les tests unitaires en C.

1. La base des tests unitaires
2. Un petit indicateur de performances
3. Gérer les plantages indécents
4. Et la réflexivité dans tout ça ?
5. Autre(s) remarque(s)
1. La base des tests unitaires
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.
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 tilitaires, 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
Bien sûr, 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. Voici un exemple de fichiers que j’inclus quand j’écris des tests unitaires. Je vais le commenter petit à petit.
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 ode présenté ici n’est pas exactement le même que dans les sources, c’est normal, pas la peine de s’affoler.
char str[STR_BUFSZ];
#define NO_TEARDOWN
#define cunit_assert(test, teardown, message, …) do           \
{                                                           \
if(!(test))                                             \
{                                                       \
memset(str, 0, sizeof str);                         \
snprintf(str, STR_BUFSZ-1, message, ##__VA_ARGS__); \
teardown;                                           \
return 1;                                           \
}                                                       \
} while(/*CONSTCOND*/0)
#define cunit_run_test(test) do             \
{                                           \
if(profile)                             \
{                                       \
cunit_run_test_profiling(test);     \
}                                       \
else                                    \
{                                       \
cunit_run_test_noprofiling(test);   \
}                                       \
} while(0/*CONSTCOND*/)
Cette macro est le pendant du assert() mais pour mes tests unitaires. Je l’appelle ainsi, dans un code :
static int
test_foo_function_1(void)
{
[...]
ret = foo_function_1(arguments);
cunit_assert(value == ret,
NO_TEARDOWN,
“Expected value %d, but got %d”,
value, ret);
return 0;
}
foo_function_1() est une fonction de mon module foo. 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 l’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. Ici, nous sommes dans un cas où aucun teardown n’est nécessaire. Si notre test unitaire nécessite une allocation mémoire, ou quoi que ce soit qui engendre un “nettoyage”, il faut alors appeler un teardown dans la macro. En effet, dans le cas où l’assertion échoue, la macro sort de la fonction (return 1;), aussi il faut libérer la mémoire, etc. Voici un exemple :
static int
test_allocation(void)
{
int *foo = malloc(sizeof *foo);
int expected = 42;
*foo = expected;
cunit_assert(expected != *foo,
free(foo),
“Expected %d, but got %d”,
expected, *foo);
free(foo);
return 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)
Maintenant, j’appelle cette macro dans la fonction principale du module testé, de cette manière :
int
test_module_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);
cunit_assert(0 == q.idx,
NO_TEARDOWN,
“q.idx should be %d, but is %d”,
0, q.idx);
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 int
test_queue_rst_2(void)
{
queue_t q;
q.idx = 42; /* non-zero value */
queue_rst(&q);
cunit_assert(42 == q.idx,
NO_TEARDOWN;
“q.idx should be %d, but is %d”,
42, q.idx);
return 0;
}
int
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 :
poz@faust:~/cunit$ ./test_queue
[+] test_queue: test_queue_rst_1
[ERROR] test_queue: test_queue_rst_2: q.idx should be 42, but is 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.
2. Un petit indicateur de performances
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 520 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(3), 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;                                                   \
double ret = 0;                                                 \
double sum_ret = 0;                                             \
double mean = 0;                                                \
double log_10 = 0;                                              \
double 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 = (double)rdtsc();                                      \
test();                                                     \
sum_ret += ((double)rdtsc()) – ret;                         \
mean = sum_ret / (i+1);                                     \
log_10 = (int)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(3) en environnement POSIX ou HPET), mais ce n’est pas forcément très intéressant
pour nos mesures de tests unitaires.
3. Gérer les plantages indécents
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(2) 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(3) et longjmp(3), 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
4. Et la réflexivité dans tout ça ?
Les petits malins, à ce stade, disent «bah ouais mais qui me dit que ton framework fonctionne ?». Eh bien ils ont raison, idéalement, il faut tester son propre système de test. Bon, évidemment c’est le problème de l’oeuf et de la poule, il ne faut pas non plus tester le module de test du framework. On part du principe qu’on n’est pas trop stupide non plus, et qu’on sait s’arrêter quand on estime que le résultat est satisfaisant. Dans notre cas c’est assez facile, parce que cet environnement de test est vraiment léger et simple. Un exemple de test pourrait être :
static int
test_cunit_dummy(int statement, int ok)
{
cunit_assert(!!statement == ok,
NO_TEARDOWN,
“Expected right statement, but it’s not!”);
return 0;
}
static int
test_cunit_handle_ok_1(void)
{
int expected = !0; /* the behaviour is correct */
int ret = (0 == test_cunit_dummy(1 == 1, STATEMENT_IS_RIGHT));
cunit_assert(expected && ret,
NO_TEARDOWN,
“Expected %d, but got %d”,
expected, ret);
return 0;
}
[...]
/*
* Does the signal handler do correctly its job?
*/
static int
test_cunit_handle_sigsegv(void)
{
int foo[1];
foo[4242] = 0×42; /* 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;
}
static int
test_cunit_teardown(void)
{
int test = 0;
int val = !test;
/*
* To be sure that the teardown is executed, we test ’0′, which is
* wrong, by definition.  So, ‘goto out’ should be executed.
*/
cunit_assert(0, goto out, “Impossible!”);
out:
test = val;
cunit_assert(val == test,
NO_TEARDOWN,
“A problem occurred with the teardown!”);
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;
cunit_run_test(cunit_test_teardown);
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
[+] test_cunit: test_cunit_teardown
[...]
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.
5. Autre(s) remarque(s)
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.
  • Share/Bookmark

Leave a Reply