Intégration

Troisième volet de la saga sur la conception émergente. Après avoir implémenté la logique de notre premier scénario, il est temps d'obtenir une première version utilisable de notre application. Pour cela, il suffit de donner corps aux composants d'interaction avec l'environnement du logiciel, l'interface utilisateur et l'accès à des documents XML, puis d'intégrer l'ensemble de nos composants au sein d'un exécutable. L'utilisation de TDD pour la réalisation de composants d'interaction externe est toujours un point délicat. Si le monde extérieur est lourd à simuler, les tests unitaires perdent de leur intérêt car le temps passé à mettre en oeuvre l'environnement de test devient prohibitif. En même temps, si les composants ne sont pas suffisamment testés, la mise au point de l'ensemble après intégration peut rapidement devenir fastidieuse.

Commençons par l'accès aux documents XML. Ces documents sont typiquement récupérés sur Internet à travers un protocole HTTP. La seule question (ou presque) à se poser est : ai-je un moyen simple de simuler un serveur HTTP dans mes tests unitaires ? Il se trouve que le framework .NET dispose d'une classe HttpListener permettant de faire cela à peu de frais. Nous encapsulons cette classe dans un WebServer qui nous fournit un outil de test simple et efficace :

WebServer webServer = new WebServer("http://localhost:8086/");
webServer.AddResource( "bingo1.txt", delegate
  { return System.Text.Encoding.UTF8.GetBytes("This is bingo 1"); } );
webServer.AddResource( "bingo2.txt", delegate
  { return System.Text.Encoding.UTF8.GetBytes("This is bingo 2"); } );
webServer.IsUp += delegate
{
  System.Net.HttpWebRequest httpRequest =
    WebRequest.Create("http://localhost:8086/bingo1.txt")
      as System.Net.HttpWebRequest;
  TextReader reader =
    new StreamReader(httpRequest.GetResponse().GetResponseStream());
  Assert.AreEqual( "This is bingo 1", reader.ReadLine() );
  httpRequest =
    WebRequest.Create("http://localhost:8086/bingo2.txt")
      as System.Net.HttpWebRequest;
  reader =
    new StreamReader(httpRequest.GetResponse().GetResponseStream());
  Assert.AreEqual( "This is bingo 2", reader.ReadLine() );
};
webServer.Start();
webServer.Stop();

A partir de là, écrire un test d'utilisation nominale d'un IXmlFetcher est chose aisée :

WebServer webServer = new WebServer("http://localhost:8086/");
webServer.AddResource( "podcast.xml",
  delegate
  {
    return System.Text.Encoding.UTF8.GetBytes(
    @"<?xml version='1.0' encoding='utf-8'?>
      <rss version='2.0'>
        <channel>
          <title>Test1 Channel</title>
          <item><title>Test1 Item 1</title></item>
          <item><title>Test1 Item 2</title></item>
        </channel>
      </rss>");
  }
);
webServer.Start();
bool responseReceived = false;
IXmlFetcher fetcher = new XmlFetcher();
fetcher.Retrieved += delegate
{
  Assert.IsNotNull(fetcher.Response,"response is null");
  Assert.AreEqual(2,fetcher.Response.CreateNavigator().Select("//item").Count,"wrong number of items");
  responseReceived = true;
};
fetcher.Source = "http://localhost:8086/podcast.xml";
Assert.IsTrue(responseReceived,"response not received");
webServer.Stop();

Pour réussir le test, il nous faut tout d'abord créer un XmlFetcher qui implémente l'interface IXmlFetcher. Puis il faut réaliser une requête HTTP/XML lors du positionnement de la propriété Source. A défaut d'information complémentaire, nous nous contentons d'une requête synchrone qui, certes bloquera le programme en attendant la réponse HTTP mais au moins fournit une solution simple et satisfaisant aux exigences :

public class XmlFetcher : IXmlFetcher
{
  public event EventHandler Retrieved;
  System.Xml.XPath.IXPathNavigable response_;

  public XmlFetcher() { response_ = null; }

  string IXmlFetcher.Source {
    set {
      System.Net.HttpWebRequest httpRequest = System.Net.WebRequest.Create(value) as System.Net.HttpWebRequest;
      System.Net.HttpWebResponse httpResponse = httpRequest.GetResponse() as System.Net.HttpWebResponse;
      System.Xml.XmlDocument doc = new XmlDocument();
      doc.Load(httpResponse.GetResponseStream());
      response_ = doc;
      Retrieved(this,null);
    }
  }

  System.Xml.XPath.IXPathNavigable IXmlFetcher.Response {
    get { return response_; }
  }
}

Les puristes du TDD pourraient objecter que je brûle un peu les étapes en écrivant autant de lignes de test d'un seul coup puis en écrivant tout ce code d'une seule traite pour réussir le test. Ils auraient raison. D'ailleurs, en réalité, j'ai commencé par écrire un test qui se limitait à instancier un XmlFetcher, puis j'ai écrit une classe XmlFetcher vide. Ensuite j'ai écrit le test sans WebServer ni partie XML (celle où on compte les items) et, pour le réussir, j'ai implémenté une propriété Source qui lève immédiatement l'évènement Retrieved. Dans une troisième étape, j'ai rajouté au test la partie XML avec le renvoi dans Response d'un document codé en dur. Enfin, en dernier, j'ai rajouté le WebServer dans le test et le dialogue HTTP dans XmlFetcher. Si je présente les choses de manière aussi concise, c'est que je trouve leur lecture plus digeste et que je suppose que le lecteur connait suffisamment les gammes du TDD pour pouvoir se focaliser sur les enchainements de plus haut niveau.

Notre partie XML étant au point, intéressons nous à la partie interface utilisateur. Il existe des outils, tels NUnitForms ou NUnitAsp, adaptés aux tests unitaires de divers types d'interface utilisateur. En l'occurrence, nous écrivons une application WinForms donc NUnitForms serait tout à fait utilisable. Cependant, j'estime que l'intérêt est relativement limité. Je ne connais que 2 niveaux pour appréhender les tests d'interactions utilisateur :

  • Le niveau Humble Dialog Box que l'on a implémenté dans l'interface IPodcastSubscriptionView et dont le comportement a été testé précédemment lors du développement de PodcastSubscription.
  • Le niveau "total" où les moindres évènements réflexe du moindre widget se devraient d'être testé mais qui devient rapidement une usine à gaz si l'on se met en tête de tout tester à chaque fois.

L'approche suivante me parait raisonnable :

  • Lorsque l'on utilise des widgets standards, seul le niveau Humble Dialog Box mérite un test. On y aborde l'interaction utilisateur sans se préoccuper des comportements graphiques éventuellement paramétrables au niveau du widget sans lien avec la logique de l'application.
  • Lorsque les widgets standards ne suffisent pas, on en développe des spécifiques. On les teste alors avec un outil adéquat mais on essaie toujours de garder le niveau d'abstraction où la logique de l'application est découplée de la partie graphique proprement dite.

C'est en tout cas ce que je vais faire ici : écrire un composant WinForms sans aucun test unitaire et passer directement en intégration. Avec le concepteur graphique, je réalise le contrôle utilisateur PodcastSubscriptionView suivant : GUI

Je fais ensuite le lien entre ces éléments et IPodcastSubscriptionView :

public partial class PodcastSubscriptionView : UserControl, IPodcastSubscriptionView
{
  public event EventHandler NewSource;
  //...
  string IPodcastSubscriptionView.Source {
    get { return SourceTextBox.Text; }
  }

  System.Collections.Generic.IList<IPodcastItem> IPodcastSubscriptionView.Items {
    set { ItemsGridView.DataSource = value; }
  }

  void GoButtonClick(object sender, EventArgs e)
  {
    NewSource(this,null);
  }
}

Pour couronner le tout, il me manque encore à rajouter un programme principal où une simple Form contient un PodcastSubscriptionView nommé TheSubscriptionView et instancie les composants de notre application :

public partial class MainForm : Form
{
  PodcastSubscription subscription_;

  [STAThread]
  public static void Main(string[] args)
  {
    Application.Run(new MainForm());
  }

  public MainForm()
  {
    InitializeComponent();
    subscription_ = new PodcastSubscription(TheSubscriptionView,new XmlFetcher());
  }
}

Et voilà, ça fonctionne ! Run

Le diagramme statique a sa forme finale après le 1er scénario : Conception

Un développeur de niveau correct doit pouvoir réaliser un tel travail de conception/implémentation en 1 ou 2 heures. Il est temps pour lui de montrer le résultat à son client qui, idéalement, ne doit pas être très loin.
Le client voit immédiatement que la fonctionnalité attendue est là mais comme c'est un client qui aime bien essayer diverses choses avec le logiciel qu'on lui met entre les mains (quel client n'aime pas cela ?), il entre une URL qui ne correspond pas à un podcast valide et là c'est le drame : Erreur

Nous n'avons pas traité les cas d'erreur lors de la récupération du document XML ! Heureusement, notre méthode de travail va nous permettre de rajouter cela très rapidement.

Ce sera le sujet du prochain épisode.