Ceci est un extrait de l'eBook Unit Testing Succinctly de Marc Clifton, gracieusement fourni par Syncfusion..
L'expression "prouver l'exactitude" est normalement utilisée dans le contexte de la véracité d'un calcul, mais en ce qui concerne les tests unitaires, prouver l'exactitude comporte en réalité trois grandes catégories, dont la seconde seulement concerne les calculs eux-mêmes:
Il existe de nombreux aspects d'une application dans lesquels le test unitaire ne peut généralement pas être appliqué pour prouver l'exactitude. Ceux-ci incluent la plupart des fonctionnalités de l'interface utilisateur telles que la mise en page et la convivialité. Dans de nombreux cas, les tests unitaires ne constituent pas la technologie appropriée pour tester les exigences et le comportement des applications en termes de performances, de charge, etc..
Prouver l'exactitude implique:
Regardons quelques exemples de chacune de ces catégories, leurs forces, faiblesses et problèmes que nous pourrions rencontrer avec notre code.
La forme de test unitaire la plus élémentaire consiste à vérifier que le développeur a écrit une méthode qui énonce clairement le «contrat» entre l'appelant et la méthode appelée. Cela prend généralement la forme de vérifier que des entrées incorrectes dans une méthode entraînent la génération d'une exception. Par exemple, une méthode "diviser par" peut lancer une ArgumentOutOfRangeException
si le dénominateur est 0:
public static int Divide (num numérateur, dénominateur int) if (dénominateur == 0) jette un nouvel argument ArgumentOutOfRangeException ("Le dénominateur ne peut pas être 0."); retourne numérateur / dénominateur; [Méthode de test] [ExpectedException (typeof (ArgumentOutOfRangeException))] public void BadParameterTest () Divide (5, 0);
Cependant, vérifier qu'une méthode implémente des tests contractuels est l'un des tests unitaires les plus faibles que l'on puisse écrire.
Un test unitaire plus fort implique de vérifier que le calcul est correct. Il est utile de classer vos méthodes dans l’une des trois formes de calcul suivantes:
Celles-ci déterminent les types de tests unitaires que vous pouvez écrire pour une méthode particulière..
le Diviser
méthode de l'échantillon précédent peut être considérée comme une forme de réduction des données. Il prend deux valeurs et retourne une valeur. Pour illustrer:
[TestMethod] public void VerifyDivisionTest () Assert.IsTrue (Divide (6, 2) == 3, "6/2 devrait être égal à 3!");
Ceci illustre le fait de tester une méthode qui réduit les entrées, généralement, à une sortie. C'est la forme la plus simple de tests unitaires utiles.
Les tests unitaires de transformation de données ont tendance à fonctionner sur des ensembles de valeurs. Par exemple, voici un test pour une méthode qui convertit les coordonnées cartésiennes en coordonnées polaires.
public statique double [] ConvertToPolarCoordinates (double x, double y) double dist = Math.Sqrt (x * x + y * y); double angle = Math.Atan2 (y, x); renvoie new double [] dist, angle; [TestMethod] public void ConvertToPolarCoordinatesTest () double [] pcoord = ConvertToPolarCoordinates (3, 4); Assert.IsTrue (pcoord [0] == 5, "Distance attendue égale à 5"); Assert.IsTrue (pcoord [1] == 0,92729521800161219, "Angle attendu de 53,130 degrés");
Ce test vérifie l'exactitude de la transformation mathématique.
Les transformations de liste doivent être séparées en deux tests:
Par exemple, du point de vue des tests unitaires, l'exemple suivant est mal écrit car il intègre à la fois la réduction et la transformation des données:
nom de la structure publique chaîne publique FirstName get; ensemble; chaîne publique LastName get; ensemble; liste publiqueConcatNames (Liste noms) liste concatenatedNames = nouvelle liste (); foreach (nom dans les noms) concatenatedNames.Add (name.LastName + "," + name.FirstName); return concatenatedNames; [TestMethod] public vide NameConcatenationTest () List noms = nouvelle liste () new Name () FirstName = "John", LastName = "Travolta", new Name () FirstName = "Allen", LastName = "Nancy"; liste newNames = ConcatNames (noms); Assert.IsTrue (newNames [0] == "Travolta, John"); Assert.IsTrue (newNames [1] == "Nancy, Allen");
Ce code est mieux testé par unité en séparant la réduction de données de la transformation de données:
chaîne publique Concat (Nom du nom) nom de retour. NomLast + "," + nom.Premier Nom; [TestMethod] public void ContactNameTest () Nom prénom = nouveau nom () Prénom = = "John", Nom = = "Travolta"; string concatenatedName = concat (nom); Assert.IsTrue (concatenatedName == "Travolta, John");
La syntaxe LINQ (Language-Integrated Query) est étroitement associée aux expressions lambda, ce qui donne une syntaxe facile à lire qui rend la vie difficile pour les tests unitaires. Par exemple, ce code:
liste publiqueConcatNamesWithLinq (Liste noms) retourne les noms.Select (t => t.LastName + "," + t.Prenom) .ToList ();
est nettement plus élégant que les exemples précédents, mais ne se prête pas bien au test unitaire de l’unité, c’est-à-dire à la réduction des données d’une structure de nom à une seule chaîne délimitée par des virgules, exprimée dans la fonction lambda t => t.LastName + "," + t.Prenom
. Pour séparer l'unité de la liste, l'opération nécessite:
liste publiqueConcatNamesWithLinq (Liste noms) retourne les noms.Sélectionner (t => Concat (t)). ToList ();
Nous pouvons voir que les tests unitaires peuvent souvent nécessiter une refactorisation du code pour séparer les unités des autres transformations..
La plupart des langues sont "avec état" et les classes gèrent souvent l'état. L'état d'une classe, représenté par ses propriétés, est souvent une chose utile à tester. Considérons cette classe représentant le concept de connexion:
Classe publique BeenConnectedToServiceException: ApplicationException publique. DéjàConnectedToServiceException (chaîne msg): base (msg) . Classe publique ServiceConnection public bool Connected get; ensemble protégé; public void Connect () if (Connecté) jette une nouvelle exception BeenConnectedToServiceException ("Une seule connexion à la fois est autorisée."); // Connectez-vous au service. Connecté = vrai; public void Disconnect () // Se déconnecter du service. Connecté = faux;
Nous pouvons écrire des tests unitaires pour vérifier les différents états autorisés et non autorisés de l'objet:
[TestClass] classe publique ServiceConnectionFixture [TestMethod] vide publique TestInitialState () ServiceConnection conn = new ServiceConnection (); Assert.IsFalse (conn.Connected); [TestMethod] public void TestConnectedState () ServiceConnection conn = new ServiceConnection (); conn.Connect (); Assert.IsTrue (conn.Connected); [TestMethod] public void TestDisconnectedState () ServiceConnection conn = new ServiceConnection (); conn.Connect (); conn.Disconnect (); Assert.IsFalse (conn.Connected); [TestMethod] [ExpectedException (typeof (DéjàConnectedToServiceException))] public void TestAlreadyConnectedException () ServiceConnection conn = new ServiceConnection (); conn.Connect (); conn.Connect ();
Ici, chaque test vérifie l'exactitude de l'état de l'objet:
La vérification des états révèle souvent des erreurs dans la gestion des états. Reportez-vous également aux «Classes de Mocking» suivantes pour d’autres améliorations par rapport au code précédent..
La gestion des erreurs externes et la récupération sont souvent plus importantes que de vérifier si votre propre code génère des exceptions au bon moment. Il y a plusieurs raisons à cela:
Ces types d'exceptions sont difficiles à tester car ils nécessitent la création d'au moins une erreur générée généralement par le service que vous ne contrôlez pas. Une façon de faire est de «se moquer» du service; toutefois, cela n'est possible que si l'objet externe est implémenté avec une interface, une classe abstraite ou des méthodes virtuelles.
Par exemple, le code antérieur de la classe “ServiceConnection” n'est pas modifiable. Si vous souhaitez tester sa gestion d'état, vous devez créer physiquement une connexion au service (quel qu'il soit) pouvant ou non être disponible lors de l'exécution des tests unitaires. Une meilleure implémentation pourrait ressembler à ceci:
Classe publique MockableServiceConnection public bool Connected get; ensemble protégé; void virtuel protégé ConnectToService () // Connectez-vous au service. void virtuel protégé DisconnectFromService () // Se déconnecter du service. public void Connect () if (Connecté) jette une nouvelle exception BeenConnectedToServiceException ("Une seule connexion à la fois est autorisée."); ConnectToService (); Connecté = vrai; public void Disconnect () DisconnectFromService (); Connecté = faux;
Remarquez comment cette refactorisation mineure vous permet maintenant d’écrire une classe fictive:
Classe publique ServiceConnectionMock: MockableServiceConnection protected override void ConnectToService () // Ne rien faire. protégé remplacer void DisconnectFromService () // Ne rien faire.
qui vous permet d'écrire un test unitaire qui teste la gestion des états indépendamment de la disponibilité du service. Comme cela est illustré, même de simples modifications d’architecture ou d’implémentation peuvent considérablement améliorer la testabilité d’une classe..
Votre première ligne de défense en prouvant que le problème a été corrigé est, paradoxalement, de prouver que le problème existe. Nous avons déjà vu un exemple d’écriture d’un test qui a prouvé que la méthode Divide vérifie la valeur du dénominateur de 0
. Supposons qu'un rapport de bogue est classé car un utilisateur a bloqué le programme en entrant 0
pour le dénominateur.
La première chose à faire est de créer un test qui exerce cette condition:
[Méthode de test] [ExpectedException (typeof (DivideByZeroException))]] public void BadParameterTest () Divide (5, 0);
Ce test passe parce que nous prouvons que le bogue existe en vérifiant que lorsque le dénominateur est 0
, une DivideByZeroException
est élevé. Ces types de tests sont considérés comme des «tests négatifs», car ils passer quand une erreur se produit. Le test négatif est aussi important que le test positif (discuté ci-après) car il vérifie l'existence d'un problème avant qu'il ne soit corrigé..
De toute évidence, nous voulons prouver qu'un bogue a été corrigé. C'est un test «positif».
Nous pouvons maintenant introduire un nouveau test, qui vérifiera que le code lui-même détecte l’erreur en lançant un ArgumentOutOfRangeException
.
[TestMethod] [ExpectedException (typeof (ArgumentOutOfRangeException))] public void BadParameterTest () Divide (5, 0);
Si nous pouvons écrire ce test avant en réglant le problème, nous verrons que le test échoue. Enfin, après avoir résolu le problème, notre test positif réussit et le test négatif échoue à présent..
Bien que ce soit un exemple trivial, il illustre deux concepts:
Enfin, prouver qu’un bogue existe n’est pas toujours facile. Cependant, en règle générale, les tests unitaires qui nécessitent trop de configuration et de moquages sont un indicateur du fait que le code testé n'est pas assez isolé des dépendances externes et pourrait être candidat au refactoring..
Il devrait être évident que le test de régression est un résultat utile du point de vue du test unitaire. Au fur et à mesure que le code subit des modifications, des bugs seront introduits qui seront révélés si vous avez une bonne couverture de code dans vos tests unitaires. Cela permet effectivement de gagner beaucoup de temps en débogage et, surtout, en gain de temps et d’argent lorsque le programmeur découvre le bogue plutôt que l’utilisateur..
Le développement d'applications commence généralement par un ensemble d'exigences de haut niveau, généralement axées sur l'interface utilisateur, le flux de travail et les calculs. Dans l’idéal, l’équipe réduit les visible ensemble d’exigences jusqu’à un ensemble d’exigences programmatiques, qui sont invisible à l'utilisateur, de par sa nature même.
La différence se manifeste dans la façon dont le programme est testé. Les tests d’intégration sont généralement au visible niveau, tandis que les tests unitaires sont au plus fin grain de invisible, test d'exactitude programmatique. Il est important de garder à l'esprit que les tests unitaires ne sont pas destinés à remplacer les tests d'intégration; Cependant, comme pour les exigences d'applications de haut niveau, il est possible de définir des exigences programmatiques de bas niveau. En raison de ces exigences programmatiques, il est important d’écrire des tests unitaires.
Prenons une méthode Round. La méthode .NET Math.Round arrondira un nombre dont le composant fractionnaire est supérieur à 0,5, mais arrondira à un nombre inférieur lorsque le composant fractionnaire est inférieur ou égal à 0,5. Disons que ce n'est pas le comportement que nous souhaitons (pour une raison quelconque), et nous voulons arrondir lorsque la composante fractionnaire est de 0,5 ou plus. Il s'agit d'une exigence de calcul qui devrait pouvoir être dérivée d'une exigence d'intégration de niveau supérieur, résultant de la méthode et du test suivants:
public static int RoundUpHalf (double n) if (n < 0) throw new ArgumentOutOfRangeException("Value must be >= 0. "); int ret = (int) n; double fraction = n - ret; if (fraction> = 0.5) ++ ret; return ret; [Méthode Test] public void RoundUpTest () int result1 = RoundUpHalf (1.5); int result2 = RoundUpHalf (1.499999); Assert.IsTrue (result1 == 2, "Attendu 2."); Assert.IsTrue (result2 == 1, "Attendu 1.");
Un test séparé pour l'exception devrait également être écrit.
Prendre des exigences au niveau de l'application qui sont vérifiées avec les tests d'intégration et les réduire à des exigences de calcul de niveau inférieur est un élément important de la stratégie de test d'unité globale, car elle définit des exigences de calcul claires que l'application doit respecter. Si vous rencontrez des difficultés avec ce processus, essayez de convertir les exigences de l'application en l'une des trois catégories informatiques: réduction des données, transformation des données et changement d'état..