Mis à jour le 10/03/2017
  • 20 heures
  • Moyenne

Ce cours est visible gratuitement en ligne.

Vous pouvez obtenir un certificat de réussite à l'issue de ce cours.

J'ai tout compris !

Les tests unitaires

Connectez-vous ou inscrivez-vous gratuitement pour bénéficier de toutes les fonctionnalités de ce cours !

Une des grandes préoccupations des créateurs de logiciels est d’être certains que leur application informatique fonctionne et surtout qu’elle fonctionne dans toutes les situations possibles. Nous avons tous déjà vu notre système d’exploitation planter, ou bien notre logiciel de traitement de texte nous faire perdre les 50 pages de rapport que nous étions en train de taper. Ou encore, un élément inattendu dans un jeu où l'on arrive à passer à travers un mur alors qu’on ne devrait pas…

Bref, pour être sûr que son application fonctionne, il faut faire des tests.

Qu’est-ce qu’un test unitaire et pourquoi en faire ?

Un test constitue une façon de vérifier qu’un système informatique fonctionne.

Tester son application c’est bien. Il faut absolument le faire. C’est en général une pratique plutôt laissée de côté et rébarbative. Il y a plusieurs façons de faire des tests. Celle qui semble la plus naturelle est celle qui se fait manuellement. On lance son application, on clique partout, on regarde si elle fonctionne.
Celle que je vais présenter ici constitue une pratique automatisée visant à s’assurer que des bouts de code fonctionnent comme il faut et que tous les scénarios d’un développement sont couverts par un test. Lorsque les tests couvrent tous les scénarios d’un code, nous pouvons assurer que notre code fonctionne. De plus, cela permet de faire des opérations de maintenance sur le code tout en étant certain que ce code n’aura pas subi de régressions.
De la même façon, les tests sont un filet de sécurité lorsqu’on souhaite refactoriser son code ou l’optimiser.
Cela permet dans certains cas d’avoir un guide pendant le développement, notamment lorsqu’on pratique le TDD. Le Test Driven Development (TDD) (ou en Français développement piloté par les tests) est une méthode de développement de logiciel qui préconise d'écrire les tests unitaires avant d'écrire le code source d'un logiciel. Nous y reviendrons ultérieurement.

En général, un test se décompose en trois partie, suivant le schéma « AAA », qui correspond aux mots anglais « Arrange, Act, Assert », que l’on peut traduire en français par Arranger, Agir, Auditer.

  • Arranger : Il s’agit dans un premier temps de définir les objets, les variables nécessaires au bon fonctionnement de son test (initialiser les variables, initialiser les objets à passer en paramètres de la méthode à tester, etc.).

  • Agir : Ensuite, il s’agit d’exécuter l’action que l’on souhaite tester (en général, exécuter la méthode que l’on veut tester, etc.)

  • Auditer : Et enfin de vérifier que le résultat obtenu est conforme à nos attentes.

Notre premier test

Imaginons que nous voulions tester une méthode toute simple qui fait l’addition entre deux nombres, par exemple la méthode suivante :

public static int Addition(int a, int b)
{
    return a + b;
}

Faire un test consiste à écrire des bouts de code permettant de s’assurer que le code fonctionne. Cela peut-être par exemple :

static void Main(string[] args)
{
    // arranger
    int a = 1;
    int b = 2;
    // agir
    int resultat = Addition(a, b);
    // auditer
    if (resultat != 3)
        Console.WriteLine("Le test a raté");
}

Ici, le test passe bien, ouf !
Pour être complet, le test doit couvrir un maximum de situations, il faut donc tester notre code avec d’autres valeurs, et ne pas oublier les valeurs limites :

static void Main(string[] args)
{
    int a = 1;
    int b = 2;
    int resultat = Addition(a, b);
    if (resultat != 3)
        Console.WriteLine("Le test a raté");
    a = 0;
    b = 0;
    resultat = Addition(a, b);
    if (resultat != 0)
        Console.WriteLine("Le test a raté");
    a = -5;
    b = 5;
    resultat = Addition(a, b);
    if (resultat != 0)
        Console.WriteLine("Le test a raté");
}

Voilà pour le principe. Ici, nous considérons avoir écrit suffisamment de tests pour nous assurer que cette méthode est bien fonctionnelle.
Bien sûr, cette méthode était par définition fonctionnelle, mais il est important de prendre le réflexe de tester des fonctionnalités qui sont déterminantes pour notre application.

Voyons maintenant comment nous pourrions tester une méthode avec l’approche TDD.
Pour rappel, lors d’une approche TDD, le but est de pouvoir faire un développement à partir des cas de tests préalablement établis par la personne qui exprime le besoin ou suivant les spécifications fonctionnelles.

Imaginons que nous voulions tester une méthode qui calcule la factorielle d’un nombre. Nous savons que la factorielle de 0 vaut 1, la factorielle de 1 vaut 1. Commençons par écrire les tests :

static void Main(string[] args)
{
    int valeur = 0;
    int resultat = Factorielle(valeur);
    if (resultat != 1)
        Console.WriteLine("Le test a raté");

    valeur = 1;
    resultat = Factorielle(valeur);
    if (resultat != 1)
        Console.WriteLine("Le test a raté");
}

Le code ne compile pas ! Forcément, nous n’avons pas encore créé la méthode Factorielle. C’est la première étape. La suite de la méthode est de faire en sorte que le test compile, mais il échouera puisque la méthode n’est pas encore implémentée :

public static int Factorielle(int a)
{
    throw new NotImplementedException();
}

Il faudra ensuite écrire le code minimal qui servira à faire passer nos deux tests. Cela peut-être :

public static int Factorielle(int a)
{
    return 1;
}

Si nous exécutons nos tests, nous voyons que cette méthode est fonctionnelle car ils passent tous. La suite de la méthode consiste à refactoriser le code, à l’optimiser. Ici, il n’y a rien à faire tellement c’est simple.
On se rend compte par contre qu'on n'a pas couvert énormément de cas de tests, juste des tests avec 0 et 1 c'est un peu léger... Nous savons que la factorielle de 2 vaut 2, la factorielle de 3 vaut 6, la factorielle de 4 vaut 24, ... Continuons à écrire des tests. (Il faut bien sûr garder les anciens tests afin d’être sûr qu’on couvre un maximum de cas) :

static void Main(string[] args)
{
    int valeur = 0;
    int resultat = Factorielle(valeur);
    if (resultat != 1)
        Console.WriteLine("Le test a raté");

    valeur = 1;
    resultat = Factorielle(valeur);
    if (resultat != 1)
        Console.WriteLine("Le test a raté");

    valeur = 2;
    resultat = Factorielle(valeur);
    if (resultat != 2)
        Console.WriteLine("Le test a raté");

    valeur = 3;
    resultat = Factorielle(valeur);
    if (resultat != 6)
        Console.WriteLine("Le test a raté");

    valeur = 4;
    resultat = Factorielle(valeur);
    if (resultat != 24)
        Console.WriteLine("Le test a raté");
}

Et nous pouvons écrire une méthode Factorielle qui fait passer ces tests :

public static int Factorielle(int a)
{
    if (a == 2)
        return 2;
    if (a == 3)
        return 6;
    if (a == 4)
        return 24;
    return 1;
}

Lançons les tests, nous voyons que tout est OK. Cependant, nous n’allons pas faire des if en déclinant tous les cas possible, il faut donc repasser par l’étape d’amélioration et de refactorisation du code, afin d’éviter les redondances de code, d’améliorer les algorithmes, etc. Cette opération devient sans risque puisque le test est là pour nous assurer que la modification que l’on vient de faire est sans régression, si le test passe toujours bien sûr…
Nous voyons que nous pouvons améliorer le code en utilisant la vraie formule de la factorielle :

public static int Factorielle(int a)
{
    int total = 1;
    for (int i = 1 ; i <= a ; i ++)
    {
        total *= i;
    }
    return total;
}

Ce qui permet d’illustrer que par exemple la factorielle de 5 est égale à 1*2*3*4*5.
Relançons nos tests, ils passent tous. Parfait. Nous sommes donc certains que notre changement de code n’a pas altéré la fonctionnalité car les tests continuent de passer.
On peut même rajouter des tests pour le plaisir, comme la factorielle de 10, histoire d’avoir quelque chose d’un peu plus grand :

valeur = 10;
resultat = Factorielle(valeur);
if (resultat != 3628800)
    Console.WriteLine("Le test a raté");

Est-ce que cette méthode est optimisable ? Sûrement.
Est-ce qu’il y a un risque à optimiser cette méthode ? Aucun ! En effet, nos tests nous garantissent que s’ils continuent à passer, alors une optimisation n’entraine pas de régression dans la fonctionnalité.

On sait par exemple qu'il y a un autre moyen pour calculer une factorielle. Par exemple, pour calculer la factorielle de 5, il suffit de multiplier 5 par la factorielle de 4. Pour calculer la factorielle de 4, il faut multiplier 4 par la factorielle de 3, et ainsi de suite jusqu'à arriver à 1... Bref, pour obtenir une factorielle on peut se servir du résultat de la factorielle du nombre précédent. Ce qui peut s’écrire :

public static int Factorielle(int a)
{
    if (a <= 1) 
        return 1;
    return a * Factorielle(a - 1); 
}

Ici la méthode Factorielle est une méthode récursive, c’est-à-dire qu’elle s’appelle elle-même. Cela nous permet de d’indiquer que la factorielle d’un nombre correspond à ce nombre multiplié par la factorielle du nombre précédent. Bien sûr, il faut s’arrêter à un moment dans la récursion. On s’arrête ici quand on atteint le chiffre 1.
Pour s’assurer que cette factorielle fonctionne bien, il suffit de relancer les tests. Tout est OK, c’est parfait ! :)

Voilà donc un exemple de TDD. Bien sûr, la méthode est ici poussée au maximum pour que vous compreniez l’intérêt de cette pratique. On peut gagner du temps en partant directement sur la bonne implémentation. Mais vous verrez qu’il y a toujours des premiers essais qui satisfont les tests mais qu’il sera possible d’améliorer régulièrement notre code. Ceci devient possible grâce aux tests qui nous assurent que tout continue à bien fonctionner.
La pratique du TDD dépend de la façon dont le développeur appréhende sa philosophie de développement. Elle est présentée ici pour sensibiliser ce dernier à cette pratique mais son utilisation n’est pas du tout obligatoire.
Voilà pour les tests basiques. Cependant, utiliser une application console pour faire ses tests, ce n’est pas très pratique, vous en conviendrez. Nous avons besoin d’outils !

Le framework de test

Un framework de test est aux tests ce que l’IDE est au développement. Il fournit un environnement structuré permettant l’exécution de test et des méthodes pour aider au développement de ceux-ci.

Il existe plusieurs frameworks de test. Microsoft dispose de son framework, MSTest. D'autres framework de tests existent, comme le très connu NUnit. NUnit est la version .NET du framework XUnit, qui se décline pour plusieurs environnements, avec par exemple PHPUnit pour le langage PHP, JUnit, pour java, etc.

La première chose à faire pour réaliser des tests est de reprendre notre solution et d'y ajouter un nouveau projet de tests. Faites un clic-droit sur la solution et ajoutez un nouveau projet ; ça, vous savez faire :

Ajouter un nouveau projet
Ajouter un nouveau projet

Puis choisissez un nouveau modèle de projet de test unitaire :

Création d'un projet de tests unitaires
Création d'un projet de tests unitaires

Nous pouvons voir que Visual Studio nous génère un projet, contenant une classe UnitTest1  avec le code suivant :

[TestClass]
public class UnitTest1
{
    [TestMethod]
    public void TestMethod1()
    {
    }
}

Une classe, une méthode et des attributs... Que des choses connues :p. Je vais revenir sur ces attributs dans un instant.

Maintenant, il nous faut une fonctionnalité à tester. Créons une classe utilitaire, disons Math, qui contiendra notre fameuse méthode de calcul de factorielle :

public static class Math
{
    public static int Factorielle(int a)
    {
        if (a <= 1)
            return 1;
        return a * Factorielle(a - 1);
    }
}

Vous avez deviné grâce au code généré par Visual Studio que les tests doivent se mettre dans une classe spéciale. Ici aussi, pas de règle de nommage obligatoire, mais il est intéressant d’avoir une norme pour facilement s’y retrouver. Je vous propose de nommer les classes de tests en commençant par le nom de la classe que l’on doit tester, suivie du mot Tests. Ce qui donne : MathTests. Donc, le plus simple ici est de renommer notre fichier UnitTest1.cs en MathTests, directement depuis Visual Studio :

Renommer une classe depuis Visual Studio
Renommer une classe depuis Visual Studio

En faisant ça, Visual Studio nous propose également de renommer la classe pour qu'elle ait le même nom que le fichier. Acceptez le renommage.

Pour être reconnue par le framework de test, la classe doit respecter un certain nombre de contraintes. Elle doit dans un premier temps être décorée de l’attribut [TestClass]. Il s’agit d’un attribut qui permet à MSTest de reconnaître les classes qui contiennent des tests. C'est notamment cet attribut qu'a généré Visual Studio et qui décore la déclaration de la classe.

Cet attribut est dans une assembly de MSTest, qui a automatiquement été référencée lors de la création du projet. Il s'agit de l'assembly Microsoft.VisualStudio.QualityTools.UnitTestFramework .

Nous allons pouvoir créer des méthodes de test à l’intérieur de cette classe. De la même façon, une méthode pourra être reconnue comme une méthode de test si elle est décorée de l’attribut [TestMethod].

Ici aussi, il est intéressant de suivre une règle de nommage afin de pouvoir identifier rapidement l’intention de la méthode de test. Je vous propose le nommage suivant :
MethodeTestee_EtatInitial_EtatAttendu()

Par exemple, une méthode de test permettant de tester la factorielle pourrait s’appeler :

[TestClass]
public class MathTests
{
    [TestMethod]
    public void Factorielle_AvecValeur3_Retourne6()
    {
        // test à faire
    }
}

Il existe plein d’autres attributs que vous découvrirez ultérieurement. Il est temps de passer à l’écriture du test et surtout à la vérification du résultat.
Pour cela, on utilise des méthodes de MSTest qui nous permettent de vérifier par exemple qu’une valeur est égale à une autre attendue. Cela se fait grâce à la méthode Assert.AreEqual():

[TestMethod]
public void Factorielle_AvecValeur3_Retourne6()
{
    int valeur = 3;
    int resultat = Math.Factorielle(valeur);
    Assert.AreEqual(6, resultat);
}

Elle permet de vérifier que la variable valeur vaut bien 6.

Rajoutons tant qu’on y est une méthode de test qui échoue :

[TestMethod]
public void Factorielle_AvecValeur10_Retourne1()
{
    int valeur = 10;
    int resultat = Math.Factorielle(valeur);
    Assert.AreEqual(1, resultat, "La valeur doit être égale à 1");
}

J’en ai profité pour rajouter un message qui permettra d’indiquer des informations complémentaires si le test échoue.

Rendez-vous maintenant dans le menu TEST, Exécuter, Tous les tests :

Exécuter tous les tests
Exécuter tous les tests

La fenêtre explorateur de tests  s'ouvre et nous pouvons voir le résultat de nos tests :

Affichage du résultat des tests
Affichage du résultat des tests

Ce qui nous permet de voir rapidement qu’il y a un test qui passe (icône verte) et un test qui échoue (icône rouge). Forcément, notre test n’était pas bon, il faut le réécrire.

Si nous cliquons sur le test échoué, nous voyons également qu’il nous indique que le résultat attendu était 1 alors que le résultat obtenu est de 3628800. Nous pouvons également voir le message que nous avons demandé d’afficher en cas d’erreur :

Affichage du détail de l'exécution d'un test
Affichage du détail de l'exécution d'un test

Pour en finir avec MSTest, notons qu’il y a beaucoup de méthodes permettant de vérifier si un résultat est correct. Regardons les assertions suivantes :

bool b = true;
Assert.IsTrue(b);
string s = null;
Assert.IsNull(s);

Elles parlent d’elles-mêmes. La première permet de vérifier qu’une condition est vraie. La deuxième permet de vérifier la nullité d’une variable. À noter qu’elles ont chacune leur pendant (IsFalse, IsNotNull). En regardant la complétion automatique, vous découvrirez d’autres méthodes de vérification, mais celles-ci sont globalement suffisantes.

Il est également possible d’utiliser un attribut pour vérifier qu’une méthode lève bien une exception, par exemple :

[TestMethod]
[ExpectedException(typeof(FormatException))]
public void ToInt32_AvecChaineNonNumerique_LeveUneException()
{
    Convert.ToInt32("abc");
}

Dans ce cas, le test passe si la méthode lève bien une FormatException.

Avant de terminer, présentons deux attributs supplémentaires : les attributs TestInitialize  et TestCleanup .
Ils permettent de décorer des méthodes qui seront appelées respectivement avant chaque test et après chaque test. C'est l'endroit idéal pour factoriser des initialisations ou des nettoyages dont dépendent tous les tests.

[TestClass]
public class MathTests
{
    [TestInitialize]
    public void InitialisationDesTests()
    {
        // rajouter les initialisations
    }

    [TestMethod]
    public void Factorielle_AvecValeur3_Retourne6()
    {
        // test à faire
    }

    [TestCleanup]
    public void NettoyageDesTests()
    {
        // nettoyer les variables, ...
    }
}

Il existe plein d’autres choses utiles à dire sur MSTest, comme la description des autres attributs, ce que je ne vais pas faire ici. N’hésitez pas à aller voir sur internet des informations plus poussées pour approfondir votre maitrise des tests.

Le framework de simulacre

Un framework de simulacre fournit un moyen de tester une méthode en l’isolant du reste du système. Imaginons par exemple une méthode qui permette de récupérer la météo du jour, en allant la lire dans une base de données.

Nous avons ici un problème car lorsque nous exécutons le test le lundi, il pleut. Quand nous exécutons le test le mardi, il fait beau, etc.
Nous avons une information qui varie au cours du temps. Il est donc difficile de tester automatiquement que la méthode arrive bien à construire la météo du jour à partir de ces informations, vu qu’elles varient.

Le but de ces frameworks de simulacre est de pouvoir bouchonner le code dont notre développement dépend afin de pouvoir le tester unitairement, sans dépendance et isolé du reste du système.
Cela veut dire que dans notre test, nous allons remplacer la lecture en base de données par une fausse méthode qui renvoie toujours qu’il fait beau. Cependant, ceci doit se faire sans modifier notre application, sinon cela n’a pas d’intérêt. Voilà à quoi servent ces framework de simulacres.
Il en existe plusieurs, plus ou moins complexe. Citons par exemple Moq (prononcez « moque-you ») ou encore MS Fakes (il y en a plein d’autres).
L’intérêt de Moq est qu’il est simple d’accès et qu'il est gratuit ;), nous allons le présenter rapidement. Il permet de faire des choses simples et facilement. 

Pour installer Moq, rien de plus simple : NuGet :

Installation et référence de Moq
Installation et référence de Moq

Ensuite, pour pouvoir bouchonner facilement les méthodes d'une classe, elle doit implémenter une interface. Imaginons la classe d’accès aux données suivante :

public class Dal : IDal
{
    public Meteo ObtenirLaMeteoDuJour()
    {
        // ici, c'est le code pour lire en base de données
        // mais finalement, peu importe ce qu'on y met vu qu'on va bouchonner la méthode
        throw new NotImplementedException();
    }
}

Qui implémente l’interface suivante :

public interface IDal
{
    Meteo ObtenirLaMeteoDuJour();
}

Avec l’objet Meteo suivant :

public class Meteo
{
    public double Temperature { get; set; }
    public Temps Temps { get; set; }
}

Et l’énumération Temps suivante :

public enum Temps
{
    Soleil,
    Pluie
}

Nous pourrons écrire un test qui bouchonne l’appel à la méthode ObtenirLaMeteoDuJour, qui doit normalement aller lire en base de données, pour renvoyer un faux objet à la place. Pour bien montrer ce fonctionnement, j’ai fait en sorte que la méthode lève une exception, comme ça, si on passe dedans ça sera tout de suite visible.
La méthode de test classique devrait être :

[TestMethod]
public void ObtenirLaMeteoDuJour_AvecUnBouchon_RetourneSoleil()
{
    IDal dal = new Dal();
    Meteo meteoDuJour = dal.ObtenirLaMeteoDuJour();
    Assert.AreEqual(25, meteoDuJour.Temperature);
    Assert.AreEqual(Temps.Soleil, meteoDuJour.Temps);
}

Si nous exécutons le test, nous aurons une exception.
Utilisons maintenant Moq pour bouchonner cet appel et le remplacer par ce que l’on veut :

[TestMethod]
public void ObtenirLaMeteoDuJour_AvecUnBouchon_RetourneSoleil()
{
    Meteo fausseMeteo = new Meteo { Temperature = 25, Temps = Temps.Soleil };
    IDal fausseDal = Mock.Of<IDal>();
    Mock.Get(fausseDal).Setup(dal => dal.ObtenirLaMeteoDuJour()).Returns(fausseMeteo);

    Meteo meteoDuJour = fausseDal.ObtenirLaMeteoDuJour();
    Assert.AreEqual(25, meteoDuJour.Temperature);
    Assert.AreEqual(Temps.Soleil, meteoDuJour.Temps);
}

On utilise la méthode générique Mock.Of<>() pour créer une fausse implémentation de notre interface. Ensuite, on récupère le bouchon via la méthode Mock.Get pour ensuite paramétrer le comportement de la méthode et lui faire renvoyer ce que l'on veut. Pour cela, on utilise la méthode Setup à travers une expression lambda pour indiquer que la méthode ObtenirLaMeteoDuJour retournera en fait un faux objet météo. Cela se fait tout naturellement en utilisant la méthode Returns(). L’avantage de ces constructions est que la syntaxe claire parle d’elle-même à partir du moment où on connait les expressions lambdas...

Il ne reste plus qu'à appeler notre fausse méthode et récupérer le faux objet qui fait office de bouchon.

Bien sûr, ici, ce test n’a pas grand intérêt. Mais il faut le voir à un niveau plus général. Imaginons que nous ayons besoin de tester la fonctionnalité qui met en forme cet objet météo récupéré de la base de données ou bien l’algorithme qui nous permet de faire des statistiques sur ces données météos… Là, nous sommes sûrs de pouvoir nous baser sur une valeur connue de la dépendance à la base de données. Cela permettra également de décliner tous les cas possibles en changeant la valeur du bouchon et de faire les tests les plus exhaustifs possibles.
Nous pouvons faire la même chose avec les propriétés. Imaginons la classe suivante dont la propriété valeur retourne un nombre aléatoire :

public interface IGenerateur
{
    int Valeur { get; }
}

public class Generateur : IGenerateur
{
    private Random r;
    public Generateur()
    {
        r = new Random();
    }

    public int Valeur
    {
        get
        {
            return r.Next(0, 100);
        }
    }
}

Nous pourrions avoir besoin de bouchonner cette propriété pour qu’elle renvoie un nombre connu à l’avance. Cela se fera de la même façon :

IGenerateur generateur = Mock.Of<IGenerateur>();
Mock.Get(generateur).SetupGet(x => x.Valeur).Returns(5);

Assert.AreEqual(5, generateur.Valeur);

Ici, la propriété Valeur renverra toujours 5 en se moquant (...) bien du générateur de nombre aléatoire.

Je m’arrête là pour l’aperçu de ce framework de simulacre. Nous avons pu voir qu’il pouvait facilement bouchonner des dépendances nous permettant de faciliter la mise en place de nos tests unitaires. Rappelez-vous, pour qu’un test soit efficace, il doit pouvoir se concentrer sur un point précis du code sans être embêté par les dépendances éventuelles qui peuvent perturber l’état du test à un instant t.

En résumé

  • Les tests unitaires sont un moyen efficace de tester des bouts de code dans une application afin de garantir son bon fonctionnement.

  • Ils sont un filet de sécurité permettant de faire des opérations de maintenance, de refactoring ou d'optimisation sur le code.

  • Les frameworks de tests unitaires sont en général accompagnés d'outils permettant de superviser le bon déroulement des tests et la couverture de tests.

Conclusion

Vous voilà arrivés à la fin de ce cours. Grâce à lui, vous êtes désormais à l'aise avec la programmation orientée objet en C#. Il est temps pour vous de découvrir des nouvelles choses, car les applications console c'est bien, mais c'est un peu limité :D.

En suivant d'autres cours, vous pourrez découvrir comment créer des applications client lourd pour Windows avec WPF, des sites web avec ASP.NET, des applications pour Windows Phone ou tablettes, des applications hébergées dans le cloud Azure, des web services, des web api, des jeux, et j'en passe...

Si vous voulez découvrir ce que l'on peut faire avec le C#, suivez ce lien pour avoir un aperçu des différents types d'applications que l'on peut réaliser avec le C#.

En un peu plus détaillé, vous pouvez également apprendre à réaliser des applications pour Windows Phone avec le C# ou encore réaliser des sites web avec ASP.NET MVC.

Exemple de certificat de réussite
Exemple de certificat de réussite