Interfaces et jeux de tests en C#

Nous abordons dans cet article deux éléments du C# qui permettent de rendre un programme plus formel et de s’assurer de son bon fonctionnement : les interfaces et les jeux de tests.

Les interfaces décrivent ce que doit contenir une classe, alors que les jeux de tests sont plutôt une vérification de son bon fonctionnement. Les interfaces permettent de définir les bases d’un programme, les plans de fonctionnement, alors que les tests vont permettre de s’assurer de ce bon fonctionnement.

Créer une interface en C# : un élément d’architecture du programme

En créant une interface, nous allons indiquer qu’une classe ou une structure doit fournir certaines choses. La documentation C# officielle parle de « contrat », et c’est bien ce qu’est une interface : on oblige une classe ou une structure de contenir certaines choses, si elle hérite de notre interface.

Nous créons donc un « patron » de conception d’une classe, sans définir la classe en lui-même. On saura ainsi de quelle manière on peut utiliser les classes qui sont faites avec ce patron. Typiquement, dans une équipe, vous pouvez avoir un architecte qui fait le design du logiciel, et d’autres personnes qui s’occupent de bâtir les fonctions.

Pour l’exemple, imaginons une implémentation de véhicule. Toute classe de notre programme qui implémente cette classe doit avoir une méthode pour entrer et sortir du véhicule, une méthode pour modifier la vitesse (accélération ou réduction), et des informations comme le nombre de personnes .

interface IVehicle
{
    public int CharactersInside { get; }
    public int Capacity { get; }
    public void EnterVehicle(object character, bool forceExit);
    public void ExitVehicle(object character);
    public void ChangeSpeed(float speed);
}

Notez que nous l’avons appelé IVehicle. Le I majuscule au début du nom est une convention de nommage pour les interfaces, et les interfaces bénéficient d’une coloration syntaxique différente.

Ensuite, pour qu’une classe implémente cette interface, nous utilisons le symbole : au début du définition de la classe (comme pour un héritage).

class Car : IVehicle
{
   // Car implémente l'interface IVehicle
}

Un gros avantage d’un IDE comme Visual Studio 2019, c’est qu’il permet de gérer facilement l’implémentation d’interface. En faisant une classe vide et en lui demandant d’implémenter IVehicle, on a l’erreur suivante, et Visual Studio propose de faire une implémentation par défaut (en écrivant les méthodes et propriétés, avec des exceptions « Non implémentées).

Visual Studio propose d’implémenter lui-même les membres d’interface

On peut alors remplacer les exceptions System.NotImplementedException(); par nos propres implémentations, et garder par exemple celles qui ne sont pas encore prêtes :

class Car : IVehicle
{
    private List<object> charactersInCar = new List<object>();
    public int CharactersInside => charactersInCar.Count();
    public int Capacity => capacity;
    private int capacity = 4;

    public void ChangeSpeed(float speed)
    {
        throw new System.NotImplementedException();
    }
    public void EnterVehicle(object character, bool forceExit)
    {
        if(CharactersInside <= capacity || forceExit)
        {
            charactersInCar.RemoveAt(0);
            charactersInCar.Add(character);
        }
    }
    public void ExitVehicle(object character)
    {
        charactersInCar.Remove(character);
    }
}

Une classe peut implémenter plusieurs interfaces

Une dernière chose concernant les interfaces : une classe peut hériter d’une autre classe, et en même temps implémenter une ou plusieurs interfaces. Dans ce cas, dans la déclaration de la classe, on indique d’abord la classe parente, puis les interfaces.

Par exemple, pour un personnage non joueur, notre classe Shopkeeper qui hérite de la classe Character et qui implémente les interface IInteractable (pour pouvoir discuter avec lui) et IFightable (pour que le système de combat fonctionne avec le personnage), la fonction code sera comme ainsi :

class Shopkeeper : Character, IInteractable, IFightable
{
   // Le Shopkeeper est un personnage (hérite de Character) et implémente deux interfaces.
}

L’interface permet de définir comment on peut communiquer avec une classe (« une porte a une poignée »), (« si j’envoie ces deux entiers à une méthode nommée foo, elle me renvoie un booléen »), mais pas de tester si les fonctions envoient un résultat correct ou se comporte comme voulu. Pour ça, il y a les tests, et c’est ce que nous allons voir.

Jeux de tests en C# dans Visual Studio : le développement par le test

Comme leur nom l’indique, les jeux de tests vont comporter un certain nombre de tests sur des méthodes d’une classe choisie.

Dans Visual Studio 2019, allez au niveau de votre solution, choisissez « Ajouter > Nouveau Projet », puis cherchez « Test » dans la liste des projets disponibles. Nous allons pour cet exemple utiliser MSTest pour .NET Core.

Ajouter un projet de test

Après avoir nommé le projet (typiquement, un nom contenant le projet de base et avec le mot clef UnitTests ou Tests), Visual Studio a créé les fichiers pour les tests, avec un script UnitTest1.cs contenant les scripts de base ci-dessous et les fichiers de dépendances.

On voit qu’il importe par défaut un namespace UnitTesting servant aux tests unitaires.

using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GameCodeClub.People.Tests
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

Il nous faut faire le lien entre le jeu de tests et la classe à tester. On fait un clic droit sur la section « Dépendances » du projet de test, on choisit « Ajouter une référence de projet… » et on coche ce qu’on veut tester.

Cela va automatiquement ajouter dans notre fichier de tests l’import l’espace de nom comprenant la classe que nous voulons tester, par exemple :

using GameCodeClub.People;

Le fichier de test doit être écrit ainsi :

  1. La classe de test indiquant ce qui est testé, précédée de [TestClass]
  2. Les méthodes de tests, avec un nom indiquant ce qui est testé et le résultat attendu (et éventuellement le nom de la méthode), chaque méthode doit être précédée de [TestMethod]
  3. Une méthode de Assert dans la classe de test, indiquant le résultat attendu (et éventuellement le message d’erreur à retourner).

Exemple de test avec Assert.IsTrue / Assert.IsFalse

Par exemple, Assert permet de tester si une valeur cible de type booléen doit être juste avec Assert.IsTrue ou fausse avec Assert.IsFalse. Ci-dessous, nous créons des instances de personnes de la classe Person et nous testons la propriété IsAdult de cette classe:

[TestMethod]
public void IsAdult_AgeLessThan18_ReturnFalse()
{
    Person Person8YearsOld = new Person("Anna Test", 8);
    Assert.IsFalse(Person8YearsOld.IsAdult, "Une personne de 8 ans n'est pas adulte.");
}
public void IsAdult_AgeOverThan18_ReturnTrue()
{
    Person Person36YearsOld = new Person("Benjamin Test", 36);
    Assert.IsTrue(Person36YearsOld.IsAdult, "Une personne de 36 ans est adulte");
}
public void IsAdult_AgeEqualsTo18_ReturnTrue()
{
    Person Person18YearsOld = new Person("Charlie Test", 18);
    Assert.IsTrue(Person18YearsOld.IsAdult, "Une personne de 18 ans est adulte");
}

Ainsi, si la propriété est correctement implémentée, elle devrait renvoyer False dans le 1er cas et True dans les deux suivants.

En faisant un clic droit sur notre projet de test et en choisissant « Exécuter les tests », Visual Studio ouvre l’Explorateur de Test et exécute tous les tests demandés. Cet explorateur indique quels tests ont réussi et quels test ont raté.

Après avoir lancé le test la première fois, Visual Studio indique directement dans l’interface où sont les erreurs.

Ci-dessous, j’ai volontairement mal codé la propriété IsAdult pour qu’elle ne fonctionne pas lorsque l’âge est exactement 18. En allant sur cette propriété après avoir exécuté les tests, j’ai le détail des tests réussis ou non.

Test des valeurs retournées par une méthode avec Assert.AreEqual()

Nous pouvons également tester des méthodes. Par exemple, si nous avons une méthode statique qui permet d’avoir les initiales à partir d’un nom (entrée et résultat tous deux de type string), nous allons pouvoir vouloir la tester sur certains cas extrêmes, voire sur une liste de cas, pour nous assurer qu’elle fonctionne toujours comme attendu.

Voici une méthode de test utilisant une liste de valeurs d’entrée dont les réponses attendues sont connues :

[TestMethod]
public void GetInitials_DataSet()
{
	Dictionary<string, string> correctValues = new();
	correctValues.Add("Alphonse Allais", "A. A.");
	correctValues.Add("Jean-Jacques Rousseau", "J. J. R.");
	correctValues.Add("Voltaire", "V.");
	correctValues.Add("Victor Hugo", "V. H.");
	correctValues.Add("George Sand", "G. S.");
	foreach (KeyValuePair<string, string> name in correctValues)
	{
		Assert.AreEqual(Person.GetInitials(name.Key), name.Value);
	}
}

Par exemple, si notre fonction est H.S. pour les noms composés, sur l’explorateur de tests, nous pourrons voir que « DataSet » retourne un échec. En cliquant pour voir le détail, nous pouvons voir sur quel élément du DataSet la fonction n’a pas marché.

Pour tester avec plus de précision, nous pouvons faire des méthodes de test précise pour certains comportements de la fonction. Ainsi nous savons qu’il y a des cas particuliers : prénom composé, présence d’une particule, ou nom unique. Nous faisons des méthodes explicites pour ces cas de figure :

[TestMethod]
public void GetInitials_NameWithHyphen_GetAllLetters()
{
	Assert.AreNotEqual(Person.GetInitials("Jean-Jacques Rousseau"), "J. R.", "L'initiale du deuxième prénom n'est pas prise en compte dans l'implémentation.");
	Assert.AreEqual(Person.GetInitials("Jean-Jacques Rousseau"), "J. J. R.");
}
[TestMethod]
public void GetInitials_OneName_GetOneLetters()
{
	Assert.AreEqual(Person.GetInitials("Voltaire"), "V.");
}
[TestMethod]
public void GetInitials_NameWithParticle_IgnoreParticle()
{
	Assert.AreEqual(Person.GetInitials("Honoré de Balzac"), "H. B.", "La particule doit être ignorée.");
	Assert.AreNotEqual(Person.GetInitials("Honoré de Balzac"), "H. d. B.", "La particule doit être ignorée.");
}

Grâce à ces fonctions au nom et au tests explicites, on sait exactement ce qui n’a pas fonctionné et pourquoi cela n’a pas fonctionné.

N.B. : les classes sont redondantes à titre d’illustration, mais un seul AreEqual() ou AreNotEqual() est en l’occurrence amplement suffisant pour relever l’erreur de test.

Ci-dessous, on voit que notre implémentation sommaire de la méthode GetInitials() ne fonctionne que dans un nombre limités de cas. On va devoir améliorer la méthode jusqu’à ce que tout soit correct par rapport aux cas de tests, aussi bien les méthodes de test que le DataSet.

Exemple de tests effectués sur une fonction GetInitials()

Le gros intérêt de ces méthodes de tests : ils permettent de vérifier le fonctionnement de classes et de méthodes hors de l’environnement.

Le Test Driven Development (Développement Piloté par les Tests) utilise les Tests comme point de départ. On écrit d’abord les tests, puis on créée le code de l’application, jusqu’à ce qu’il passe tous les tests.

Ce qu’il est possible de tester avec la classe Assert

Voici une petite référence de ce que contient Assert :

Méthode de testMéthode inverseEffet
AreEqual(a, b)AreNotEqual(a, b)Teste si les valeurs de a et b sont égales.
AreSame(a, b)AreNotSame(a, b)Teste si a et b sont le même objet (la même instance)
IsTrue(b)IsFalse(b)Teste si B (booléen) est vrai.
IsNull(o)IsNotNull(o)Vérifie si l’objet o est null.
IsInstanceOfType(o, T)IsNotInstanceOfType(o, T)Vérifie si l’objet o est une instance du type T.
Fail(msg)Indique une erreur pour la méthode testée (avec le message facultatif msg)
Inconclusive(msg)Indique que le test n’est ni validé, ni en erreur (avec un message faculatif msg);

Assert.Fail() et Assert.Inconclusive() permettent notamment de faire des tests sur sur les comportements inattendus des fonctions (en les mettant dans un try/catch, par exemple).

Assert.Inconclusive() est traité de manière spécifique dans l’explorateur de tests, avec une icône « Warning ». Vous pouvez l’utiliser pour des cas où la vérification du test nécessite une ressource qui n’est pas disponible au moment du test, par exemple (même si la bonne pratique, pour avoir des tests efficace, c’est qu’ils soient indépendant d’autres ressources).

Vous pouvez aussi l’utiliser comme contenu par défaut des tests que vous n’avez pas encore écrit, comme ça vous le distinguez des tests réussis ou ratés.

Conclusion : les interfaces et tests rendent votre programme robuste et modulable

Nous l’avons vu, les interfaces comme les tests sont des outils très utiles au développement, notamment en équipe.

L’interface est plutôt pour l’architecture d’une application, vous permettre de vous assurer qu’une classe que vous ajoutez ou modifiez a toujours ce qu’il faut pour être utilisée par l’application. Et de même, si une nouveauté est ajoutée à une interface, le formalisme du code vous forcera à retoucher les classes concernées pour que tout fonctionne avec cette interface.

Les tests unitaires sont des outils très puissants pour vérifier le bon fonctionnement de votre application, aussi bien en amont du développement que lors des refactorisations de code ou ajout de nouvelles fonctions. La présence d’un jeu de tests précis et exhaustifs vous assure à toute étape que le programme fonctionne exactement comme attendu, et que le programme ne subit pas de régressions sur son fonctionnement attendu.