Finalisation avant 1ère livraison

Après avoir terminé l'implémentation de notre 1er scénario, nous sommes presque sur le point de pouvoir livrer une 1ère version de notre logiciel. Notre client ayant mis en évidence un cas d'utilisation non couvert par nos tests, nous allons nous empresser de rectifier le tir. J'entends déjà les esprits chagrins se lamenter "Voilà ce qui arrive quand on veut laisser émerger la conception au lieu de bien réfléchir avant de se lancer dans le codage !" J'ai envie de leur répondre que, permièrement, en se contentant de réfléchir sur papier, on a bien peu de chances de découvrir les cas limites. En l'occurrence, le client n'a vu le problème que parce qu'il avait le logiciel définitif devant lui et non pas des diagrammes à l'exécutabilité hypothétique ou une vulgaire maquette. Deuxièmement, moi, au moins, j'ai déjà un logiciel près d'être livré. Si j'avais passé mon temps à "concevoir" dans un état de pure abstraction, j'aurais gaspillé une bonne partie de mon temps.
Ceci étant dit, retournons au travail. Une URL invalide saisie comme source pour notre podcast fait planter le logiciel et cela n'est pas acceptable.

Face à un cas d'utilisation non prévu, il est de bon ton de demander au client ce qu'il envisage comme comportement pour le logiciel. En l'occurrence, faisons le choix de rajouter une information d'état du logiciel qui rendra compte à l'utilisateur des éventuelles erreurs. Cela étant décidé, il reste à choisir lequel de nos composants devrait avoir la responsabilité de détecter cette erreur avant qu'elle puisse être remontée jusqu'à l'interface graphique.
Le choix de la simplicité devrait guider notre démarche. L'erreur est matérialisée sous la forme d'une exception levée dans XmlFetcher et il est hors de question de la laisser passer jusqu'à PodcastSubscription. En effet, cela reviendrait à tester des fonctionnalités d'interfaçage externe (en l'occurrence une exception issue de System.Net) dans notre partie traitant de logique métier. On mettrait là le doigt dans un engrenage inextricable en ce qui concerne la testabilité de PodcastSubscription.. La solution est donc presque évidente : il nous faut attraper l'exception dans le composant où elle se produit et remonter l'information en utilisant un évènement. Cela nous permettra de conserver une uniformité et donc une bonne lisibilité de nos interfaces.

Va donc pour un évènement sur IXmlFetcher mais quel évènement ? Puisqu'on en est à traiter les exceptions sur les accès HTTP/XML, traitons les toutes en bloc avec un évènement FailedToRetrieve. Si, par la suite quelqu'un (le développeur, le client, ...) pense à un cas particulier d'erreur, il sera toujours possible de rajouter un évènement pour gérer le scénario.
Comme toujours, pour écrire le code adéquat, commençons par écrire un test. Nous voulons que lors de la découverte d'une erreur, un message adéquat soit présenté à l'utilisateur :

//...
Expect.Once.On(mockView).SetProperty("Status").To("Failed to retrieve podcast at http://someserver/podcast.content.xml");

IXmlFetcher mockFetcher = mocks.NewMock<IXmlFetcher>();
Expect.Once.On(mockFetcher).SetProperty("Source").To("http://someserver/podcast.content.xml");
MockEvent failedToRetrieveEvent = new MockEvent();
Stub.On(mockFetcher).EventAdd("FailedToRetrieve", Is.Anything).Will(MockEvent.Hookup(failedToRetrieveEvent));

PodcastSubscription subscription = new PodcastSubscription(mockView,mockFetcher);
failedToRetrieveEvent.Raise();
mocks.VerifyAllExpectationsHaveBeenMet();

On remarquera que la séquence d'initialisation du test (dont je n'ai pas recopié l'intégralité) est très similaire à celle de notre scénario principal. Pour éviter de laisser s'installer des duplicata de code, je regroupe mes initialisations (les créations d'objets mock et les déclarations de stubs) dans une méthode de setup pour ne conserver dans les méthodes de test proprement dites que les attentes (Expect de NMock et Assert de NUnit) et les levées d'évènement. Les étapes de refactorisation ne doivent pas être négligées, y compris au niveau des tests, car elles font partie intégrante du processus qui permet de garder un code, et donc, une conception clairs et lisibles.
Par ailleurs, on n'oubliera pas qu'il vaut mieux faire une refactorisation de test sur la barre rouge. En effet, refactoriser un test, c'est un peu le réécrire. Et quand on écrit un test, on s'assure de le faire échouer avant de le faire passer. Ici on va faire de même.
Mon test principal fonctionne. Avant de le modifier, je rajoute, par exemple, une levée d'exception dans le traitement de l'évènement NewSource par PodcastSubscription.

    view_.NewSource += delegate
    {
      fetcher_.Source = view_.Source;
      throw new Exception("Test Refactoring");
    };

Je lance mon test sur le scénario principal, je constate qu'il échoue du fait de la levée de l'exception. Je suis donc en état de refactoriser. Je passe toute mes initialisations dans une méthode de set up et je conserve la substance au niveau de chaque méthode de test. En l'occurrence, mon test pour le scénario où est rencontrée une erreur de récupération du podcast peut désormais être écrit :

[Test]
public void FailedToRetrieve()
{
    Expect.Once.On(mockFetcher).SetProperty("Source").To("http://someserver/podcast.content.xml");
    Expect.Once.On(mockView).SetProperty("Status").To("Failed to retrieve podcast at http://someserver/podcast.content.xml");
    newSourceEvent.Raise();
    failedToRetrieveEvent.Raise();
    mocks.VerifyAllExpectationsHaveBeenMet();
}

Après la refactorisation, je relance mon test principal. Ils échoue cette fois non pas sur l'exception mais sur l'existence de l'évènement FailedToRetrieve dans IXmlFetcher. Je m'empresse de le rajouter :

public interface IXmlFetcher
{
    event EventHandler Retrieved;
    event EventHandler FailedToRetrieve;
    string Source { set; }
    System.Xml.XPath.IXPathNavigable Response { get; }
}

Je recompile mon projet. Erreur sur la classe XmlFetcher. Et oui : il faut déclarer cet évènement dans les classes qui implémentent cette interface. Je recompile. Je relance le tests : ils échoue désormais sur la levée d'exception. C'est une bonne nouvelle car cela signifie que ma refactorisation n'a pas altéré son objectif. J'enlève la levée d'exception. Mon test sur scénario principal redevient vert. Je peux désormais me concentrer sur le test du cas d'erreur qui échoue sur l'existence d'une propriété Status sur IPodcastSubscriptionView. J'adapte donc l'interface en conséquence :

public interface IPodcastSubscriptionView
{
    event EventHandler NewSource;
    string Source { get; }
    string Status { set; }
    System.Collections.Generic.IList<IPodcastItem> Items { set; }
}

Le test échoue encore mais cette fois c'est parce que la propriété S en question n'est pas positionnée comme il était attendu lors de la levée de FailedToRetrieve. Pour arranger cela, il suffit de traiter l'évènement dans PodcastSubscription :

public PodcastSubscription(IPodcastSubscriptionView view,IXmlFetcher fetcher)
{
    //...
    fetcher_.FailedToRetrieve += delegate
    {
        view_.Status = "Failed to retrieve podcast at " + view_.Source;
    };
}

L'implémentation de PodcastSubscription étant acquise, nous allons nous préoccuper de PodcastSubscriptionView et de XmlFetcher. Pour PodcastSubscriptionView, la cause est vite entendue. Conformément à nos choix précédents, à savoir qu'il serait lourd et inutile d'automatiser un test unitaire de la partie purement graphique, nous nous contentons de rattacher la propriété Status à un widget de label :

string IPodcastSubscriptionView.Status {
    set {
        StatusLabel.Text = value;
    }
}

Pour XmlFetcher, nous restons également cohérents. Nous allons tester la levée de l'évènement FailedToRetrieve sur une erreur d'accès à un document XML. Prenons pour commencer un cas simplissime : le serveur auquel nous tentons d'accéder n'existe pas :

[Test]
public void NetworkFailureOnNoServer()
{
    IXmlFetcher fetcher = new XmlFetcher();
    bool failedToRetrieve = false;
    fetcher.FailedToRetrieve += delegate
    {
        failedToRetrieve = true;
    };
    fetcher.Source = "http://localhost:8086/podcast.xml";
    Assert.IsTrue(failedToRetrieve,"retrieve should have failed");
}

Le test échoue par la levée d'une System.Net.Sockets.SocketException. Parfait. Pour contrer cela, je rajoute une protection contre les exceptions dans IXmlFetcher.Source :

string IXmlFetcher.Source {
  set {
    try
    {
      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);
    }
    catch( Exception )
    {
    }
  }
}

Je n'ai désormais plus de levée d'exception et je constate, avec plaisir, que mon assertion échoue. Je rajoute la levée d'évènement et tout rentre dans l'ordre :

catch( Exception )
{
  FailedToRetrieve(this,null);
}

A ce moment là, je me demande : est-ce utile de définir des tests supplémentaires pour XmlFetcher afin de tester les divers cas d'erreurs que je pourrais imaginer : erreur http (403 pour accès interdit, 404 pour document non trouvé, ...), invalidité du document XML (si on donnait l'URL d'un document qui n'est pas du XML) ?
Etant donné que je tiens à faire dans la simplicité et que je sais que pour signaler toutes ces erreurs les classes de System.Net et de System.Xml lèveront une exception quelconque, je décide de m'en passer. Tout en m'autorisant, bien sûr, à changer d'avis ultérieurement si le besoin s'en faisait sentir.

Nous voila donc avec une application qui fonctionne et que nous pouvons à nouveau présenter à notre client. On appréciera à cet instant les bienfaits de la méthode de développement employée : puisque nous avions déjà rassemblé nos composants (alors qu'ils étaient plus simples) en un exécutable opérationnel, nous n'avons pas à subir de qulconques problèmes d'intégration.

L'utilisation normale de l'application fonctionne toujours : Exécution

Et en cas de saisie erronée, l'erreur est correctement affichée : Erreur

Nous pouvons livrer le logiciel et enchainer, dans le prochain épisode, avec l'implémentation d'un nouveau scénario.
Pour ceux qui seraient intéressés, le projet complet est disponible en annexe de ce billet.

Pour terminer, un petit mot sur l'émergence de la conception : aucune nouvelle classe n'a été rajoutée pour traiter le scénario alternatif des cas d'erreur, le seul changement notable étant le rajout de quelques élements dans les interfaces de communication entre nos composants. Je ne sais pas ce que vous en pensez mais, en ce qui me concerne, cela signifie que notre conception initiale, aussi incomplete soit-elle et malgré son émergence très itérative, n'était probablement pas si mauvaise que ça.