Test-driven development (aka TDD) is a software development process that relies on the repetition of a very short development cycle: red-green-refactor. The idea of this process is to turn a requirement into one or a couple of specific test cases, run those tests to make sure they are red, then implementing the code to turn those tests green. A third step is to refactor the code while keeping the tests green.
The testing pattern encouraged is a four-phase one and well described in this blog article by Thoughtbot
Let's practise TDD with a simple game that we will use until the end of the day. We will implement "The Longest Word", a game where given a list of nine letters, you have to find the longest possible English word formed by those letters.
Example:
Grid: OQUWRBAZE
Longest word: BAROQUE
The word baroque
is valid as it exists in the English dictionary (even though its origin is French 🇫🇷 😋)
Note that the word bower
is also valid. The goal here is not to write code which finds the longest word, but to analyze a human player attempt and judge if this word is valid or not against the given grid!
We need to break down the problem in tiny pieces. We also need to find the right level of modelling against the Object-Oriented paradigm.
In the TDD paradigm, one question we always ask is:
How can I test this?
Asking this question means you need to think about your code like a black box. It will take some parameters in entry and you will observe the output, comparing them to an expected result.
❓ Take a few minutes to think about the two main functions of our game.
View solution
We need a first function to compute a grid of nine random letters:
def random_grid():
pass
We need another function which, given a nine letter grid, tells if a word is valid:
def is_valid(word, grid):
pass
❓ How can we use the Object-Oriented paradigm on this problem? Again, take some time to think about it.
View solution
We can create a Game
class which will have the following blueprint:
- Generate and hold a 9-letter random list
- Test the validity of a word against this grid
Now that we have a better idea of the object we want to build, we can start writing a test. First of all, let's create a new Python project:
cd ~/code/<user.github_nickname>
mkdir longest-word && cd $_
pipenv --python 3.8
pipenv install nose pylint --dev
pipenv install --pre --dev astroid # Fix for https://github.com/PyCQA/pylint/issues/2241
touch game.py
mkdir tests
touch tests/test_game.py
code .
Let's set up our test class, inheriting from unittest.TestCase
# tests/test_game.py
import unittest
import string
from game import Game
class TestGame(unittest.TestCase):
def test_game_initialization(self):
new_game = Game()
grid = new_game.grid
self.assertIsInstance(grid, list)
self.assertEqual(len(grid), 9)
for letter in grid:
self.assertIn(letter, string.ascii_uppercase)
Read this code. If you have any question about it, ask a teacher. You can copy/paste this code to tests/test_game.py
.
Now it's time to run it first to make sure those tests are failing:
nosetests
What next? Now you should read the error message, and try to fix it, and only this one (don't anticipate). Let's do the first one together:
E
======================================================================
ERROR: Failure: ImportError (cannot import name 'Game' from 'game' (/Users/seb/code/ssaunier/longest-word/game.py))
----------------------------------------------------------------------
Traceback (most recent call last):
# [...]
File ".../longest-word/tests/test_game.py", line 2, in <module>
from game import Game
ImportError: cannot import name 'Game' from 'game' (.../longest-word/game.py)
----------------------------------------------------------------------
Ran 1 test in 0.004s
FAILED (errors=1)
OK so the error message is ImportError: cannot import name 'Game' from 'game'
. It can't find a Game
type.
❓ How can we fix it?
View solution
We need to create a Game
class in the ./game.py
file:
# game.py
# pylint: disable=missing-docstring
class Game:
pass
Let's run the tests again:
nosetests
We get this error message:
E
======================================================================
ERROR: test_game_initialization (test_game.TestGame)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../longest-word/tests/test_game.py", line 7, in test_game_initialization
grid = new_game.grid
AttributeError: 'Game' object has no attribute 'grid'
----------------------------------------------------------------------
Ran 1 test in 0.004s
FAILED (errors=1)
🎉 PROGRESS!!! We have a new error message: AttributeError: 'Game' object has no attribute 'grid'
.
Did you get this quick feedback loop? We run the test, we get an error message, we figure out how to fix only this, we run the test again and we move to a new error message!
❓ Try to implement the Game
code to make this test pass. Don't look at the solution just yet, try to apply TDD on this problem!
💡 You can use print()
or import pdb; pdb.set_trace()
in combination with nosetests -s
.
View solution
One possible implementation is:
# game.py
# pylint: disable=missing-docstring
import string
import random
class Game:
def __init__(self):
self.grid = []
for _ in range(9):
self.grid.append(random.choice(string.ascii_uppercase))
Let's move to the second method of our Game
class.
We use TDD, which means that we need to write the test first. For the first test, we gave away the code.
❓ It's your turn to implement a test for this new is_valid(self, word)
method! See, we already gave you the method signature...
View solution
A possible implementation of the test would be:
# tests/test_game.py
# [...]
def test_empty_word_is_invalid(self):
new_game = Game()
self.assertIs(new_game.is_valid(''), False)
def test_is_valid(self):
new_game = Game()
new_game.grid = list('KWEUEAKRZ') # Force the grid to a test case:
self.assertIs(new_game.is_valid('EUREKA'), True)
self.assertEqual(new_game.grid, list('KWEUEAKRZ')) # Make sure the grid remained untouched
def test_is_invalid(self):
new_game = Game()
new_game.grid = list('KWEUEAKRZ') # Force the grid to a test case:
self.assertIs(new_game.is_valid('SANDWICH'), False)
self.assertEqual(new_game.grid, list('KWEUEAKRZ')) # Make sure the grid remained untouched
Run the tests to make sure they are not passing:
nosetests
❓ It's your turn! Update the game.py
implementation to make the tests pass!
View solution
A possible implemantation is:
# game.py
# [...]
def is_valid(self, word):
if not word:
return False
letters = self.grid.copy() # Consume letters from the grid
for letter in word:
if letter in letters:
letters.remove(letter)
else:
return False
return True
Make sure to make pylint
happy:
pipenv run pylint game.py
You can disable those rules:
# pylint: disable=missing-docstring
# pylint: disable=too-few-public-methods
Before you jump to the next exercise, let's mark your progress with the following:
cd ~/code/<user.github_nickname>/reboot-python
cd 02-Best-Practices/02-TDD
touch DONE.md
git add DONE.md && git commit -m "02-Best-Practices/02-TDD done"
git push origin master