mercredi 2 octobre 2013

BDD : l'exemple du Kata Potter


Dans cet article, je vais présenter l’utilisation de la méthodologie « Behavior Driven Development » (BDD) au travers d’un exemple très connu : le Kata Potter.

J’effectuerais cet exemple en C#, sous Visual Studio 2012 et en utilisant le framework SpecFlow.
Cet exemple a pour but d’illustrer la puissance du BDD grâce à l’écriture de scénarios très clairs qui amèneront l’implémentation de l’exemple. Le but n’est pas de faire un design de classe parfait mais d’avoir une approche pédagogique.

Pour une vision plus large du BDD, vous pouvez vous reportez à ce lien.
Cet article nécessite une connaissance minimale de C#.
Vous trouverez une archive avec l’ensemble du code source du kata sur Github à ici : https://github.com/pierregillon/katapotter.

Introduction au framework SpecFlow
Le framework SpecFlow est intégré au sein de Visual Studio et permet l’écriture de scénarios selon les mots clés « Given », « When » et « Then ». Ces scénarios représentent un besoin fonctionnel et génèrent des tests automatisés. Il permet aussi de créer des jeux de données appelés aussi exemples, qui permettent de répéter un scénario avec différents paramètres d’entré.

Mise en place de l’architecture technique
Voici la liste des étapes pour mettre en place l’architecture technique :

1.     Créer un nouveau projet de tests unitaires


2.     Ajouter le framework Specflow
Pour ce faire, vous avez besoin du gestionnaire de packages Nuget. Si vous ne l’avez pas encore, reportez-vous à ce lien. Une fois installé, allez dans  « Tools » -> « Library and package manager » -> « Manage Nuget packages for Solution » puis rechercher « Specflow » et appliquez le à votre projet.


3.     Installer l’Addon Specflow de Visual Studio.
Pour intégrer SpecFlow dans VS, vous pouvez le télécharger depuis « Tools » -> « Extensions and updates » et rechercher « specflow ».


4.      Corriger le framework de tests utilisé.
Par défaut, Specflow utilise le framework de test NUnit. Si vous souhaitez le changer et utiliser par exemple le framework MSTest, il vous faut modifier le fichier de configuration de l’application (App.config) et ajouter la ligne suivante :

<configuration>
<configSections>
<section name="specFlow" type="TechTalk.SpecFlow.Configuration.ConfigurationSectionHandler, TechTalk.SpecFlow" />
</configSections>
<specFlow>
<unitTestProvider name="MsTest" />
</specFlow>
</configuration>

Une fois ces étapes effectuées, vous êtes prêt pour commencer l’écriture des scénarios.

Enoncé: le Kata Potter
Once upon a time there was a series of 5 books about a very English hero called Harry. (At least when this Kata was invented, there were only 5. Since then they have multiplied) Children all over the world thought he was fantastic, and, of course, so did the publisher. So in a gesture of immense generosity to mankind, (and to increase sales) they set up the following pricing model to take advantage of Harry's magical powers.

One copy of any of the five books costs 8 EUR. If, however, you buy two different books from the series, you get a 5% discount on those two books. If you buy 3 different books, you get a 10% discount. With 4 different books, you get a 20% discount. If you go the whole hog, and buy all 5, you get a huge 25% discount.

Note that if you buy, say, four books, of which 3 are different titles, you get a 10% discount on the 3 that form part of a set, but the fourth book still costs 8 EUR.
Potter mania is sweeping the country and parents of teenagers everywhere are queuing up with shopping baskets overflowing with Potter books. Your mission is to write a piece of code to calculate the price of any conceivable shopping basket, giving as big a discount as possible.

Règles réduites
D’après l’énoncé, voici les règles réduites que l’on peut extraire :
  • Un livre coûte 8 euros
  • Les livres peuvent être acheté en groupe afin d’obtenir des réductions :
    • L’achat de 1 livre n’apporte aucune réduction
    • L’achat de 2 livres différents apporte une réduction de 5%
    • L’achat de 3 livres différents apporte une réduction de 10%
    • L’achat de 4 livres différents apporte une réduction de 20%
    • L’achat de 5 livres différents apporte une réduction de 25%
  • Lors de l’achat d’un ensemble de livres, et pour avoir la plus petite réduction, ces livres sont regroupés dans différents lots auxquels on applique la réduction appropriée.
Remarque :
Pour ceux qui connaissent ce Coding Dojo, nous sauterons la partie de regroupement optimum des livres pour atteindre le prix le plus bas. Nous partons du principe que les livres sont regroupés par nombre maximum de différence.
Par exemple : l’achat de 2 x tome 1, 2 x tome 2, 2 x tome 3, 1 x tome 4 et 1 x tome 5 donne deux regroupements : un groupe [ 1, 2, 3, 4, 5 ] et [ 1, 2, 3 ].


Préparation du fichier « .feature »

Tout d’abord, nous devons préparer le fichier « .feature ». Une fois SpecFlow installé, faites un clic droit sur le projet -> « Add … » -> « New item » puis sélectionner « SpecFlow Feature File » :



Ce fichier permet de définir une fonctionnalité. On trouve deux parties à ce fichier :
  • L’entête : elle permet de décrire la fonctionnalité selon les 3 propositions : « En tant que », « Je souhaite », « Pour ». On retrouve ces concepts au sein de la méthode Scrum lors de la description d’une UserStory.
  • Les scénarios : ce sont l’ensemble des critères d’acceptations qui permettent de savoir si la fonctionnalité est valide. On peut définir autant de scénarios que l’on souhaite ainsi que des exemples de jeu de données. Un scénario représente un cas d’utilisation réel et non abstrait de la fonctionnalité.
Pour l’exemple, j’ai appelé le fichier « BasketPriceCalculation.feature ». Il regroupera comme son nom l’indique, l’ensemble des scénarios pour le calcul du prix du panier d’achat.
Dans l’entête, nous pouvons décrire la fonctionnalité de la manière suivante :

Feature: BasketPriceCalculationFeature
       In order to buy Harry Potter books
       As customer
       I want to know the price of my basket

Remarque :
Le framework Specflow fonctionne mal en français notamment à cause des apostrophes utilisés dans des propositions comme « j’ai », « l’utilisateur », etc. Ces caractères sont utilisés comme délimiteurs de variables, ce qui pose, du coup, des problèmes. Pour la suite de l’exemple, l’ensemble des fonctionnalités et scénarios seront écrits en anglais. Il est possible de réaliser ce Kata en français mais l’écriture des scénarios devient un peu moins élégante.

Nous allons maintenant écrire différents scénarios et les implémenter afin de répondre au mieux à l’énoncé. Nous allons piloter le développement de l’application par le comportement attendu de l’énoncé. Chaque fonctionnalité sera détaillée en 3 étapes : écriture du scénario, implémentation du scénario et exécution du test et refactoring.

Définition d’un livre

Ecriture du scénario
Le premier scénario à implémenter est :

« Un livre coûte 8 euros  »

On définit ici la première notion la plus importante : le livre.
Grâce aux deux mots clés « Given » et « Then », on peut décrire cette proposition de la manière suivante :

Scenario: Definition of a book
Given A book
Then The book price is 8 euros

C’est aussi simple que ça.

La proposition « Given A book » représente une précondition alors que la proposition « Then The book price is 8 euros » représente une post condition.
La phrase située à droite de « Scenario: » est le titre du scénario. C’est ce qui apparaitra dans l’explorateur de tests.

Comme c’est le premier scénario, nous devons générer le fichier « Steps » qui regroupera le code généré. Pour ce faire, faites un clic droit dans le fichier « .feature » ->  « Generate Step Definition ». La fenêtre suivante s’affiche : 


Vous pouvez cliquer sur « Generate » et indiquez ensuite le chemin vers votre projet de tests. Le fichier est ajouté à votre solution :


Votre fichier « Steps » contient la définition des méthodes à implémenter : 
    public class BasketPriceCalculationFeatureSteps
    {
        [Given(@"A book")]
        public void GivenABook()
        {
            ScenarioContext.Current.Pending();
        } 
        
        [Then(@"The book price is (.*) euros")]
        public void ThenTheBookPriceIsEuros(decimal expectedPrice)
        {
            ScenarioContext.Current.Pending();
        }
    }
La magie opère. Nous retrouvons les phrases du scénario que nous avons écrit dans les attributs des différentes méthodes générées (ainsi que dans leur nom).
La méthode ScenarioContext.Current.Pending(); signifie que le test n’est pas implémenté et qu’il sera « ignoré ». On remarque que les méthodes générées peuvent être vides ou avec paramètres :
  • La méthode « GivenABook » ne prend aucun paramètre.
  • La méthode « ThenTheBookPriceIsEuros»  prend un paramètre de type entier.
Si l’on lance les tests, on obtient un test jaune qui correspond à un test « ignoré ».
Remarque :
Il faut faire très attention à la manière dont on écrit les scénarios. Chaque phrase écrite génère un code qui lui est associée. Les guillemets [ « » ] ou apostrophe [ ‘’ ] sont considérés comme encadrant une variable. La méthode générée devient donc différente. Dans notre cas, l’utilisation d’un nombre au milieu d’une phrase ajoute automatiquement un paramètre dans la méthode associée.

Implémentation du scénario
Maintenant que nous avons les méthodes, comment les implémenter ?

Le mot clé « Given » signifie qu’il faut initialiser un objet dans le test et le mot clé « Then » signifie qu’il faut tester l’état d’un objet initialisé.

La méthode « GivenABook » doit donc créer un livre et l’enregistrer. La méthode « ThenTheBookPriceIsEuros» doit comparer une propriété « Price » du livre initialisé. Le « The » de « The Book Price » fait bien référence au précédent livre.

Ces méthodes sont appelées dans l’ordre du scénario par SpecFlow. La méthode « GivenABook » précède « ThenTheBookPriceIsEuros». Une question importante apparaît : comment partager de l’information entre les méthodes, ici le livre ?

De manière naturelle, on peut déclarer un attribut de la classe « Steps » pour sauvegarder le résultat de la méthode « GivenABook » pour ensuite l’utiliser dans « ThenTheBookPriceIsEuros».  De plus, la représentation d’un prix est souvent faite en nombre flottant. On peut donc changer le type du paramètre en decimal.

On obtient le code 
    [Binding]
    public class BasketPriceCalculationFeatureSteps
    {
        private Book _book;
 
        [Given(@"A book")]
        public void GivenABook()
        {
            _book = new Book();
        }

        [Then(@"The book price is (.*) euros")]
        public void ThenTheBookPriceIsEuros(decimal expectedPrice)
        {
            Assert.AreEqual(expectedPrice, _book.Price, "Le prix du livre est incorrect.");
        }
    }

Une fois le test écrit, on peut définir la classe Book avec une implémentation très simple uniquement dans le but de faire compiler le test :
public class Book
    {
        public decimal Price { get; private set; }
    }

Remarques :
De manière plus générale, pour des projets plus importants, on associe rarement un fichier « Feature » avec un fichier « Steps ». En fait, Specflow nous permet de réutiliser des propositions « Given », « When », « Then » présentent dans d’autres fichiers « Feature » :



Pour ce faire, on n’utilise plus de champs dans les classes « Steps », mais on ajoute les variables dans le ScenarioContext. Pour plus d'information, consultez ce lien.

Execution du test et refactoring
Bien entendu, si l’on lance le test, on obtient une magnifique couleur rouge :


En effet, par défaut le prix du livre est à 0 euros, ce qui fait échouer le test.
Pour le faire passer, rien de plus simple, il suffit d’initialiser la propriété « Price » du livre dans son constructeur :

    public class Book
    {
        public Book()
        {
            Price = 8;
        }

        public decimal Price { get; private set; }
    }


Aucun refactoring n’est à réaliser, nous avons implémenté notre premier test en BDD !

Scénario 2 : Réduction avec 1 livre
Le prochain scénario à implémenter est :
« L’achat de 1 livre n’apporte aucune réduction »
Une notion très importante ici est la notion d’achat.
Comment représenter ce concept et l’implémenter ? Une propriété booléenne « EstAchete » dans le livre ? Un objet « Achat » ?
Pour l’exemple, j’ai choisi de l’implémenter en utilisant le concept de panier d’achat. Il y a donc une notion d’ajout de livre au sein d’un panier d’achat.

Ecriture du scénario
Nous pouvons donc écrire le scénario suivant :

Scenario: Discount of 0% for 1 book
Given A basket
When I add a book to basket
Then The basket price is 8 euros

Parlant non ?
Maintenant, nous devons générer le code associé. Vous remarquez que la couleur des propositions non implémentée est violet. Pour les générer, cliquez sur une proposition par exemple « Given A basket » puis faites « Go To Definition ». Un écran vous présente la méthode générée et vous propose de la mettre dans le presse-papier :


En acceptant, la méthode est enregistrée et vous pourrez la coller directement dans le fichier « Steps ». Je recommande de générer et d’implémenter les propositions une à une. La règle du « baby steps » est très importante ici, car cela simplifie le problème et fait émerger doucement le design des classes. Si vous le souhaitez, vous pouvez aussi réutiliser l’écran « Generate Step Definition » comme vu dans le premier exemple.

Implémentation du scénario

GivenABasket

La méthode générée est :
        [Given(@"A basket")]
        public void GivenABasket()
        {
            ScenarioContext.Current.Pending();
        }
L’implémenter est aussi simple que « GivenABook ». Cela donne :
        private Basket _basket;

        [Given(@"A basket")]
        public void GivenABasket()
        {
            _basket = new Basket();
        }
WhenIAddABookToBasket

La méthode générée est :
        [When(@"I add a book to basket")]
        public void WhenIAddABookToBasket()
        {
            ScenarioContext.Current.Pending();
        }
Pour l’implémenter, il nous suffit d’ajouter le concept d’opération d’ajout d’un livre dans la classe « Basket ». Un exemple d’implémentation serait :
       [When(@"I add a book to basket")]
        public void WhenIAddABookToBasket()
        {
            var book = new Book();
            _basket.AddBook(book);
        }
ThenTheBasketPriceIsEuros
La méthode générée est :
        [Then(@"The basket price is (.*) euros")]
        public void ThenTheBasketPriceIsEuros(Decimal p0)
        {
            ScenarioContext.Current.Pending();
        }
L’implémenter est aussi simple que « ThenTheBookPriceIsEuros ». On ajoute un concept de prix « Price » dans l’objet « Basket » :
        [Then(@"The basket price is (.*) euros")]
        public void ThenTheBasketPriceIsEuros(decimal expectedBasketPrice)
        {
            Assert.AreEqual(expectedBasketPrice, _basket.Price, "Le prix du panier est incorrect.");
        }

Classe Basket
Pour faire compiler la classe de test, il faut bien sûr compléter l’implémentation de la classe « Basket » :
     public class Basket
    {
        public decimal Price { get; private set; }
        
        public void AddBook(Book book)
        {
            throw new NotImplementedException();
        }
    }

Exécution du test et refactoring
Une fois tout le scénario implémenté, nous pouvons lancer les tests. A la première exécution, on obtient bien sûr :


Il faut implémenter la méthode « AddBook » dans la classe « Basket ». On peut maintenant amener le concept « évident » de liste de livres au sein du panier d’achat :
    public class Basket
    {
        private readonly List<book> _books = new List<book>();

        public decimal Price { get; private set; }
        
        public void AddBook(Book book)
        {
            if (book == null) throw new ArgumentNullException("book");
            _books.Add(book);
        }
    }
Si on le relance le test :


En effet, le prix du panier d’achat ne tient pour l’instant pas compte des livres. Il faut modifier la propriété "Price" pour calculer la somme des prix des livres. On obtient :
    public class Basket
    {
        private readonly List<book> _books = new List<book>();

        public decimal Price
        {
            get { return _books.Sum(book =&gt; book.Price); }
        }

        public void AddBook(Book book)
        {
            if (book == null) throw new ArgumentNullException("book");
            _books.Add(book);
        }
    }
Et cette fois :


Scénario 3 : Réduction avec 2 livres
Le prochain scénario à implémenter est :
« L’achat de 2 livres différents apporte une réduction de 5% »
Après la notion d’achat, vient la notion de réduction. Une question importante se pose : qu’est-ce que « deux livres différents » ? La réponse est simple : ce sont deux livres de « tomes » différents (en anglais « volume »). D’après l’énoncé, nous avons 5 tomes différents, numéroté de 1 à 5.

Ecriture du scénario
Pour le scénario, nous devons prendre un exemple concret. Prenons 1 livre du tome 1 et un livre du tome 2, on obtient le scénario suivant :

Scenario: Discount of 5% for 2 different books
Given A basket
When I add a book of volume 1 to basket
       And I add a book of volume 2 to basket
Then The basket price is 15.20 euros

Intéressant, mais pourquoi 15.20€ ?
Une réduction de 5% sur deux livres à 8 euros donne : 2  * 8 * 0.95 = 15.20€.

Rappelez-vous qu’un scénario doit décrire un exemple factuel, alimenté de données.
Autre point intéressant : nous réutilisons des propositions écrites du scenario précèdent ! En effet, « Given A basket » et « Then The basket price is x euros » sont réutilisées. On le remarque car la couleur des propositions est noire. Il ne reste à implémenter que la proposition « When I add a book of volume x to basket».

Implémentation du scénario
Après la génération de code, on obtient :
        [When(@"I add a book of volume (.*) to basket")]
        public void WhenIAddABookOfVolumeToBasket(int p0)
        {
            ScenarioContext.Current.Pending();
        }

Une implémentation pourrait être :
        [When(@"I add a book of volume (.*) to basket")]
        public void WhenIAddABookOfVolumeToBasket(int volumeNumber)
        {
            var book = new Book { VolumeNumber = volumeNumber };
            _basket.AddBook(book);
        }

Avec la propriété « VolumeNumber » ajoutée dans la classe « Book ».

Exécution du scénario et refactoring
L’exécution des tests donne :



Effectivement, pour l’instant, notre implémentation ne calcul le prix du panier que par la somme des prix des livres qu’il contient. 
Pour résoudre ce problème, nous devons regrouper les livres par numéro de tome pour trouver combien sont différents et donc savoir si la réduction doit être appliquée. Nous allons donc modifier notre implémentation de la propriété « Price » de la classe « Basket » de la manière « naïve » suivante :
        public decimal Price
        {
            get
            {
                var price = _books.Sum(book => book.Price);
                var groupedBookByVolume = _books.GroupBy(book => book.VolumeNumber) .ToList();
                if (groupedBookByVolume.Count() == 2) {
                    return price * 0.95m;
                }
                return price;
            }
        }

Ainsi, en groupant les livres par numéro de tome, on peut savoir s’il faut appliquer une réduction. Les tests deviennent :

A retenir :
Un aspect très intéressant des scénarios écrits avec SpecFlow est la réutilisabilité. Pour l’exemple précédent, pour faire passer notre test, nous n’avons eu à implémenter qu’une seule méthode : « WhenIAddABookOfVolumeToBasket ». Les méthodes « GivenABook  » et « ThenTheBasketPriceIsEuros » ont été réutilisées.
De manière générale, si les scénarios sont bien écrits et réutilise les propositions déjà implémentées, leur vitesse d’implémentation va en s’accélérant. Ce qui est très puissant, c’est qu’un nouveau scénario de 200 lignes peut être implémenté par le développeur en 0 secondes

Scénario 4 : Réduction de 3 livres

Le prochain scénario à implémenter est :
« L’achat de 3 livres différents apporte une réduction de 10% »
Dans ce cas, aucune nouvelle notion ne vient s’ajouter. On peut écrire directement le nouveau scénario.

Ecriture du scénario
Le scénario est :

Scenario: Discount of 10% for 3 different books
Given A basket
When I add a book of volume 1 to basket
       And I add a book of volume 2 to basket
       And I add a book of volume 3 to basket
Then The basket price is 21.60 euros

On remarque qu’il n’y a aucune méthode à implémenter. Le scénario réutilise les 3 propositions déjà écrites. Pour les petits malins, nous avons bien : 3 * 8 * 0.9 = 21.60€

Exécution du scénario et refactor
Si l’on exécute le test, on obtient :


En effet, au sein de la classe « Basket », on ne gère que la réduction sur 2 livres. On peut donc ajouter une nouvelle condition dans la propriété « Price » :
        public decimal Price
        {
            get
            {
                var price = _books.Sum(book => book.Price);
                var groupedBookByVolume = _books.GroupBy(book => book.VolumeNumber).ToList();
                if (groupedBookByVolume.Count() == 2) {
                    return price*0.95m;
                }
                if (groupedBookByVolume.Count() == 3) {
                    return price*0.90m;
                }
                return price;
            }
        }

Si l’on ré-exécute :


Il est temps de faire du refactoring. On remarque que l’on compare toujours le nombre de livre groupé par une valeur et que nous en déduisons la réduction. 
Pourquoi ne pas faire un dictionnaire stockant en clé le nombre de livre différent et en valeur le montant de la réduction ? Ou mieux, pourquoi ne pas créer un concept de catalogue de réduction nous permettant d’obtenir une réduction par rapport à un nombre de livre différent ? Je choisis la seconde proposition :
    public class DiscountCatalog
    {
        private readonly Dictionary<int, decimal> _catalog = new Dictionary<int, decimal>
            {
                {1, 0},
                {2, 0.05m},
                {3, 0.10m},
            };

        public decimal GetDiscount(int numberOfDifferentArticles)
        {
            if (_catalog.ContainsKey(numberOfDifferentArticles) == false) {
                throw new Exception("No discount found for this number of different articles.");
            }
            return _catalog[numberOfDifferentArticles];
        }
    }
La classe « Basket » devient donc :
    public class Basket
    {
        private readonly List<Book> _books = new List<Book>();
        private readonly DiscountCatalog _discountCatalog = new DiscountCatalog();

        public decimal Price
        {
            get
            {
                var price = _books.Sum(book => book.Price);
                var numberOfDifferentBooks = _books.GroupBy(book => book.VolumeNumber).Count();
                return price*(1 - _discountCatalog.GetDiscount(numberOfDifferentBooks));
            }
        }

        public void AddBook(Book book)
        {
            if (book == null) {
                throw new ArgumentNullException("book");
            }
            _books.Add(book);
        }
    }

Si l’on relance les tests, tout est ok. Voilà un exemple d’implémentation plus satisfaisant.

Scénario 5 et 6 : Réduction de 4 et 5 livres
Les deux prochains scénarios à implémenter sont :
« L’achat de 4 livres différents apporte une réduction de 20% »
« L’achat de 5 livres différents apporte une réduction de 25% »

Ecriture des scénarios
On obtient deux scénarios :

Scenario: Discount of 20% for 4 different books
Given A basket
When I add a book of volume 1 to basket
       And I add a book of volume 2 to basket
       And I add a book of volume 3 to basket
       And I add a book of volume 4 to basket
Then The basket price is 25.60 euros


Scenario: Discount of 25% for 5 different books
Given A basket
When I add a book of volume 1 to basket
       And I add a book of volume 2 to basket
       And I add a book of volume 3 to basket
       And I add a book of volume 4 to basket
       And I add a book of volume 5 to basket
Then The basket price is 30.00 euros

De la même manière que pour le scénario 3, il n’y a aucun code à implémenter.

Exécution du scénario et refactoring
Pour faire passer ces deux tests, il suffit d’ajouter dans la classe « DiscountCatalog » la correspondance entre le nombre de livres différents 4 et 5 et leurs réductions réciproques :
  private readonly Dictionary<int, decimal> _catalog = new Dictionary<int, decimal>
            {
                {1, 0},
                {2, 0.05m},
                {3, 0.10m},
                {4, 0.20m},
                {5, 0.25m},
            };

Ainsi, on obtient :

Scénario 7 : Regroupement de livres

A ce point, nous avons effectué les 6 premières règles du Kata Potter, intégralement en BDD. Maintenant, viens la règle suivante :
« Lors de l’achat d’un ensemble de livres, et pour avoir la plus petite réduction, ces livres sont regroupés dans différents lots auxquels on applique la réduction appropriée. »
Une nouvelle notion apparait ici : la notion de « lots » de livres. Prenons un exemple pour mieux comprendre. Nous décidons d’acheter :
  • 1 livre du tome 1
  • 2 livres du tome 2
  • 2 livres du tome 3

Pour savoir quelles réductions appliquées, il faut regrouper ces livres dans des lots de livres différents afin d’avoir la plus haute réduction. En effet, plus le nombre de livres différents au sein d’un lot est important, plus la réduction est importante. Nous avons donc :
  • Un lot des tomes [ 1, 2, 3 ] : réduction de 10%
  • Un lot des tomes [ 2, 3 ] : réduction de 5%
Ecriture des scénarios
L’exemple peut s’écrire en BDD de la manière suivante :

Scenario: Discount of 20% for 4 different books
Given A basket
When I add a book of volume 1 to basket
       And I add a book of volume 2 to basket
       And I add a book of volume 2 to basket
       And I add a book of volume 3 to basket
       And I add a book of volume 3 to basket
Then The basket price is 25.60 euros

Même si écrire le scénario comme ci-dessus permet de réutiliser les propositions implémentées, on l’éloigne de son vrai but. Le scénario doit représenter un réel besoin métier et doit donc être lisible et concis. On peut regrouper les propositions d’achat de livre de même volume pour obtenir un scénario plus lisible :

Scenario: Discount of 1 set of 3 books and 1 set to 2 books
Given A basket
When I add 1 book(s) of volume 1 to basket
       And I add 2 book(s) of volume 2 to basket
       And I add 2 book(s) of volume 3 to basket
Then The basket price is 36.80 euros

Si l’on pose le calcul, on obtient bien : 3 * 8 * 0.90 + 2 * 8 * 0.95 = 36.80 €

Implémentation du scénario
Le code généré est :
        [When(@"I add (.*) book\(s\) of volume (.*) to basket")]
        public void WhenIAddBookSOfVolumeToBasket(int p0, int p1)
        {
            ScenarioContext.Current.Pending();
        }

On remarque que cette proposition ressemble beaucoup à la proposition « When I add a book of volume x to basket », déjà implémentée. Nous pouvons ici réutiliser la méthode « WhenIAddABookOfVolumeToBasket » pour implémenter notre nouvelle proposition :
        [When(@"I add (.*) book\(s\) of volume (.*) to basket")]
        public void WhenIAddBookSOfVolumeToBasket(int bookCount, int volumeNumber)
        {
            for (int i = 0; i < bookCount; i++) {
                WhenIAddABookOfVolumeToBasket(volumeNumber);
            }
        }

Exécution du scénario et refactoring
En exécutant les tests, on obtient :


Comment implémenter cette fonctionnalité ?
En fait, il faut pouvoir distribuer les livres du panier d’achat dans différents lots tout en gardant l’unicité d’un livre (via son numéro de tome).

Dans cet exemple, j’ai fait le choix de donner la responsabilité à la classe « Basket » de trouver le bon lot dans lequel ajouter le livre du panier. La classe « Basket » va foncièrement changer car elle va maintenant contenir une liste de « BookSet ». C’est cette classe qui contiendra désormais le code de calcul du prix avec la réduction.

La classe « Basket » devient :
    public class Basket
    {
        private readonly List<BookSet> _bookSets = new List<BookSet>();

        public decimal Price
        {
            get { return _bookSets.Sum(bookSet => bookSet.Price); }
        }

        public void AddBook(Book book)
        {
            if (book == null) {
                throw new ArgumentNullException("book");
            }
            var added = false;
            foreach (var bookSet in _bookSets) {
                if (bookSet.Contains(book) == false) {
                    bookSet.AddBook(book);
                    added = true;
                    break;
                }
            }
            if (added == false) {
                var bookSet = new BookSet();
                bookSet.AddBook(book);
                _bookSets.Add(bookSet);
            }
        }
    }

Le code de la méthode « AddBook » a pour responsabilité de trouver le bon lot dans lequel ajouter le livre. Si tous les lots contiennent déjà un tome du livre, alors la méthode crée un nouveau lot.
La classe « BookSet » ressemble beaucoup à l’ancienne implémentation de « Basket » :
    public class BookSet
    {
        private readonly List<Book> _books = new List<Book>();
        private readonly DiscountCatalog _discountCatalog = new DiscountCatalog();

        public decimal Price
        {
            get
            {
                var price = _books.Sum(book => book.Price);
                var numberOfDifferentBooks = _books.GroupBy(book => book.VolumeNumber).Count();
                return price*(1 - _discountCatalog.GetDiscount(numberOfDifferentBooks));
            }
        }

        public void AddBook(Book book)
        {
            if (book == null) {
                throw new ArgumentNullException("book");
            }
            _books.Add(book);
        }
        public bool Contains(Book book)
        {
            if (book == null) {
                throw new ArgumentNullException("book");
            }
            return _books.Contains(book);
        }
    }    

Si l’on relance les tests :


Cela ne passe toujours pas : le prix est invalide…

Mais c’est normal ! On vérifie s’il existe un lot contenant un exemplaire du tome, via la méthode « Contains ». Pour que cela fonctionne, il faut que deux livres de tome identique soit égaux ! Il faut donc surcharger la méthode « Equals » dans la classe « Book » :
    public class Book
    {
        public Book()
        {
            Price = 8;
        }

        public decimal Price { get; private set; }
        public int VolumeNumber { get; set; }

        public override bool Equals(object obj)
        {
            var book = obj as Book;
            if (book != null == false) {
                return base.Equals(obj);
            }
            return book.VolumeNumber == VolumeNumber;
        }
        public override int GetHashCode()
        {
            return VolumeNumber.GetHashCode();
        }
    }


Et maintenant :


Fini ?
Non, presque. Red, Green, … Refactor ! Le code de la méthode « AddBook » dans la classe « Basket » ne semble pas très propre. Nous pouvons séparer les responsabilités cette méthode en deux:

  • La récupération du premier lot de livre disponible
  • La création du lot si nécessaire puis l’ajout du livre

        public void AddBook(Book book)
        {
            if (book == null) {
                throw new ArgumentNullException("book");
            }
            var availableBookSet = GetFirstAvailableBookSet(book);
            if (availableBookSet == null) {
                availableBookSet = new BookSet();
                _bookSets.Add(availableBookSet);
            }
            availableBookSet.AddBook(book);
        }

        private BookSet GetFirstAvailableBookSet(Book book)
        {
            if (book == null) {
                throw new ArgumentNullException("book");
            }
            return _bookSets.FirstOrDefault(bookSet => bookSet.Contains(book) == false);
        }
Le code est propre et tous les BDD sont implémentés.
L’exercice est terminé.

Conclusion
Ce cours exercice a pour but de présenter l’utilisation du « Behavior Driven Developpement » sur un exemple concret : le Kata Potter. J’espère vous avoir fait constaté sa puissance notamment grâce au lien qu’il crée entre :
  • l’écriture de scénarios très clair, exprimant le besoin métier
  • l’implémentation du code de l’application via les tests générés
Cette  méthode permet de piloter le développement d’une application par le comportement qu’elle doit avoir. Les scénarios permettent de définir précisément ce comportement attendu avec des exemples afin de guider l’implémentation. Ces scénarios sont composés des mots clés « Given », « When », « Then » permettant d’écrire des propositions claires et concises. Le maître mot : la réutilisabilité : chaque proposition peut être réutilisée dans d’autres scénarios et même dans d’autres fichiers « Steps ».

Le BDD suit les 3 règles de base du TDD : Red, Green, Refactor. Les étapes sont :
  1. Commencer par écrire votre scénario
  2. Générer une à une les différentes phrases (propositions) du scénario dans le fichier « Steps » et tout en les implémentant au fur et à mesure.
  3. Créer vos objets avec des valeurs par défaut et ne vous concentrez sur le code métier qu’une fois le test rouge.
  4. Faites tout d’abord une lazy implémentation des objets testés (objets du domaine) puis faites du refactoring pour améliorer le code, tout en conservant les tests au vert. Le terme clé : « baby steps » !
  5. Une fois que le code est propre et fonctionnel, revenez à l’étape 1.
Actuellement, dans mon entreprise, j’utilise cette méthodologie tous les jours et j’en perçois l’utilité au quotidien. Nous somme en cycle Agile (Scrumban) et c’est au Product Owner d’écrire les scénarios. Ils constituent les spécifications de l’application. On évite ainsi l’écriture d’un fichier Word de 500 pages pour décrire l’application entière. On n’écrit les scénarios que pour des fonctionnalités à implémenter dans le sprint à venir. Ils en sont les critères d’acceptation et définissent le « terminé ». N’avez-vous jamais imaginé jouer, rejouer et rejouer des scénarios fonctionnels automatiquement ? De vous à moi, c’est génial au quotidien.

De manière générale, les tests unitaires sont essentiels pour créer une application robuste. Mais contrairement à d’autres processus de tests, ils permettent aussi de la rendre très souple, oserais-je dire … agile ? Le développeur à la possibilité de modifier du code existant avec énormément de sécurité. Stratégie de cache ? Optimisation ? Changement d’héritage ? Re-conception ? Autant de choix pouvant l’amener à recoder une grande quantité de classe. Avec les tests, il s’exécutera sans aucune inquiétude : ils seront là pour le guider et lui indiquer toute régression en temps réel.
En tant que développeur, avez-vous été une seule fois été serein lors de refactoring sur du code qui n’est pas de vous, la veille d’un déploiement ? Êtes-vous en total confiance lors de la modification des classes fondations de l’application, 2 heures avant une démonstration à un client ? Testez votre application et vous le serez.

Avec le TDD et le BDD, le développeur peut enfin consacrer son temps à sa vraie mission : créer, concevoir, imaginer, agencer, architecturer, philosopher … mais toujours avec un certain pragmatisme bien sûr.   ;)


2 commentaires:

  1. #Spoil: Avec le TDD et le BDD, le développeur peut enfin consacrer son temps à sa vraie mission : comprendre le DDD!

    RépondreSupprimer
    Réponses
    1. Tout à fait Ouarzy. Je dirais même plus que le DDD est complémentaire car il apporte une vision du métier épurée et ciblée. Le BDD permet à ce moment là d'exprimer les comportements des agrégats, des objets valeurs et des services du domaine. A voir dans un prochain post ;)

      Supprimer