|
| 1 | +# Développements Pilotés par les Tests |
| 2 | + |
| 3 | +Le développement piloté par les tests (alias **TDD** Test Driven Development) est un processus de développement logiciel qui s'appuie sur la répétition d'un cycle de développement très court : red-green-refactor. L'idée de ce processus est de transformer une fonctionnalité du code en un ou deux cas de test spécifiques, d'exécuter ces tests pour s'assurer qu'ils sont rouges (red), puis d'implémenter le code pour rendre ces tests verts (green). Une troisième étape consiste à refactoriser le code tout en gardant les tests verts. |
| 4 | + |
| 5 | + |
| 6 | + |
| 7 | +Le modèle de test recommandé est en quatre phases et est décrit dans cet [article de blog de Thoughtbot](https://robots.thoughtbot.com/four-phase-test) |
| 8 | + |
| 9 | +## Le mot le plus long |
| 10 | + |
| 11 | +Pratiquons le TDD avec un jeu simple que nous utiliserons jusqu'à la fin de la journée. Nous allons mettre en œuvre "Le mot le plus long", un jeu où, à partir d'une liste de neuf lettres, vous devez trouver le plus long mot anglais possible formé par ces lettres. |
| 12 | + |
| 13 | +Exemple : |
| 14 | + |
| 15 | +``` |
| 16 | +Grid: OQUWRBAZE |
| 17 | +Longest word: BAROQUE |
| 18 | +``` |
| 19 | + |
| 20 | +Le mot [`baroque`](https://en.wiktionary.org/wiki/baroque) est valide car il existe dans le dictionnaire anglais (même si son origine est française 🇫🇷 😋). |
| 21 | + |
| 22 | +Notez que le mot [`bower`](https://en.wiktionary.org/wiki/bower) est également valide. Le but ici n'est **pas** d'écrire un code qui trouve le mot le plus long, mais d'analyser la tentative du joueur humain et de juger si ce mot est valide ou non par rapport à la grille donnée ! |
| 23 | + |
| 24 | +### Une première approche |
| 25 | + |
| 26 | +Nous devons **décomposer** le problème en petits morceaux. Nous devons également trouver le bon niveau de **modélisation** par rapport au paradigme Orienté Objet. |
| 27 | + |
| 28 | +Dans le paradigme TDD, une question que nous nous posons toujours est : |
| 29 | + |
| 30 | +> Comment puis-je le tester ? |
| 31 | +
|
| 32 | +Se poser cette question signifie que vous devez considérer votre code comme une boîte noire. Il prendra certains paramètres en entrée et vous observerez la sortie, en la comparant à un résultat attendu. |
| 33 | + |
| 34 | +❓ Prenez quelques minutes pour réfléchir aux **deux fonctions principales** de notre jeu. |
| 35 | + |
| 36 | +<details><summary markdown="span">Voir la solution |
| 37 | +</summary> |
| 38 | + |
| 39 | +Nous avons besoin d'une première fonction pour construire une grille de neuf lettres aléatoires : |
| 40 | + |
| 41 | +```python |
| 42 | +def random_grid(): |
| 43 | + pass |
| 44 | +``` |
| 45 | + |
| 46 | +Nous avons aussi besoin d'une autre fonction qui, à partir d'une grille de neuf lettres, indique si un mot est valide : |
| 47 | + |
| 48 | +```python |
| 49 | +def is_valid(word, grid): |
| 50 | + pass |
| 51 | +``` |
| 52 | + |
| 53 | +</details> |
| 54 | + |
| 55 | +<br> |
| 56 | + |
| 57 | +❓ Comment pouvons-nous utiliser le paradigme Orienté Objet sur ce problème ? Encore une fois, prenez le temps d'y réfléchir. |
| 58 | + |
| 59 | +<details><summary markdown='span'>Voir la solution |
| 60 | +</summary> |
| 61 | + |
| 62 | +Nous pouvons créer une classe `Game` qui aura le modèle suivant : |
| 63 | + |
| 64 | +1. Générer et maintenir une liste aléatoire de 9 lettres |
| 65 | +1. Testez la validité d'un mot par rapport à cette grille |
| 66 | + |
| 67 | +</details> |
| 68 | + |
| 69 | +<br> |
| 70 | + |
| 71 | +### Démarrer le projet en TDD |
| 72 | + |
| 73 | +Maintenant que nous avons une meilleure idée de l'objet que nous voulons construire, nous pouvons commencer à écrire un test. Tout d'abord, créons un nouveau projet Python : |
| 74 | + |
| 75 | +```bash |
| 76 | +cd ~/code/<user.github_nickname> |
| 77 | +mkdir longest-word && cd $_ |
| 78 | +pipenv --python 3.8 |
| 79 | +pipenv install nose pylint --dev |
| 80 | +pipenv install --pre --dev astroid # Fix for https://github.com/PyCQA/pylint/issues/2241 |
| 81 | + |
| 82 | +touch game.py |
| 83 | +mkdir tests |
| 84 | +touch tests/test_game.py |
| 85 | + |
| 86 | +code . |
| 87 | +``` |
| 88 | + |
| 89 | +Créons notre classe de test, héritant de [`unittest.TestCase`](https://docs.python.org/3.8/library/unittest.html#basic-example) |
| 90 | + |
| 91 | +```python |
| 92 | +# tests/test_game.py |
| 93 | +import unittest |
| 94 | +import string |
| 95 | +from game import Game |
| 96 | + |
| 97 | +class TestGame(unittest.TestCase): |
| 98 | + def test_game_initialization(self): |
| 99 | + new_game = Game() |
| 100 | + grid = new_game.grid |
| 101 | + self.assertIsInstance(grid, list) |
| 102 | + self.assertEqual(len(grid), 9) |
| 103 | + for letter in grid: |
| 104 | + self.assertIn(letter, string.ascii_uppercase) |
| 105 | + |
| 106 | +``` |
| 107 | + |
| 108 | +Lisez ce code. Si vous avez _des_ questions à son sujet, demandez à un professeur. Vous pouvez copier/coller ce code dans `tests/test_game.py`. |
| 109 | + |
| 110 | +Maintenant, il est temps de l'exécuter pour s'assurer que ces tests **échouent** : |
| 111 | + |
| 112 | +```bash |
| 113 | +nosetests |
| 114 | +``` |
| 115 | + |
| 116 | +Et ensuite ? Maintenant, vous devez **lire le message d'erreur**, et essayer de le **corriger**, seulement celui-ci (n'anticipez pas). Faisons le premier ensemble : |
| 117 | + |
| 118 | +```bash |
| 119 | +E |
| 120 | +====================================================================== |
| 121 | +ERROR: Failure: ImportError (cannot import name 'Game' from 'game' (/Users/seb/code/ssaunier/longest-word/game.py)) |
| 122 | +---------------------------------------------------------------------- |
| 123 | +Traceback (most recent call last): |
| 124 | + # [...] |
| 125 | + File ".../longest-word/tests/test_game.py", line 2, in <module> |
| 126 | + from game import Game |
| 127 | +ImportError: cannot import name 'Game' from 'game' (.../longest-word/game.py) |
| 128 | + |
| 129 | +---------------------------------------------------------------------- |
| 130 | +Ran 1 test in 0.004s |
| 131 | + |
| 132 | +FAILED (errors=1) |
| 133 | +``` |
| 134 | + |
| 135 | +Le message d'erreur est donc `ImportError : cannot import name 'Game' from 'game'`. Il ne trouve pas le type `Game`. |
| 136 | + |
| 137 | +❓ Comment pouvons-nous le résoudre ? |
| 138 | + |
| 139 | +<details><summary markdown='span'>Voir la solution |
| 140 | +</summary> |
| 141 | + |
| 142 | +Nous devons créer une classe `Game` dans le fichier `./game.py` : |
| 143 | + |
| 144 | +```python |
| 145 | +# game.py |
| 146 | +# pylint: disable=missing-docstring |
| 147 | + |
| 148 | +class Game: |
| 149 | + pass |
| 150 | +``` |
| 151 | + |
| 152 | +</details> |
| 153 | + |
| 154 | +<br> |
| 155 | + |
| 156 | +Exécutons à nouveau les tests : |
| 157 | + |
| 158 | +```bash |
| 159 | +nosetests |
| 160 | +``` |
| 161 | + |
| 162 | +Nous obtenons ce message d'erreur : |
| 163 | + |
| 164 | +``` |
| 165 | +E |
| 166 | +====================================================================== |
| 167 | +ERROR: test_game_initialization (test_game.TestGame) |
| 168 | +---------------------------------------------------------------------- |
| 169 | +Traceback (most recent call last): |
| 170 | + File ".../longest-word/tests/test_game.py", line 7, in test_game_initialization |
| 171 | + grid = new_game.grid |
| 172 | +AttributeError: 'Game' object has no attribute 'grid' |
| 173 | +
|
| 174 | +---------------------------------------------------------------------- |
| 175 | +Ran 1 test in 0.004s |
| 176 | +
|
| 177 | +FAILED (errors=1) |
| 178 | +``` |
| 179 | + |
| 180 | +🎉 NOUS PROGESSONS !!! Nous avons un **nouveau** message d'erreur : `AttributeError: 'Game' object has no attribute 'grid'`. |
| 181 | + |
| 182 | + |
| 183 | + |
| 184 | +### A votre tour ! |
| 185 | + |
| 186 | +Vous avez compris cette boucle de rétroaction rapide ? Nous exécutons le test, nous obtenons un message d'erreur, nous trouvons un moyen de le corriger, nous exécutons à nouveau le test et nous passons à un nouveau message d'erreur ! |
| 187 | + |
| 188 | +❓ Essayez d'implémenter le code de la classe `Game` pour faire passer ce test. Ne regardez pas encore la solution, essayez d'appliquer le TDD sur ce problème ! |
| 189 | + |
| 190 | +💡 Vous pouvez utiliser `print()` ou `import pdb; pdb.set_trace()`en association avec `nosetests -s`. |
| 191 | + |
| 192 | +<details><summary markdown='span'>Voir la solution |
| 193 | +</summary> |
| 194 | + |
| 195 | +Une des implémentations possibles est : |
| 196 | + |
| 197 | +```python |
| 198 | +# game.py |
| 199 | +# pylint: disable=missing-docstring |
| 200 | + |
| 201 | +import string |
| 202 | +import random |
| 203 | + |
| 204 | +class Game: |
| 205 | + def __init__(self): |
| 206 | + self.grid = [] |
| 207 | + for _ in range(9): |
| 208 | + self.grid.append(random.choice(string.ascii_uppercase)) |
| 209 | +``` |
| 210 | + |
| 211 | +</details> |
| 212 | + |
| 213 | +<br> |
| 214 | + |
| 215 | +## Vérifier la validité d'un mot |
| 216 | + |
| 217 | +Passons à la deuxième méthode de notre classe `Game`. |
| 218 | + |
| 219 | +Nous utilisons le **TDD**, ce qui signifie que nous devons écrire le test **en premier**. Pour le premier test, nous vous avons donné le code. |
| 220 | + |
| 221 | +❓ C'est à votre tour d'implémenter un test pour cette nouvelle méthode `is_valid(self, word)` ! Vous voyez, nous vous avons déjà donné la [signature](https://en.wikipedia.org/wiki/Type_signature#Method_signature) de la méthode... |
| 222 | + |
| 223 | +<details><summary markdown='span'>Voir la solution |
| 224 | +</summary> |
| 225 | + |
| 226 | +Une implémentation possible de ce test serait : |
| 227 | + |
| 228 | +```python |
| 229 | +# tests/test_game.py |
| 230 | + |
| 231 | +# [...] |
| 232 | + |
| 233 | + def test_empty_word_is_invalid(self): |
| 234 | + new_game = Game() |
| 235 | + self.assertIs(new_game.is_valid(''), False) |
| 236 | + |
| 237 | + def test_is_valid(self): |
| 238 | + new_game = Game() |
| 239 | + new_game.grid = list('KWEUEAKRZ') # Forcer la grille à un scénario de test : |
| 240 | + self.assertIs(new_game.is_valid('EUREKA'), True) |
| 241 | + self.assertEqual(new_game.grid, list('KWEUEAKRZ')) # S'assurer que la grille n'a pas été modifiée |
| 242 | + |
| 243 | + def test_is_invalid(self): |
| 244 | + new_game = Game() |
| 245 | + new_game.grid = list('KWEUEAKRZ') # Forcer la grille à un scénario de test : |
| 246 | + self.assertIs(new_game.is_valid('SANDWICH'), False) |
| 247 | + self.assertEqual(new_game.grid, list('KWEUEAKRZ')) # S'assurer que la grille n'a pas été modifiée |
| 248 | +``` |
| 249 | +</details> |
| 250 | + |
| 251 | +<br> |
| 252 | + |
| 253 | +Exécutez les tests pour vous assurer qu'ils ne passent pas : |
| 254 | + |
| 255 | +```bash |
| 256 | +nosetests |
| 257 | +``` |
| 258 | + |
| 259 | +❓ C'est à votre tour ! Mettez à jour l'implémentation de `game.py` pour que les tests passent ! |
| 260 | + |
| 261 | +<details><summary markdown='span'>Voir la solution |
| 262 | +</summary> |
| 263 | + |
| 264 | +Une implémentation possible est : |
| 265 | + |
| 266 | +```python |
| 267 | +# game.py |
| 268 | + |
| 269 | +# [...] |
| 270 | + |
| 271 | + def is_valid(self, word): |
| 272 | + if not word: |
| 273 | + return False |
| 274 | + letters = self.grid.copy() # Consume letters from the grid |
| 275 | + for letter in word: |
| 276 | + if letter in letters: |
| 277 | + letters.remove(letter) |
| 278 | + else: |
| 279 | + return False |
| 280 | + return True |
| 281 | +``` |
| 282 | + |
| 283 | +</details> |
| 284 | + |
| 285 | +<br> |
| 286 | + |
| 287 | + |
| 288 | +## Style |
| 289 | + |
| 290 | +Assurez-vous de rendre `pylint` content : |
| 291 | + |
| 292 | +```bash |
| 293 | +pipenv run pylint game.py |
| 294 | +``` |
| 295 | + |
| 296 | +Vous pouvez désactiver ces règles : |
| 297 | + |
| 298 | +```python |
| 299 | +# pylint: disable=missing-docstring |
| 300 | +# pylint: disable=too-few-public-methods |
| 301 | +``` |
| 302 | + |
| 303 | +## C'est terminé ! |
| 304 | + |
| 305 | +Avant de passer à l'exercice suivant, sauvegardez votre avancement avec ce qui suit : |
| 306 | + |
| 307 | +```bash |
| 308 | +cd ~/code/<user.github_nickname>/reboot-python |
| 309 | +cd 02-Best-Practices/02-TDD |
| 310 | +touch DONE.md |
| 311 | +git add DONE.md && git commit -m "02-Best-Practices/02-TDD done" |
| 312 | +git push origin master |
| 313 | +``` |
0 commit comments