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.
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
1. Créer un nouveau projet de tests unitaires
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 »
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é.
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.
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 :
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
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.
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
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 :
[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 :
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 !
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 :
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 :
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.
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 » :
Exécution du test et refactoring
public class Basket { public decimal Price { get; private set; } public void AddBook(Book book) { throw new NotImplementedException(); } }
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 => 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
Ecriture du scénario
Rappelez-vous qu’un scénario doit décrire un exemple factuel, alimenté de données.
Implémentation du scénario
Exécution du scénario et refactoring
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.
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
And I add a book of volume 2 to basket
Intéressant, mais pourquoi 15.20€ ?
Une réduction de 5% sur deux livres à 8 euros donne : 2 * 8 * 0.95 = 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».
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 ».
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.
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€
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
Ecriture des scénarios
Exécution du scénario et refactoring
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% »
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.
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); } }
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:
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é.
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 :
- Commencer par écrire votre scénario
- 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.
- 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.
- 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 » !
- 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.
;)
#Spoil: Avec le TDD et le BDD, le développeur peut enfin consacrer son temps à sa vraie mission : comprendre le DDD!
RépondreSupprimerTout à 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