Cours programmation réseau en C++

UDP - S'assurer du bon fonctionnement de son code

Ce troisième article montre la mise en place de tests afin de vérifier le bon fonctionnement de notre bibliothèque réseau.

22 commentaires Donner une note à l'article (5) 

Article lu   fois.

L'auteur

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

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 :

Tester.hpp
Sélectionnez
1.
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.

Utils_Test.hpp
Sélectionnez
1.
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.

AckHandler_Test.hpp
Sélectionnez
1.
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.

AckHandler_Test.cpp
Sélectionnez
1.
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.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2018 Cyrille (Bousk) Bousquet. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.