TDD sur page blanche

Après quelques considération d'ordre général sur la conception émergente, il est temps d'entrer dans le vif du sujet. Comme prévu, nous allons essayer de faire émerger la "conception" du code que nous allons écrire. Comme nous en sommes au début du projet, nous n'avons aucune base sur laquelle ancrer notre code mais ce n'est pas grave. L'essentiel est de faire un peu de conception sur papier -- suffisamment pour démarrer l'écriture d'un premier test. Le TDD fera le reste. Le premier scénario à implémenter est "L'utilisateur s'abonne à un flux de podcast et visualise la liste de ses éléments". Nous allons donc commencer par en proposer une représentation simple sous forme de diagramme de séquence, puis, nous implémenterons la logique de ce scénario à travers un ensemble de composants mis en évidence par le diagramme.

Pour un utilisateur, s'abonner à un podcast, c'est donner une URL source permettant de l'identifier. Le contenu associé à cette URL est un document XML qui devra être analysé pour déterminer la liste des éléments du podcast que l'utilisateur veut visualiser.

Rappelons quelques principes exprimés précédemment : minimisation des responsabilités des composants interagissant avec le monde extérieur et faible couplage entre composants. Nous définissons donc 2 composants responsables de interactions externes : view pour l'interaction utilisateur et xml pour l'interaction avec la récupération du contenu XML d'un podcast. Ces composants étant minimisés, le scénario sera déroulé dans un 3ème composant subscription qui en implémentera la logique. Scénario 1

La situation présente est une configuration typique où une injection de dépendance nous permet d'implémenter un couplage faible. Nous avons subscription qui dépend de 2 fournisseurs view et xml. Nous démarrons donc l'écriture du test correspondant (où l'on peut essayer d'avoir des noms plus explicites que ceux utilisés en première approche) :

Mockery mocks = new Mockery();
IPodcastSubscriptionView mockView = mocks.NewMock<IPodcastSubscriptionView>();
IXmlFetcher mockFetcher = mocks.NewMock<IXmlFetcher>();
PodcastSubscription subscription = new PodcastSubscription(mockView,mockFetcher);

Je n'ai pas encore abordé la partie outillage sous-jacente à tout développement TDD. J'utilise l'IDE SharpDevelop qui a le notable avantage de bien intégrer NUnit pour l'écriture de tests unitaires. Je rajoute à cela NMock pour faciliter l'écriture des bouchons. La connaissance de ce genre d'outils est bien évidemment un pré-requis pour la pratique efficace du TDD.

Revenons au test. Pour qu'il compile, il est nécessaire de créer les 2 interfaces IPodcastSubscriptionView et IXmlFetcher de nos 2 composants d'interaction externe. Pour l'instant, elles peuvent rester vides. Il faut aussi démarrer notre composant de souscription PodcastSubscription. La première partie du scénario consiste à écouter les évènements de changement d'URL source pour le podcast et à déclencher le requête de document XML correspondante. La gestion des évènements au niveau de NMock étant assez succinte, nous allons utiliser une classe d'aide MockEvent pour intégrer la levée d'évènements à nos bouchons. Le test devient :

Mockery mocks = new Mockery();

IPodcastSubscriptionView mockView = mocks.NewMock<IPodcastSubscriptionView>();
MockEvent newSourceEvent = new MockEvent();
Stub.On(mockView).EventAdd("NewSource", Is.Anything).Will(MockEvent.Hookup(newSourceEvent));
Stub.On(mockView).GetProperty("Source").Will(Return.Value("http://someserver/podcast.content.xml"));

IXmlFetcher mockFetcher = mocks.NewMock<IXmlFetcher>();
Expect.Once.On(mockFetcher).SetProperty("Source").To("http://someserver/podcast.content.xml");

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

Nous définissons ainsi un évènement NewSource sur IPodcastSubscriptionView et une propriété Source qui renvoie l'URL attendue. La valeur de la source aurait pu être passée en paramètre de l'évènement mais l'atomicité des opérations est en général un gage de simplicité : nul besoin d'introduire d'éventuelles variables intermédiaires là où il n'y en a pas besoin. Par ailleurs, nous déclarons également une propriété Source sur IXmlFetcher pour déclencher la récupération du document XML lors de sa modification. Pour que le test compile, nos interfaces nécessitent de légères modifications :

public interface IPodcastSubscriptionView
{
  event EventHandler NewSource;
  string Source { get; }
}
public interface IXmlFetcher
{
  string Source { set; }
}

Le test compile mais ne réussit pas. N'ayant encore rien écrit dans notre PodcastSubscription, le contraire aurait été étonnant et, d'ailleurs, source de méfiance : il faut toujours avoir un test qui ne réussit pas avant de le faire réussir. Si l'on ne se tient pas à cette règle, on prend le risque d'écrire des tests qui ne vérifient rien du tout. Pour que le test réussise, il suffit de rajouter :

public class PodcastSubscription
{
  IPodcastSubscriptionView view_;
  IXmlFetcher fetcher_;

  public PodcastSubscription(IPodcastSubscriptionView view,IXmlFetcher fetcher)
  {
    view_ = view;
    fetcher_ = fetcher;
    view_.NewSource += delegate
    {
      fetcher_.Source = view_.Source;
    };
  }
}

La 2ème partie du scénario consiste à attendre la disponibilité du document XML, signalée par un évènement en provenance de IXmlFetcher puis à analyser ce document pour transmettre la liste des éléments du podcast à IPodcastSubscriptionView. Le test devient :

Mockery mocks = new Mockery();

IPodcastSubscriptionView mockView = mocks.NewMock<IPodcastSubscriptionView>();
MockEvent newSourceEvent = new MockEvent();
Stub.On(mockView).EventAdd("NewSource", Is.Anything).Will(MockEvent.Hookup(newSourceEvent));
Stub.On(mockView).GetProperty("Source").Will(Return.Value("http://someserver/podcast.content.xml"));

IXmlFetcher mockFetcher = mocks.NewMock<IXmlFetcher>();
MockEvent retrievedEvent = new MockEvent();
Stub.On(mockFetcher).EventAdd("Retrieved", Is.Anything).Will(MockEvent.Hookup(retrievedEvent));

Expect.Once.On(mockFetcher).SetProperty("Source").To("http://someserver/podcast.content.xml");
Expect.Once.On(mockView).SetProperty("Items");
PodcastSubscription subscription = new PodcastSubscription(mockView,mockFetcher);
newSourceEvent.Raise();
retrievedEvent.Raise();
mocks.VerifyAllExpectationsHaveBeenMet();

Ce test ne vérifie rien quant au contenu du document XML par rapport à la liste d'éléments fournie à IPodcastSubscriptionView mais, pour l'instant, on va s'en contenter : procédons par petits pas. Pour que le test compile, il faut modifier les interfaces de nos composants en rajoutant la levée de l'évènement Retrieved par IXmlFetcher et le positionnement de Items sur IPodcastSubscriptionView :

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

On notera l'introduction d'une nouvelle interface IPodcastItem. En effet, pour transmettre la liste des éléments du podcast à IPodcastSubscriptionView, nous avons besoin d'une représentation individuelle de chaque élément. Dans l'état actuel de notre test, cette interface peut rester vide. On notera également l'utilisation d'un liste générique du framework .NET. Ce choix semble le plus simple et est conforté par la possibilité d'utiliser cette liste générique comme DataSource d'un widget : il est souvent payant de rester dans les pratiques usuelles de la technologie utilisée. Le test compile et échoue. Pour le faire réussir, il faut, à nouveau, adapter notre PodcastSubscription en rajoutant dans le constructeur :

fetcher_.Retrieved += delegate
{
  view_.Items = new System.Collections.Generic.List<IPodcastItem>();
};

Les flux d'évènements du scénario sont maintenant implémentés et testés mais il nous manque encore la définition d'un cas réel de document XML. Pour cela, adaptons le test en simulant le renvoi d'un document XML de type podcast :

System.Xml.XmlDocument mockXml = new XmlDocument();
mockXml.LoadXml(
@"<?xml version='1.0' encoding='utf-8'?>
  <rss version='2.0'>
    <channel>
      <title>Test1 Channel</title>
      <item>
        <title>Test1 Item 1</title>
        <enclosure length='222' url='http://agilitateur.azeau.com/TDPC/Test1-Item1.mp3' type='audio/mpeg'/>
      </item>
      <item>
        <title>Test1 Item 2</title>
        <enclosure length='333' url='http://agilitateur.azeau.com/TDPC/Test1-Item2.mp3' type='audio/mpeg'/>
      </item>
    </channel>
  </rss>"
);
Stub.On(mockFetcher).GetProperty("Response").Will(Return.Value(mockXml));

Comme d'habitude, adaptons l'interface en conséquence en prenant bien garde à respecter les règles d'écriture .NET concernant les types concrets dans les interfaces.

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

Pour ce qui est de vérifier que la liste d'éléments fournie à IPodcastSubscriptionView correspond bien au contenu du document XML, on peut, soit utiliser les outils de vérification de NMock, mais il faudrait alors écrire un peu de code spécifique pour contrôler le contenu de la liste, soit exposer la liste en question au niveau de PodcastSubscription et d'utiliser de simples assertions NUnit. Dans le contexte actuel, la 2ème solution me parait la plus expressive :

Expect.Once.On(mockView).SetProperty("Items").To(Is.NotNull);
//...
Assert.AreEqual(2,subscription.Items.Count,"wrong subscription.Items.Count");
Assert.AreEqual("Test1 Item 1",subscription.Items[0].Title);
Assert.AreEqual("http://agilitateur.azeau.com/TDPC/Test1-Item1.mp3",subscription.Items[0].URL);
Assert.AreEqual("Test1 Item 2",subscription.Items[1].Title);
Assert.AreEqual("http://agilitateur.azeau.com/TDPC/Test1-Item2.mp3",subscription.Items[1].URL);

Pour réussir ce test, il faut réaliser l'analyse du document XML :

public class PodcastSubscription
{
  //...
  System.Collections.Generic.List<IPodcastItem> items_;

  public PodcastSubscription(IPodcastSubscriptionView view,IXmlFetcher fetcher)
  {
    //...
    fetcher_.Retrieved += delegate
    {
      ParseXMLResponseIntoItemList(fetcher_.Response);
      view_.Items = items_;
    };
  }

  public void ParseXMLResponseIntoItemList(IXPathNavigable response)
  {
    items_ = new System.Collections.Generic.List<IPodcastItem>();
    XPathNodeIterator responseIterator = response.CreateNavigator().Select("//item");
    while( responseIterator.MoveNext() )
    {
      items_.Add(
        new PodcastItem(
          responseIterator.Current.SelectSingleNode("title").Value,
          responseIterator.Current.SelectSingleNode("enclosure/@url").Value
        )
      );
    }
  }

  public System.Collections.Generic.IList<IPodcastItem> Items
  {
    get { return items_; }
  }

}

Sans oublier de mettre à jour IPodcastItem et d'en définir une implémentation triviale PodcastItem :

public interface IPodcastItem
{
  string Title { get; }
  string URL { get; }
}

Voilà donc notre premier scénario au complet et qui fonctionne ! Le diagramme statique de conception qui en découle est le suivant : Conception

Il n'a rien d'extraordinaire mais on pourra toujours apprécier sa simplicité. Dans le prochain épisode, nous essaierons d'implémenter les morceaux manquants pour les assembler en un exécutable entièrement fonctionnel.