Azure Native Qumulo Maintenant disponible dans l'UE, au Royaume-Uni et au Canada - En savoir plus

Rendre la couverture du code à 100 % aussi simple que de lancer une pièce

Rédigé par:

Nous sommes obsédés par les tests à Qumulo.

En envoyant une nouvelle version d'un système de fichiers distribué et évolutif toutes les deux semaines, nous devons être sûrs que chaque validation de notre base de code est exempte de bogues. À cette fin, nous avons des dizaines de milliers de tests unitaires, des milliers de tests d’intégration et des centaines de tests complets du système qui vérifient que nous n’avons pas introduit de régression.

Bien que ces types de tests soient importants, nous voulions aller plus loin. Nous voulions un moyen d'exécuter tous les chemins possibles dans un morceau de code particulier afin de pouvoir vérifier que, quel que soit le code, le comportement du code était correct et que les invariants du système étaient conservés. Une approche traditionnelle à ce problème pourrait consister à inspecter manuellement les rapports de couverture de code et à créer des tests individuels pour exercer chaque branche du code, mais cela est fragile car un humain doit inspecter la couverture de code et implique souvent un test complexe parfois complexe. exercer le code non couvert. Alternativement, nous pourrions écrire des tests de fuzz qui explorent de manière probabiliste chaque branche du code, mais ceux-ci sont difficiles à concevoir et, de par leur nature, peuvent ne pas explorer tous les cas intéressants. Nous avons pensé pouvoir faire mieux.

Un exemple motivant

Imaginez que nous ayons une fonction qui désérialise une commande kumquat d'un format binaire en une structure kumquat_order</var/www/wordpress>:

struct kumquat_order {nom de caractère [100]; quantité non signée; }; int deserialize_kumquat_order (struct istream * input, struct kumquat_order * out) {int error_code = istream_read (entrée, sortie-> quantité, taille de sortie-> quantité); if (code_erreur! = 0) renvoie code_erreur; booléen anonyme; error_code = istream_read (entrée, & anonyme, taille de l'anonyme); if (code_erreur! = 0) renvoie code_erreur; if (anonyme) strcpy (commande-> nom, "anonyme"); else istream_read (entrée, sortie-> nom, taille de sortie-> nom); return 0; }

L'un des invariants de cette fonction est que si un appel dans le flux retourne une erreur, la fonction doit également renvoyer une erreur. Comme vous pouvez le voir, cet invariant a été violé lorsque nous lisons le nom de la commande, où le code d'erreur de istream_read()</var/www/wordpress> is completely ignored:

istream_read(input, out->name, sizeof out->name);</var/www/wordpress>

Dans ce cas, il ne serait pas incroyablement difficile d'écrire un test unitaire pour découvrir ce bogue, mais comme nous ajoutons plus de champs au kumquat_order et plus de complexité au format binaire, il deviendrait de plus en plus difficile de vérifier notre invariant et protéger contre cette classe de bogue. Ce que nous aimerions vraiment, c’est une implémentation de l’interface istream qui peut renvoyer de manière déterministe une erreur à chaque point appelé.

Présentation de rseq

Afin de mener ces tests de couverture exhaustifs, nous avons créé un composant que nous appelons rseq, qui signifie «séquence aléatoire» (bien que ce soit un peu trompeur - il n'y a rien de «aléatoire» à propos de rseq). L'interface fondamentale pour rseq est remarquablement simple:

struct rseq; booléen rseq_flip_coin (struct rseq *); booléen rseq_next_simulation (struct rseq *);

rseq est construit sur le concept d'une simulation de test. Chaque fois que le test atteint un point de décision qu’il veut faire partie de l’ensemble des décisions sous simulation, il fait appel à rseq_flip_coin()</var/www/wordpress>, which returns a boolean that the test can use to influence the behavior of this simulation of the test. At the end of each simulation, the test calls rseq_next_simulation()</var/www/wordpress>, which will return false when there are no more simulations with unique paths through the decision space. Let’s look at a simple example:

struct rseq * rseq = rseq_new (); faire {printf ("% c", rseq_flip_coin (rseq)? 't': 'f'); printf ("% c", rseq_flip_coin (rseq)? 't': 'f'); printf ("% c,", rseq_flip_coin (rseq)? 't': 'f'); } while (rseq_next_simulation (rseq));

La sortie de ce code est l'ensemble complet des combinaisons de décisions:

fff, fft, ftf, ftt, tff, tft, ttf, ttt,</var/www/wordpress>

Surtout, rseq est capable de travailler même si les appels à rseq_flip_coin()</var/www/wordpress> are conditional. For example, if we only make the second call to rseq_flip_coin()</var/www/wordpress> if the first call returns true:

struct rseq * rseq = rseq_new (); faire {if (rseq_flip_coin (rseq)) {printf ("a"); printf ("% c", rseq_flip_coin (rseq)? 'b': 'c'); } else printf ("d"); printf ("% c,", rseq_flip_coin (rseq)? 'e': 'f'); } while (rseq_next_simulation (rseq));

La sortie serait:

df, de, acf, ace, abf, abe</var/www/wordpress>

Les décisions non binaires (par exemple, choisir entre cinq options différentes) peuvent être mises en œuvre en utilisant des flip de pièces, mais rseq fournit utilement une fonction qui le fait pour vous:

unsigned rseq_roll_die(struct rseq *, unsigned sides);</var/www/wordpress>

rseq internes

Comme vous l'avez peut-être remarqué, rseq effectue une sorte de recherche «fausse première» dans l'espace de décision. Cela se fait de manière relativement simple: en suivant un script de décisions à renvoyer, implémenté avec un vecteur de valeurs booléennes représentant les décisions de retournement de pièces et un curseur pour suivre la position actuelle dans le script.

Comme appels à rseq_flip_coin()</var/www/wordpress> are made, the decision at the current cursor is returned. If the cursor is at the end of the script when the call is made, we append a false decision to the script and return false from the rseq_flip_coin()</var/www/wordpress> call.

Quand rseq_next_simulation()</var/www/wordpress> is called, we trim trailing true decisions from the end of the script, which represent decision paths that have been fully explored, and flip the last false decision in the script to true and rseq_next_simulation() returns true. If the script is empty after trimming trailing true decisions, the decision tree has been fully explored and rseq_next_simulation()</var/www/wordpress> returns false.

Au cas où l'anglais n'était pas clair, nous avons créé un version Python interactive de base de rseq sur repl.it, avec lequel vous pouvez jouer pour vous aider à comprendre comment cela fonctionne.

Test exhaustif de la kumquat_order</var/www/wordpress> deserializer

Un moyen courant d’utiliser rseq chez Qumulo est une implémentation test-double d’une interface qui est injectée dans les dépendances du système sous test (SUT).

Pour revenir à l'exemple ci-dessus, ce que nous aimerions, c'est une version de l'interface istream qui renvoie une erreur ou passe à une autre implémentation de istream qui fait le travail réel. Pour des raisons qui apparaîtront plus tard, nous allons également vouloir savoir que certains appels dans cette instance de rseq ont renvoyé une erreur. Voici un exemple d'implémentation qui utilise un peu la magie de l'interface Qumulo C:

struct rseq_error_istream {a_implements (istream); // Magie Qumulo! struct rseq * rseq; booléen a renvoyé l'erreur; // Initialisé à false struct istream * delegate; }; int rseq_error_istream_read (struct rseq_error_istream * self, void * data, unsigned c) {if (rseq_flip_coin (self-> rseq)) {self-> returned_error = true; return -1; } else return istream_read (self-> delegate, data, c); }

En utilisant rseq ici, nous sommes garantis qu'au moment où rseq_next_simulation()</var/www/wordpress> returns false, every call into istream_read made by the SUT will have returned an error in at least one of the simulations. Using such an implementation of istream, we can now write a test which will fail when run against our kumquat_order</var/www/wordpress> deserializer:

struct rseq * rseq = rseq_new (); do {// Configuration de l'appareil struct file_istream * order_istream = file_istream_new ('test_order')); struct rseq_error_istream * rseq_istream = rseq_error_istream_new (rseq, istream_from_file_istream (order_istream)); // Exercice SUT struct kumquat_order order; erreur int = désérialiser_kumquat_order (istream_from_rseq_error_istream (rseq_istream), & order); // Vérifie if (rseq_istream-> returned_error) assert (error == -1); else assert (kumquat_order_is_correct (& order)); // Démontage de l'appareil rseq_error_istream_free (rseq_istream); file_istream_free (order_istream); } while (rseq_next_simulation ());

Ce qui est vraiment génial avec ce test, c'est qu'il est complètement indifférent à la façon dont deserialize_kumquat_order()</var/www/wordpress> uses the istream. As we add more complexity to the deserialization code, this test will continue to exercise every possible error path through the SUT, verifying our invariant without us having to write a new test for each new path. We could even extend the invariant to say that no calls should be made into the istream after it returns an error with relative ease by making the following modification:

int rseq_error_istream_read (struct rseq_error_istream * self, void * data, unsigned c) {assert (! self-> returned_error); if (rseq_flip_coin (self-> rseq)) {self-> returned_error = true; return -1; } else return istream_read (self-> delegate, data, c); }

Autres utilisations pour rseq

La vérification rapide est l'une des nombreuses utilisations de rseq chez Qumulo. Nous avons également fait des choses intéressantes avec rseq:

  • Simuler chaque commande possible de RPC livrés simultanément à un nœud de notre cluster
  • En simulant chaque entrelacement possible de threads coopératifs dans l'espace utilisateur (oui, nous avons écrit notre propre bibliothèque de threading dans l'espace utilisateur!) En utilisant rseq dans le planificateur pour sélectionner le prochain thread à exécuter lorsqu'un thread revient.
  • Simuler tous les états possibles d'un système complexe et démontrer que notre code peut partir de cet état (par exemple, récupération de transaction distribuée)
    rseq pièges

Bien que rseq soit extrêmement utile, il présente toutefois des pièges:

  • rseq ne fonctionne pas bien avec le non-déterminisme car il repose sur l'ordre des appels à rseq_flip_coin () étant déterministe pour chaque simulation. Par exemple, si le SUT utilise un rand () pour décider s'il doit passer un appel qui finira par appeler rseq, il deviendra rapidement confus. Pour contourner ce problème, nous encapsulons ce caractère aléatoire dans une interface à injections de dépendance qui a une implémentation de production qui appelle rand () mais qui peut être remplacée par un double de test utilisant rseq lors de l'exécution des tests.
  • De même, il est dangereux d'utiliser rseq dans un environnement multithread. Si plusieurs threads peuvent agir sur la même instance de rseq pendant une simulation, cela introduit un non-déterminisme dans l'ordre d'appel de rseq. Pour cette raison et pour bien d’autres raisons, nous nous efforçons de conserver la plus grande partie de notre code mono-thread, en utilisant des patterns d’appel asynchrones plutôt que de générer des threads chaque fois que cela est possible.
  • Lorsque le SUT est complexe, rseq peut entrer dans une explosion d'état, ce qui entraîne une croissance exponentielle du nombre de simulations à exécuter dans l'espace de décision. Dans la pratique, nous n'avons pas eu trop de problèmes avec cela, mais heureusement, rseq se prête bien à une utilisation distribuée avec des travailleurs prenant chacun une partie de l'espace de recherche si nous voulons le faire.

Articles Similaires

Remonter en haut