Motifs de conception : Model View Presenter -- MSDN Magazine, août 2006

Paru le 25 juillet 2006

Lire cet article en anglais 

Cliquez ici pour télécharger le code de cet article : DesignPatterns2006_08.exe (4423 ko)

*
Sur cette page
IntroductionIntroduction
Suivre le motif MVPSuivre le motif MVP
Faire en sorte que le premier test réussisseFaire en sorte que le premier test réussisse
Remplissage de la liste DropDownListRemplissage de la liste DropDownList
Implémentation de l'interface d'affichageImplémentation de l'interface d'affichage
Et maintenant ?Et maintenant ?

Introduction

À mesure que les technologies de création d'interface utilisateur, telles que ASP.NET et Windows® Forms, deviennent de plus en plus puissantes, il est courant de laisser la couche d'interface utilisateur en faire plus qu'elle ne devrait. Sans une séparation claire des responsabilités, la couche d'interface utilisateur peut devenir un fourre-tout pour la logique qui appartient en fait à d'autres couches de l'application. Un motif de conception, à savoir MVP (Model View Presenter), est particulièrement adapté à la résolution de ce problème. Pour illustrer mon propos, je vais créer un écran d'affichage selon le motif MVP pour les clients de la base de données Northwind.

Pourquoi n'est-il pas bon d'avoir beaucoup de logique dans la couche d'interface utilisateur ? Le code de la couche d'interface utilisateur d'une application est très difficile à tester sans exécuter l'application manuellement ou sans gérer d'affreux scripts d'exécution d'interface utilisateur qui automatisent l'exécution des composants de l'interface. Bien qu'il s'agisse en soi d'un gros problème, les lignes de code dupliquées entre les vues communes d'une application sont un problème encore plus important. Il est souvent difficile de trouver les candidats à la refactorisation, lorsque la logique permettant d'effectuer une fonction métier spécifique est copiée entre les différents éléments de la couche d'interface utilisateur. Le motif de conception MVP facilite la factorisation de la logique et du code hors de la couche d'interface utilisateur, afin d'obtenir du code plus rationnel, plus utilisable et plus facile à tester.

La figure 1 illustre les principales couches qui constituent l'exemple d'application. Notez qu'il existe des packages distincts pour l'interface utilisateur et la présentation. Vous pensiez peut-être qu'ils étaient les mêmes, mais la couche d'interface utilisateur d'un projet doit en fait être constituée uniquement des divers éléments d'interface utilisateur, à savoir les formulaires et les contrôles. Dans un projet Web Forms, il s'agit généralement d'une collection de formulaires Web ASP.NET, de contrôles utilisateur et de contrôles serveur. Dans Windows Forms, il s'agit d'une collection de Windows Forms, de contrôles utilisateur et de bibliothèques tierces. Cette couche supplémentaire est ce qui permet de séparer l'affichage de la logique. Dans la couche de présentation, vous trouvez les objets qui implémentent réellement le comportement de l'interface utilisateur, par exemple l'affichage de validation, la saisie via l'interface, etc.

Figure 1 Architecture de l'application
Figure 1 Architecture de l'application

Haut de pageHaut de page

Suivre le motif MVP

Comme vous pouvez le voir figure 2, l'interface utilisateur de ce projet est relativement standard. Lorsque la page est chargée, l'écran affiche une zone de liste déroulante remplie avec tous les clients de la base de données Northwind. Si vous sélectionnez un client dans la liste déroulante, la page est mise à jour afin d'afficher les informations relatives à ce client. En suivant le motif de conception MVP, vous pouvez factoriser les comportements hors de l'interface utilisateur, dans leurs propres classes. La figure 3 illustre un diagramme de classe qui indique l'association entre les différentes classes impliquées.

Figure 2 Informations client
Figure 2 Informations client

Il est important de noter que le présenteur n'a aucune connaissance de la couche d'interface utilisateur réelle de l'application. Il sait qu'il peut communiquer avec une interface, mais il ne sait pas et ne se préoccupe pas de ce qu'est l'implémentation de cette interface. Cela permet de promouvoir la réutilisation des présenteurs entre les technologies d'interface utilisateur disparates.

Je vais utiliser TDD (Test Driven Development) pour créer les fonctionnalités de l'écran client. La figure 4 affiche les détails du premier test que je vais utiliser pour décrire le comportement que je m'attends à constater lors du chargement de la page. TDD me permet de me concentrer sur un problème à la fois, en écrivant juste assez de code pour que le test réussisse, puis de continuer. Dans ce test, j'utilise un environnement d'objet factice appelé NMock2, qui me permet de créer des implémentations factices des interfaces.

Figure 3 Diagramme de classe MVP
Figure 3 Diagramme de classe MVP

Dans mon implémentation MVP, j'ai décidé que le présenteur accepterait comme dépendance la vue avec laquelle il va travailler. Il est toujours intéressant de créer des objets dans un état leur permettant de faire leur travail immédiatement. Dans cette application, la couche d'application dépend de la couche de service pour appeler réellement la fonctionnalité de domaine. En raison de cette exigence, il est également utile de construire un présenteur avec une interface vers une classe de service avec laquelle il peut communiquer. Cela permet de garantir qu'une fois un présenteur construit, il est prêt à effectuer tout le travail nécessaire. Je commence en créant deux objets factices spécifiques : un pour la couche de service et un pour la vue que le présenteur utilisera.

Pourquoi des objets factices ? Une règle de test d'unité consiste à isoler le test autant que possible afin de se concentrer sur un objet spécifique. Dans ce test, je suis uniquement intéressé par le comportement attendu du présenteur. À ce stade, je ne me préoccupe pas de l'implémentation réelle de l'interface d'affichage ou de l'interface de service ; je fais confiance aux contrats définis par ces interfaces et je configure les objets factices pour qu'ils se comportent en conséquence. Ainsi, je concentre mon test uniquement autour du comportement que j'attends du présenteur, et non de ses dépendances. Le comportement que le présenteur devrait avoir après l'appel de sa méthode d'initialisation est le suivant.

Tout d'abord, le présenteur doit appeler la méthode GetCustomerList sur l'objet de la couche de service ICustomerTask (factice dans le test). Notez qu'avec l'utilisation de Nmock, je peux simuler le comportement de l'objet factice. Dans le cas de la couche de service, je souhaite qu'il renvoie un objet factice ILookupCollection au présenteur. Ensuite, une fois que le présenteur a extrait ILookupCollection de la couche de service, il doit appeler la méthode BindTo de la collection et transmettre à la méthode une implémentation d'un objet ILookupList. En utilisant la méthode NMockExpect.Once, je peux être certain que le test échouera si le présenteur n'appelle pas la méthode une fois et une seule.

Après l'écriture de ce test, l'état est totalement non compilable. Je vais faire l'opération la plus simple possible pour que le test réussisse.

Haut de pageHaut de page

Faire en sorte que le premier test réussisse

L'un des avantages de l'écriture du test en premier est que je dispose à présent d'une référence (le test) que je peux suivre pour que le test soit compilé et réussisse. Le premier test comporte deux interfaces qui n'existent pas encore. Ces interfaces sont les premiers prérequis pour que le code soit compilé correctement. Je vais commencer avec le code de IViewCustomerView :

public interface IViewCustomerView
{
    ILookupList CustomerList { get; }
}

Cette interface expose une propriété qui renvoie l'implémentation d'une interface ILookupList. Je n'ai pas encore d'interface ILookupList ou même d'implémenteur pour cela. Pour que ce test réussisse, je n'ai pas besoin d'un implémenteur explicite, de sorte que je peux passer à la création de l'interface ILookupList :

public interface ILookupList { }

À ce stade, l'interface ILookupList semble relativement inutile. Mon objectif est que le test soit compilé et réussisse, et ces interfaces satisfont aux exigences du test. Il est temps de passer à l'objet que je souhaite réellement tester, à savoir ViewCustomerPresenter. Cette classe n'existe pas encore, mais en examinant le test, vous pouvez en déduire deux faits importants : elle possède un constructeur qui nécessite une vue et une implémentation de service comme dépendances, et elle présente une méthode Initialize vide. Le code de la figure 5 illustre comment compiler le test.

Rappelez-vous qu'un présenteur nécessite toutes ses dépendances pour faire correctement son travail ; c'est la raison pour laquelle la vue et le service sont transmis. Je n'ai pas implémenté la méthode d'initialisation, de sorte que si j'exécute le test, j'obtiens une exception NotImplementedException.

Comme je l'ai indiqué précédemment, je ne code pas le présenteur de manière aveugle ; je sais déjà, pour avoir examiné le test, le comportement que le présenteur doit avoir une fois la méthode d'initialisation appelée. L'implémentation de ce comportement est la suivante :

public void Initialize()
{            
    task.GetCustomerList().BindTo(view.CustomerList);
}

Le code source qui accompagne cet article contient une implémentation complète de la méthode GetCustomerList dans la classe CustomerTask (laquelle implémente l'interface ICustomerTask). Du point de vue de l'implémentation et du test du présenteur, je n'ai pas besoin de savoir s'il existe déjà une implémentation qui fonctionne. C'est ce niveau d'abstraction qui me permet de procéder au test de la classe de présenteur. Le premier test est à présent dans un état qui est compilé et exécuté. Cela prouve que lorsque la méthode Initialize du présenteur est appelée, elle interagit avec ses dépendances de la façon spécifiée dans le test, et finalement lorsque les implémentations concrètes de ces dépendances sont injectées dans le présenteur, je peux être certain que la vue résultante (page ASPX) sera remplie avec une liste de clients.

Haut de pageHaut de page

Remplissage de la liste DropDownList

Jusqu'à présent, j'ai traité principalement les interfaces permettant l'abstraction des détails de l'implémentation réelle, ce qui m'a permis de me concentrer sur le présenteur. Il est à présent temps de créer les éléments qui permettront au final au présenteur d'alimenter une liste sur une page, d'une manière pouvant être testée. La clé est ici l'interaction qui se produira dans la méthode BindTo de la classe LookupCollection. Si vous examinez l'implémentation de la classe LookupCollection dans la figure 6, vous remarquerez qu'elle implémente l'interface ILookupCollection. Le code source de l'article contient les tests qui étaient utilisés pour créer la fonctionnalité de la classe LookupCollection.

L'implémentation de la méthode BindTo est particulièrement intéressante. Notez que dans cette méthode, la collection itère dans sa propre liste privée d'implémentations ILookupDTO. Un objet ILookupDTO est une interface qui gère la liaison avec les listes déroulantes dans la couche d'interface utilisateur :

public interface ILookupDTO
{
    string Value { get; }   
    string Text { get; }
}

La figure 7 illustre le code qui teste la méthode BindTo de la collection de recherche, laquelle permet d'expliquer l'interaction attendue entre un objet LookupCollection et un objet ILookupList. La dernière ligne est particulièrement intéressante. Dans ce test, je prévois qu'avant de tenter d'ajouter des éléments à la liste, LookupCollection appellera la méthode Clear sur l'implémentation ILookupList. Je prévois ensuite 10 fois l'appel de Add sur un objet ILookupList, et comme argument de la méthode Add, LookupCollection transmettra un objet qui implémente l'interface ILookupDTO. Pour que cela fonctionne réellement avec un contrôle dans un projet Web (tel qu'une zone de liste déroulante), vous devrez créer une implémentation d'ILookupList qui sache comment travailler avec les contrôles dans un projet Web.

Le code source qui accompagne cet article contient un projet nommé MVP.Web.Controls. Ce projet contient les contrôles ou classes propres au Web que j'ai choisi de créer pour compléter la solution. Pourquoi ai-je placé le code dans ce projet et non dans le répertoire APP_CODE ou dans le projet Web proprement dit ? C'est une question de test. Tout ce qui réside dans le projet Web est difficile à tester directement sans exécuter l'application manuellement ou sans automatiser l'interface utilisateur avec un certain type de robot de test. Le motif MVP me permet de raisonner à un niveau d'abstraction plus élevé et de tester les implémentations des interfaces essentielles (ILookupList et ILookupCollection) sans exécuter manuellement l'application. Je vais ajouter une nouvelle classe, à savoir un contrôle WebLookupList, au projet Web.Controls. La figure 8 affiche le premier test de cette classe.

Certains éléments se démarquent dans le test illustré figure 8. Le projet test a clairement besoin d'une référence à la bibliothèque System.Web pour pouvoir instancier les contrôles Web DropDownList. En examinant le test, vous devez voir que la classe WebLookupList implémente l'interface ILookupList. Elle va également utiliser un objet ListControl comme dépendance. Deux des implémentations ListControl les plus courantes de l'espace de noms System.Web.UI.WebControls sont les classes DropDownList et ListBox. Une fonctionnalité essentielle du test de la figure 8 est le fait que je m'assure qu'un objet WebLookupList met correctement à jour l'état d'un objet Web ListControl réel auquel il délègue la responsabilité. La figure 9 illustre le diagramme des classes impliquées dans l'implémentation de WebLookupList. Je peux satisfaire aux exigences du premier test pour le contrôle WebLookupList avec le code de la figure 10.

Figure 9 Classe WebLookupList
Figure 9 Classe WebLookupList

Rappelez-vous, l'une des clés de MVP est la séparation des couches introduite par la création d'une interface d'affichage. Le présenteur ne sait pas avec quelle implémentation d'une vue, et respectivement un objet ILookupList, il va communiquer ; il sait simplement qu'il pourra appeler n'importe laquelle des méthodes définies par ces interfaces. Au final, la classe WebLookupList est une classe qui encapsule et délègue à un objet ListControl sous-jacent (classe de base pour certains des objets ListControls définis dans le projet System.Web.UI.WebControls). Ce code étant maintenant en place, je peux compiler et exécuter le test de contrôle WebLookupList, lequel doit réussir. Je peux ajouter un test supplémentaire pour WebLookupList, afin de tester le comportement réel de la méthode Clear :

[Test]
public void ShouldClearUnderlyingList()
{
    ListControl webList = new DropDownList();
    ILookupList list = new WebLookupList(webList);
    
    webList.Items.Add(new ListItem("1", "1"));
    
    list.Clear();
    
    Assert.AreEqual(0, webList.Items.Count);
}

Là encore, je vérifie que la classe WebLookupList change effectivement l'état de l'objet ListControl sous-jacent (DropDownList) lorsque ses propres méthodes sont appelées. L'objet WebLookupList possède à présent toutes les fonctionnalités pour alimenter un objet DropDownList dans un formulaire Web. Il est à présent temps de tout relier et d'alimenter la liste déroulante de la page Web avec une liste de clients.

Haut de pageHaut de page

Implémentation de l'interface d'affichage

Dans la mesure où je crée un front-end Web Forms, il est normal que l'implémenteur de l'interface IViewCustomerView soit un Web Form ou un contrôle utilisateur. Pour les besoins de cet article, je vais en faire un Web Form. L'aspect général de la page a déjà été créé, comme vous l'avez vu dans la figure 2. Je dois simplement implémenter l'interface d'affichage. En passant au codebehind de la page ViewCustomers.aspx, je peux ajouter le code suivant, indiquant que la page est requise pour implémenter l'interface IViewCustomersView :

public partial class ViewCustomers : Page,IViewCustomerView

Si vous examinez l'exemple de code, vous remarquerez que le projet Web et Presentation sont deux assemblys totalement différents. En outre, le projet Presentation ne comporte aucune référence au projet Web.UI, ce qui permet de préserver la couche de séparation. À l'inverse, le projet Web.UI doit avoir une référence au projet Presentation, puisque c'est là que résident l'interface View et le présenteur.

En choisissant d'implémenter l'interface IViewCustomerView, notre page Web est à présent responsable de l'implémentation des méthodes ou propriétés définies par cette interface. Il n'y a actuellement qu'une seule propriété sur l'interface IViewCustomerView, à savoir un objet getter qui renvoie une implémentation quelconque d'une interface ILookupList. J'ai ajouté une référence au projet Web.Controls afin de pouvoir instancier un objet WebLookupListControl. J'ai fait cela parce que WebLookupListControl implémente l'interface ILookupList et sait comment déléguer aux objets WebControls réels présents dans ASP.NET. En examinant le code ASPX de la page ViewCustomer, vous verrez que la liste des clients est simplement un contrôle asp:DropDownList :

<td>Customers:</td>
<td><asp:DropDownList id="customerDropDownList" AutoPostBack="true" 
        runat="server" Width="308px"></asp:DropDownList></td>
</tr>

Ces éléments étant en place, je peux rapidement continuer d'implémenter le code requis pour satisfaire l'implémentation de l'interface IViewCustomerView :

public ILookupList CustomerList
{
    get { return new WebLookupList(this.customerDropDownList);}
}

Je dois à présent appeler sur le présenteur la méthode Initialize qui déclenchera l'exécution d'un travail. Pour cela, la vue doit pouvoir instancier le présenteur, de sorte que les méthodes correspondantes puissent être appelées. Si vous revenez au présenteur, vous vous rappelerez qu'il nécessite à la fois une vue et un service avec lequel il travaillera. L'interface ICustomerTask représente une interface qui réside dans la couche de service de l'application. Les couches de service sont généralement responsables de l'orchestration de l'interaction entre les objets du domaine et de la conversion des résultats de ces interactions en objets DTO (Data Transfer Objects) qui sont ensuite transmis de la couche de service à la couche de présentation, puis à la couche d'interface utilisateur. Il existe cependant un problème ; j'ai stipulé que le présenteur doit être construit à la fois avec les implémentations de l'affichage et du service.

L'instanciation réelle du présenteur aura lieu dans le codebehind de la page Web. C'est un problème, car le projet d'interface utilisateur n'a aucune référence au projet de la couche de service. Le projet de présentation présente en revanche une référence au projet de couche de service. Cela me permet de résoudre le problème en ajoutant un constructeur surchargé à la classe ViewCustomerPresenterClass :

public ViewCustomerPresenter(IViewCustomerView view) : 
    this(view, new CustomerTask()) {}

Ce nouveau constructeur satisfait à l'exigence du présenteur pour les implémentations de la vue et du service, tout en préservant la séparation entre la couche d'interface utilisateur et la couche de service. Il est à présent relativement trivial de terminer le code du codebehind :

protected override void OnInit(EventArgs e)
{
    base.OnInit(e);
    presenter = new ViewCustomerPresenter(this);
}

protected void Page_Load(object sender, EventArgs e)
{        
    if (!IsPostBack) presenter.Initialize();
}

Notez que la clé de l'instanciation du présenteur est le fait que j'utilise la surcharge nouvellement créée pour le constructeur, et que le formulaire Web se transmet en tant qu'objet qui implémente l'interface d'affichage !

Avec le code du codebehind implémenté, je peux désormais créer et exécuter l'application. L'objet DropDownList de la page Web est désormais alimenté avec une liste de noms de clients, sans nécessité de code de liaison de données dans le codebehind. En outre, des tests ont été exécutés sur tous les éléments qui collaborent ensemble, garantissant que l'architecture de la couche de présentation se comporte comme prévu.

Je vais conclure mon étude de MVP en montrant ce qui est requis pour afficher les informations relatives à un client sélectionné dans l'objet DropDownList. Une fois de plus, je commence par écrire un test qui décrit le comportement que je m'attends à observer (voir figure 11).

Comme précédemment, je tire parti de la bibliothèque NMock pour créer des objets factices des interfaces de tâche et d'affichage. Ce test particulier vérifie le comportement du présenteur en demandant à la couche de service un objet DTO représentant un client particulier. Une fois que le présenteur a extrait le DTO de la couche de service, il met à jour les propriétés sur la vue directement, éliminant la nécessité pour l'affichage de disposer d'informations concernant l'affichage correct des informations à partir de l'objet. Par souci de concision, je ne vais pas étudier l'implémentation de la propriété SelectedItem sur le contrôle WebLookupList ; je vous laisse le soin d'examiner le code source afin de voir les détails d'implémentation. Ce que ce test démontre réellement est l'interaction entre le présenteur et l'affichage une fois que le présenteur a reçu un objet CustomerDTO à partir de la couche de service. Si je tente d'exécuter le test maintenant, je constate un échec complet, car nombre des propriétés n'existent pas encore sur l'interface d'affichage. Je vais donc continuer et ajouter les membres nécessaires à l'interface IViewCustomerView, comme vous pouvez le voir figure 12.

Immédiatement après l'ajout de ces membres d'interface, mon formulaire Web se plaint parce qu'il ne satisfait plus au contrat de l'interface ; je dois donc revenir au codebehind de mon formulaire Web afin d'implémenter les membres restants. Comme indiqué précédemment, le balisage complet de la page Web a déjà été créé, tout comme les cellules des tableaux, marquées avec l'attribut "runat=server" et nommées conformément aux informations qui doivent y être affichées. Cela rend très trivial le code résultant permettant d'implémenter les membres de l'interface :

public string CompanyName
{
    set { this.companyNameLabel.InnerText = value; }
}
public string ContactName
{
    set { this.contactNameLabel.InnerText = value; }
}
...

Avec les propriétés setter implémentées, il ne reste plus qu'une chose à faire. J'ai besoin d'un moyen de demander au présenteur d'afficher les informations du client sélectionné. En revenant au test, vous pouvez voir que l'implémentation de ce comportement réside dans la méthode DisplayCustomerDetails du présenteur. Cette méthode n'accepte en revanche aucun argument. Lorsqu'elle est appelée, le présenteur revient à l'affichage, en extrait les informations dont il a besoin (via l'objet ILookupList), puis utilise ces informations pour extraire les détails relatifs au client en question. Tout ce que j'ai à faire du point de vue de l'interface utilisateur est de définir la propriété AutoPostBack de l'objet DropDownList sur true, et je dois également ajouter le code du gestionnaire d'événements à la méthode OnInit de la page :

protected override void OnInit(EventArgs e)
{
    base.OnInit(e);
    presenter = new ViewCustomerPresenter(this);
    this.customerDropDownList.SelectedIndexChanged += delegate
    {
        presenter.DisplayCustomerDetails();
    };
}

Ce gestionnaire d'événements garantit que chaque fois qu'un nouveau client est sélectionné dans la liste déroulante, l'affichage demande au présenteur d'afficher les détails de ce client.

Il est important de noter qu'il s'agit d'un comportement typique. Lorsqu'un affichage demande à un présenteur de faire quelque chose, il demande sans donner de détails spécifiques, et c'est au présenteur de revenir à la vue et d'obtenir les informations dont il a besoin avec l'interface d'affichage. La figure 13 affiche le code requis pour implémenter le comportement requis dans le présenteur.

Vous devriez à présent voir la valeur de l'ajout de la couche du présenteur. Il incombe au présenteur de tenter d'extraire un ID pour un client dont il doit afficher les détails. Il s'agit du code qui aurait normalement dû être exécuté dans le codebehind, mais qui se trouve désormais dans une classe que je peux tester totalement et utiliser en dehors de toute technologie de couche de présentation.

Si le présenteur peut extraire un ID client valide de l'affichage, il revient à la couche de service et demande un DTO qui représente les détails du client. Une fois que le présenteur a le DTO en main, il met à jour l'affichage avec les informations contenues dans le DTO. Un point important à noter est la simplicité de l'interface d'affichage ; outre l'interface ILookupList, l'interface d'affichage est constituée entièrement de données de type chaîne. Il incombe au présenteur de convertir et de formater correctement les informations extraites du DTO, afin qu'elles puissent être envoyées à l'affichage en tant que chaîne. Bien que cela ne soit pas illustré dans cet exemple, le présenteur est également responsable de la lecture des informations à partir de l'affichage, et de leur conversion dans les types nécessaires attendus par la couche de service.

Avec tous les éléments en place, je peux à présent exécuter l'application. Lors du premier chargement de la page, j'obtiens une liste de clients et le premier client apparaît (non sélectionné) dans la liste DropDownList. Si je sélectionne un client, un postback a lieu, l'interaction entre l'affichage et le présenteur a lieu, et la page Web est mise à jour avec les informations du client correspondant.

Haut de pageHaut de page

Et maintenant ?

Le motif de conception Model View Presenter est simplement une adaptation du motif Model View Controller que de nombreux développeurs connaissent déjà ; la distinction essentielle est que MVP sépare réellement l'interface utilisateur de la couche domaine/service de l'application. Bien que cet exemple soit relativement simple du point de vue des exigences, il doit vous aider à définir l'abstraction entre une interface utilisateur et les autres couches de vos applications Vous devez maintenant comprendre comment utiliser ces couches d'indirection pour faciliter l'automatisation des tests de vos applications. Si vous examinez plus en détail le motif MVP, vous trouverez probablement d'autres façons d'extraire de vos codebehinds la logique de mise en forme et la logique conditionnelle, afin de les placer dans des modèles d'interaction affichage/présenteur faciles à tester.

Envoyez vos questions et commentaires à mmpatt@microsoft.com.

Jean-Paul Boodhoo est expert .NET senior chez ThoughtWorks, où il fournit des applications d'entreprise utilisant le .NET Framework et les méthodes agiles. Il anime souvent des présentations concernant l'exploitation de la puissance de .NET avec le développement piloté par les tests. Jean-Paul peut être contacté à l'adresse bitwisejp@gmail.com ou via le site www.jpboodhoo.com/blog.


Haut de pageHaut de page