I. Tester son code▲
Dans un jeu multijoueur, la couche réseau est un élément critique puisque l’ensemble de l’application repose dessus. Bien que celle-ci soit intensément éprouvée lors du développement, il n’est pour autant pas réaliste de se contenter de ces tests, en particulier si l’on veut changer un fonctionnement interne. Afin d’assurer la pérennité de son comportement au reste de l’équipe, il convient que son fonctionnement atteigne toujours un niveau minimum de satisfaction et de qualité pour ne pas empêcher vos collègues de travailler pour cause de « problème réseau ». Aussi la mise en place de tests spécifiques à la bibliothèque réseau est un élément important, voire primordial à la réussite de cette dernière.
II. Un projet de tests▲
Afin de tester notre bibliothèque, il s’agira de créer un projet, une application minimale, qui l’utilise. Puisque la seule dépendance de la bibliothèque réseau est la std, une simple application console fera parfaitement l’affaire.
Je ne détaillerai pas ici comment créer un tel projet. Je présume que si vous êtes en train de lire cette série d’articles vous connaissez un minimum comment le langage fonctionne. À défaut, vous pouvez toujours vous référer à la section cours C++ de développez.com.
II-A. Quel framework de tests choisir ?▲
Il existe une multitude de frameworks de tests : CppUnit, Boost.Test, Unit++ pour n’en citer que quelques-uns. Et choisir l’un d’eux n’est pas chose aisée.
Aussi je ne vous pousserai pas à utiliser l’un ou l’autre, chacun est libre dans son choix.
Pour ma part, ne souhaitant pas m’encombrer d’une dépendance, je me contente de macros pour réaliser mes tests et afficher leur résultat et ce qui est testé.
II-B. Macros de test▲
Voici les quelques macros que j’utiliserai dans le cadre de cet article :
2.
3.
4.
5.
6.
7.
#pragma once
#include
<iostream>
#define CHECK_SUCCESS(expr) do { std::cout <<
"[OK] "
#expr << std::endl; } while(0)
#define CHECK_FAILURE(expr) do { std::cout <<
"[FAILURE] "
#expr << std::endl; } while(0)
#define CHECK(expr) do { if (expr) { CHECK_SUCCESS(expr); } else { CHECK_FAILURE(expr); } } while(0)
Ceci est ma base de travail que j’étofferai au fur et à mesure que le besoin le nécessite.
II-C. Programme de test▲
Le programme de test sera très simplement une fonction main qui appelle chacun des tests réalisés.
Je procède ainsi :
- pour chaque classe ou namespace XX à tester, je crée une classe XX_Test, que je déclare amie de XX s’il s’agit d’une classe ;
- chaque classe XX_Test se trouve dans son propre fichier ;
- chaque classe XX_Test a une unique fonction
public
static
void
Test(); ; - le fichier principal se charge d’inclure chaque test et d’exécuter cette fonction Test.
De nombreux tests ne nécessiteront pas de vérifier l’état interne des membres de la classe à tester - il s’agira plus de tests de comportement que de tests unitaires pour la majorité. L’amitié sera alors superflue dans l’absolu, mais en suivant ce modèle il est plus simple pour chacun de comprendre comment le tout fonctionne.
III. Notre premier test : les fonctions utilitaires▲
Commençons avec l’élément le plus bas niveau de notre bibliothèque actuellement puisqu’il ne possède qu’une dépendance à la std : les fonctions utilitaires de Utils.h.
Le but de cette série de tests est de vérifier que notre logique de détection d’un identifiant plus récent est correcte et nos champs de bits se comportent comme attendu.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
#pragma once
#include
"Tester.hpp"
#include
"Utils.hpp"
class
Utils_Test
{
public
:
static
void
Test();
}
;
void
Utils_Test::
Test()
{
CHECK(Bousk::Utils::
IsSequenceNewer(1
, 0
));
CHECK(!
Bousk::Utils::
IsSequenceNewer(0
, 1
));
CHECK(!
Bousk::Utils::
IsSequenceNewer(0
, 0
));
CHECK(Bousk::Utils::
IsSequenceNewer(0
, std::
numeric_limits<
uint16_t>
::
max()));
CHECK(Bousk::Utils::
SequenceDiff(0
, 0
) ==
0
);
CHECK(Bousk::Utils::
SequenceDiff(1
, 0
) ==
1
);
CHECK(Bousk::Utils::
SequenceDiff(0
, std::
numeric_limits<
uint16_t>
::
max()) ==
1
);
uint64_t bitfield =
0
;
CHECK(bitfield ==
0
);
Bousk::Utils::
SetBit(bitfield, 0
);
CHECK(Bousk::Utils::
HasBit(bitfield, 0
));
CHECK(bitfield ==
Bousk::Utils::
Bit<
uint64_t>
::
Right);
Bousk::Utils::
UnsetBit(bitfield, 0
);
CHECK(bitfield ==
0
);
Bousk::Utils::
SetBit(bitfield, 5
);
CHECK(Bousk::Utils::
HasBit(bitfield, 5
));
CHECK(bitfield ==
(Bousk::Utils::
Bit<
uint64_t>
::
Right <<
5
));
Bousk::Utils::
UnsetBit(bitfield, 0
);
CHECK(bitfield ==
(Bousk::Utils::
Bit<
uint64_t>
::
Right <<
5
));
Bousk::Utils::
UnsetBit(bitfield, 5
);
CHECK(bitfield ==
0
);
}
Il s’agit de vérifier que les identifiants sont correctement reconnus comme plus récents ainsi que le cas de la réinitialisation après la valeur maximale. Puis que nos fonctions de bits changent le bon bit sur notre champ de bits.
Il n’y a pas grand-chose à dire sur celui-ci, et peu de choses à tester réellement : ajoutez autant de cas à tester que vous le souhaitez jusqu’à être satisfait et convaincu que le tout fonctionne correctement.
IV. Tester le gestionnaire d’acquittements▲
Dans l’article précédent, nous avons implémenté notre premier élément de notre bibliothèque réseau : le gestionnaire d’acquittements d’identifiants.
Il est temps de s’assurer qu’il fonctionne correctement, en le testant.
2.
3.
4.
5.
6.
7.
#pragma once
class
AckHandler_Test
{
public
:
static
void
Test();
}
;
Au niveau de l’implémentation du test, il s’agit de tester les comportements critiques et singularités imaginables.
Pour le gestionnaire d’acquittements en particulier, voici les points à tester spécifiquement :
- Que se passe-t-il si l’on reçoit un identifiant en double ?
- Que se passe-t-il si l’on reçoit un identifiant ancien non déjà acquitté ?
- Que se passe-t-il si l’on reçoit un identifiant plus ancien que le masque actuel ?
- Que se passe-t-il si l’on reçoit un identifiant qui annule l’ensemble du masque actuel ?
- Que se passe-t-il si l’on reçoit un identifiant qui annule plus que l’ensemble du masque ?
- Comment sont gérées les valeurs extrêmes du masque ?
Et bien sûr les actions plus classiques afin de vérifier que les nouveaux identifiants sont correctement reconnus en tant que tels, que le masque évolue correctement, etc.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
118.
119.
120.
121.
122.
123.
124.
125.
126.
127.
128.
void
AckHandler_Test::
Test()
{
static
constexpr
uint64_t MASK_COMPLETE =
std::
numeric_limits<
uint64_t>
::
max();
static
constexpr
uint64_t MASK_FIRST_ACKED =
Bousk::Utils::
Bit<
uint64_t>
::
Right;
static
constexpr
uint64_t MASK_FIRST_MISSING =
~
MASK_FIRST_ACKED;;
static
constexpr
uint64_t MASK_LAST_ACKED =
(MASK_FIRST_ACKED <<
63
); Bousk::UDP::
AckHandler ackhandler;
CHECK(ackhandler.lastAck() ==
std::
numeric_limits<
uint16_t>
::
max());
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
CHECK(!
ackhandler.isAcked(0
));
CHECK(!
ackhandler.isNewlyAcked(0
));
CHECK(ackhandler.loss().empty());
//!
< Réception de l’identifiant #0
ackhandler.update(0
, MASK_COMPLETE, true
);
CHECK(ackhandler.lastAck() ==
0
);
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
CHECK(ackhandler.isAcked(0
));
CHECK(ackhandler.isNewlyAcked(0
));
CHECK(ackhandler.getNewAcks().size() ==
1
);
CHECK(ackhandler.getNewAcks()[0
] ==
0
);
CHECK(ackhandler.loss().empty());
//!
< Réception de l’identifiant #2 avec identifiant #1 manquant (63e bit du masque à 0)
ackhandler.update(2
, MASK_FIRST_MISSING, true
);
CHECK(ackhandler.lastAck() ==
2
);
CHECK(ackhandler.previousAcksMask() ==
MASK_FIRST_MISSING);
CHECK(ackhandler.isAcked(2
));
CHECK(ackhandler.isAcked(0
));
CHECK(ackhandler.isNewlyAcked(2
));
CHECK(!
ackhandler.isNewlyAcked(0
));
CHECK(ackhandler.loss().empty());
//!
< Réception de l’identifiant #1
ackhandler.update(1
, MASK_COMPLETE, true
);
CHECK(ackhandler.lastAck() ==
2
);
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
CHECK(ackhandler.isAcked(1
));
CHECK(ackhandler.isAcked(2
));
CHECK(ackhandler.isAcked(0
));
CHECK(ackhandler.isNewlyAcked(1
));
CHECK(!
ackhandler.isNewlyAcked(2
));
CHECK(!
ackhandler.isNewlyAcked(0
));
CHECK(ackhandler.loss().empty());
//!
< Réception de l’identifiant #66 avec masque vide : tous les identifiants [3,65] sont manquants, mais pas encore perdus
ackhandler.update(66
, 0
, true
);
CHECK(ackhandler.lastAck() ==
66
);
CHECK(ackhandler.isNewlyAcked(66
));
CHECK(ackhandler.previousAcksMask() ==
MASK_LAST_ACKED);
CHECK(ackhandler.loss().empty());
//!
< Réception de l’identifiant #67 avec masque vide
ackhandler.update(67
, 0
, true
);
CHECK(ackhandler.lastAck() ==
67
);
CHECK(ackhandler.isNewlyAcked(67
));
CHECK(!
ackhandler.isNewlyAcked(66
));
CHECK(ackhandler.previousAcksMask() ==
MASK_FIRST_ACKED);
CHECK(ackhandler.loss().empty());
//!
< Réception de l’identifiant #68, avec masque complet, l’identifiant #3 est maintenant perdu
ackhandler.update(68
, MASK_COMPLETE, true
);
CHECK(ackhandler.lastAck() ==
68
);
CHECK(ackhandler.isNewlyAcked(68
));
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
{
auto
loss =
ackhandler.loss();
CHECK(loss.size() ==
1
);
CHECK(loss[0
] ==
3
);
}
for
(uint16_t i =
4
; i <
66
; ++
i)
{
CHECK(ackhandler.isNewlyAcked(i));
}
//!
< Réception d’un identifiant plus vieux que le masque
ackhandler.update(0
, 0
, true
);
CHECK(ackhandler.lastAck() ==
68
);
CHECK(!
ackhandler.isNewlyAcked(68
));
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
//!
< Saut de 65 identifiants, réception de #133 avec masque vide
ackhandler.update(133
, 0
, true
);
CHECK(ackhandler.lastAck() ==
133
);
CHECK(ackhandler.previousAcksMask() ==
0
);
{
auto
loss =
ackhandler.loss();
CHECK(loss.size() ==
1
);
CHECK(loss[0
] ==
69
);
}
//!
< Réception de l’identifiant #132 avec masque complet
ackhandler.update(132
, MASK_COMPLETE, true
);
CHECK(ackhandler.lastAck() ==
133
);
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
CHECK(ackhandler.loss().empty());
//!
< Saut de 100 identifiants avec masque complet, identifiants [134, 169] sont perdus
ackhandler.update(234
, 0
, true
);
CHECK(ackhandler.lastAck() ==
234
);
CHECK(ackhandler.previousAcksMask() ==
0
);
{
auto
loss =
ackhandler.loss();
const
auto
firstLost =
134
;
const
auto
lastLost =
169
;
const
auto
totalLost =
lastLost -
firstLost +
1
;
CHECK(loss.size() ==
totalLost);
for
(auto
i =
0
; i <
totalLost; ++
i)
{
CHECK(loss[i] ==
firstLost +
i);
}
}
ackhandler.update(234
, MASK_COMPLETE, true
);
ackhandler.update(236
, MASK_COMPLETE, true
);
//!
< Saut de 65 identifiants avec masque vide
ackhandler.update(301
, 0
, true
);
CHECK(ackhandler.lastAck() ==
301
);
CHECK(ackhandler.previousAcksMask() ==
0
);
CHECK(ackhandler.loss().empty());
CHECK(!
ackhandler.isAcked(237
));
//!
< Acquittement de l’identifiant #237
ackhandler.update(237
, MASK_COMPLETE, true
);
CHECK(ackhandler.lastAck() ==
301
);
CHECK(ackhandler.previousAcksMask() ==
MASK_LAST_ACKED);
CHECK(ackhandler.loss().empty());
CHECK(ackhandler.isAcked(237
));
CHECK(ackhandler.isNewlyAcked(237
));
//!
< Acquittement de tous les identifiants via masque complet et doublon de #301
ackhandler.update(301
, MASK_COMPLETE, true
);
CHECK(ackhandler.lastAck() ==
301
);
CHECK(ackhandler.previousAcksMask() ==
MASK_COMPLETE);
CHECK(ackhandler.loss().empty());
//!
< Vérification de la transformation du masque en identifiants
ackhandler.update(303
, MASK_COMPLETE, true
);
auto
newAcks =
ackhandler.getNewAcks();
CHECK(newAcks.size() ==
2
);
CHECK(newAcks[0
] ==
302
);
CHECK(newAcks[1
] ==
303
);
}
Ici encore, n’hésitez pas à ajouter autant de tests et cas que vous voulez afin de couvrir un maximum de scénarios.
V. Gestionnaire de versions▲
Un autre élément crucial lors de tout développement est d’avoir un gestionnaire de versions (ou SCM de l’anglais Source Control Management).
Il en existe plusieurs, chacun ayant ses avantages et inconvénients, tels que Subversion (SVN), Mercurial, Git, Perforce pour en citer quelques-uns des plus connus.
Pour avoir plus d’informations, je vous invite à vous rendre sur le forum dédié, lire des tutoriels ou FAQ correspondantes.
V-A. Pourquoi utiliser un gestionnaire de versions ?▲
Un gestionnaire de versions permettra de faire un suivi des modifications apportées à chaque fichier. L’utilisation de base permet également d’accompagner chaque modification d’un commentaire.
Ceci permet de créer un historique de chaque fichier, que l’on peut parcourir à tout instant pour voir l’évolution (d’une partie) d’un fichier permettant de tracer chaque modification dans le temps.
Surtout, en cas de problème majeur, il est possible de revenir à une version antérieure et fonctionnelle d’un fichier, assurant un état suffisant au reste de l’équipe en attendant de corriger le bogue – l’historique étant alors une aide précieuse afin de retrouver l’origine de l’erreur et comprendre son introduction.
V-B. Versions de support du cours▲
Le choix de l’un ou l’autre fait plus office de débat religieux qu’autre chose selon moi, aussi je n’entrerai pas dans plus de détails et vous laisse vous faire votre avis avec les ressources citées dans le paragraphe précédent. Personnellement pour l’écriture de ce cours, j’utilise Mercurial et vous pouvez retrouver le dépôt hébergé ici sur BitBucket. À la seule différence que le code hébergé sur BitBucket a des commentaires anglais puisque je compte le publier à terme.
Chaque chapitre est représenté par un tag de version : la V1.0 était la fin du chapitre 8 de TCP, et le précédent chapitre présentant le gestionnaire d’acquittements est la V1.0.2. Le chapitre actuel sera la V1.0.3. Le tag du chapitre sera désormais indiqué en fin de chaque article pour pouvoir s’y référer sur le dépôt.
VI. Conclusion▲
À titre d’exemple, ces tests m’ont permis de trouver trois erreurs lors de l’écriture du précédent article.
Idéalement, vous devriez exécuter ces tests à chaque fois que vous modifiez un fichier de votre bibliothèque réseau afin de vous assurer de ne rien casser dans les différentes implémentations.
Bien sûr ces tests ne sont pas figés et seront amenés à évoluer. Chaque fois que vous rencontrez un nouveau bogue, ou pensez à un nouveau scénario, ajoutez ses étapes de reproduction dans un test afin de vérifier que votre hypothèse est correcte, puis que votre correction fonctionne sans régresser quoi que ce soit d’autre.
Récupérez le code source sous le tag V1.0.3 dans le dépôt Mercurial à l’adresse suivante.
Article précédent |
Article suivant |
---|---|
<< UDP – Gérer les pertes et duplications d’identifiants | UDP – Créer son protocole par-dessus UDP >>> |