St. Pauli school of TDD
About
Test-driven development is designed to provide feedback at intervals of seconds or minutes on whether current software development is making progress in the right direction. If the development takes too long until all tests can be run again without errors, this feedback is missing and a slower development speed is the result. We often notice that many developers are still able to handle the first two or three TDD cycles smoothly, but the subsequent cycles are so slow that one can hardly speak of test-driven development. We have therefore developed a systematic approach that leads to continuous progress in short TDD cycles. Following the two well-known TDD approaches - "Chicago school" and "London school" - we have named this approach St. Pauli school of TDD.
Approach
Start on the API-Level
Grow slow and steady
Delegate subproblems to stubs
Replace stubs recursively
Finish off with a validation test
Treat test suite as append-only
You can find a more detailed description of the approach on the website.
Demo
To demonstrate the St. Pauli school of TDD, we choose the BankOCR-Kata and start with a test at the API-level.
class BankOcr():
def convert(self, scan):
return None
import unittest
all_zeros_scan = (" _ _ _ _ _ _ _ _ _ \n"
"| || || || || || || || || |\n"
"|_||_||_||_||_||_||_||_||_|")
class BankOcrTest(unittest.TestCase):
def setUp(self):
self.sut = BankOcr()
def test_converts_all_zeros_scan(self):
self.assertEqual(self.sut.convert(all_zeros_scan), '000000000')
def run_tests():
suite = unittest.TestSuite()
[suite.addTest(BankOcrTest(func)) for func in dir(BankOcrTest) if callable(getattr(BankOcrTest, func)) and func.startswith("test_")]
runner = unittest.TextTestRunner()
runner.run(suite)
We enter the first TDD cycle with a failing (red) test. We are therefore in the red state.
run_tests()
To get as quickly as possible into the green state, we simply return the expected value.
class BankOcr():
def convert(self, scan):
return '000000000'
run_tests()
What we did is both part of the Fake-it- and the Triangulate-Pattern. If we refactor the constant value to the real implementation, we would have used the Fake-it-pattern. But this would be a too big step at this point. That is why we continue with the Triangulate-pattern. With this pattern, we add more tests until returning hard coded answers gets ridiculous and the real implementation gets more obvious.
all_ones_scan = (" \n"
" | | | | | | | | |\n"
" | | | | | | | | |")
class BankOcrTest(unittest.TestCase):
def setUp(self):
self.sut = BankOcr()
def test_converts_all_zeros_scan(self):
self.assertEqual(self.sut.convert(all_zeros_scan), '000000000')
def test_converts_all_ones_scan(self):
self.assertEqual(self.sut.convert(all_ones_scan), '111111111')
run_tests()
Again, we do the simplest thing to get back in the green state.
class BankOcr():
def convert(self, scan):
if scan == all_zeros_scan:
return '000000000'
else:
return '111111111'
run_tests()
We do not expect to gain any more insight to the problem from the API-level, so we continue with the second part of the "Fake-It" Pattern and refactor the hard coded pard in small steps towards the real implementation. These small steps are also called baby steps.
class BankOcr():
def convert(self, scan):
if scan == all_zeros_scan:
return "".join(['000000000'])
else:
return '111111111'
run_tests()
class BankOcr():
def convert(self, scan):
if scan == all_zeros_scan:
return "".join(['0', '0', '0', '0', '0', '0', '0', '0', '0'])
else:
return '111111111'
run_tests()
Here, we identified a subproblem: The scan is converted into text digit by digit. We delegate the responsibility to convert a multiline string to a digit string to the method convert_digit
. We use the todo
method to introduce the call to convert_digit
without breaking any tests.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in [ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO]])
else:
return '111111111'
run_tests()
To split the large multiline string into something, that the convert_digit
method can work with is another subproblem. We delegate the solution of this subproblem to the split_scan
method. Again, we use the todo method to introduce the usage of the new method without leaving the green state.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
pass
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.todo(self.split_scan(scan, 3), [ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO])])
else:
return '111111111'
run_tests()
The St. Pauli school of TDD defines a recursive approach. The split_scan
function is now the new SUT and we start again with a most basic API-test. We deliberately choose a different test input than in the tests before to make use of simpler examples.
class BankOcrTest(unittest.TestCase):
def setUp(self):
self.sut = BankOcr()
def test_converts_all_zeros_scan(self):
self.assertEqual(self.sut.convert(all_zeros_scan), '000000000')
def test_converts_all_ones_scan(self):
self.assertEqual(self.sut.convert(all_ones_scan), '111111111')
def test_splits_width_1_scan(self):
self.assertEqual(self.sut.split_scan("ab\ncd", 1), [["a", "c"], ["b", "d"]])
run_tests()
To get back in the green state quickly, we use the "Triangulate"-Pattern again.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
return [["a", "c"], ["b", "d"]]
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.todo(self.split_scan(scan, 3), [ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO])])
else:
return '111111111'
run_tests()
class BankOcrTest(unittest.TestCase):
def setUp(self):
self.sut = BankOcr()
def test_converts_all_zeros_scan(self):
self.assertEqual(self.sut.convert(all_zeros_scan), '000000000')
def test_converts_all_ones_scan(self):
self.assertEqual(self.sut.convert(all_ones_scan), '111111111')
def test_splits_width_1_scan(self):
self.assertEqual(self.sut.split_scan("ab\ncd", 1), [["a", "c"], ["b", "d"]])
def test_splits_width_2_scan(self):
self.assertEqual(self.sut.split_scan("ab\ncd", 2), [["ab", "cd"]])
run_tests()
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
if width == 1:
return [["a", "c"], ["b", "d"]]
else:
return [["ab", "cd"]]
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.todo(self.split_scan(scan, 3), [ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO])])
else:
return '111111111'
run_tests()
We start to refactor the hard-coded solution to something that is slightly closer to the real implementation.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
if width == 1:
return [["a", "c"], ["b", "d"]]
else:
lines = scan.splitlines()
if (lines == []):
return []
result = [[]]
result[0].append("ab")
result[0].append("cd")
return result
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.todo(self.split_scan(scan, 3), [ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO])])
else:
return '111111111'
run_tests()
The next step is rather large to avoid bursting this notebook. In reality, there have been a lot of small baby steps, each of them a little bit closer to the solution.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
lines = scan.splitlines()
if (lines == []):
return []
result = []
for i in range(0, len(lines[0]), width):
digit = []
for line_index in range(len(lines)):
digit.append(lines[line_index][i:i+width])
result.append(digit)
return result
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.todo(self.split_scan(scan, 3), [ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO, ZERO])])
else:
return '111111111'
run_tests()
We complete our first St. Pauli TDD cycle by finishing with a validation test.
class BankOcrTest(unittest.TestCase):
def setUp(self):
self.sut = BankOcr()
def test_converts_all_zeros_scan(self):
self.assertEqual(self.sut.convert(all_zeros_scan), '000000000')
def test_converts_all_ones_scan(self):
self.assertEqual(self.sut.convert(all_ones_scan), '111111111')
def test_splits_width_1_scan(self):
self.assertEqual(self.sut.split_scan("ab\ncd", 1), [["a", "c"], ["b", "d"]])
def test_splits_width_2_scan(self):
self.assertEqual(self.sut.split_scan("ab\ncd", 2), [["ab", "cd"]])
def test_splits_all_zeros_scan(self):
self.assertEqual(self.sut.split_scan(all_zeros_scan, 3), [ZERO for i in range(0,9)])
run_tests()
The method split_scan
is now ready to go, so that the todo function around split_scan
can be removed.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
lines = scan.splitlines()
if (lines == []):
return []
result = []
for i in range(0, len(lines[0]), width):
digit = []
for line_index in range(len(lines)):
digit.append(lines[line_index][i:i+width])
result.append(digit)
return result
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.split_scan(scan, 3)])
else:
return '111111111'
run_tests()
We duplicate the working code for the all-ones-case. Still, we are in the green state.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
pass
def split_scan(self, scan, width):
lines = scan.splitlines()
if (lines == []):
return []
result = []
for i in range(0, len(lines[0]), width):
digit = []
for line_index in range(len(lines)):
digit.append(lines[line_index][i:i+width])
result.append(digit)
return result
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.todo(self.convert_digit(digit), '0') for digit in self.split_scan(scan, 3)])
else:
return "".join([self.todo(self.convert_digit(digit), '1') for digit in self.split_scan(scan, 3)])
run_tests()
Because the convert_digit
method, is so straightforward, we do not need to write a standalone test. This approach is called "Obvious Implementation". As a rule of thumb, we only use this pattern when writing the real implementation is faster than an average TDD cycle.
ZERO = [" _ ",
"| |",
"|_|"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
if digit == ZERO:
return "0"
else:
return None
def split_scan(self, scan, width):
lines = scan.splitlines()
if (lines == []):
return []
result = []
for i in range(0, len(lines[0]), width):
digit = []
for line_index in range(len(lines)):
digit.append(lines[line_index][i:i+width])
result.append(digit)
return result
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.convert_digit(digit) for digit in self.split_scan(scan, 3)])
else:
return "".join([self.todo(self.convert_digit(digit), '1') for digit in self.split_scan(scan, 3)])
run_tests()
Now we can expand the convert_digit
method for 1s, 2s, etc.
ZERO = [" _ ",
"| |",
"|_|"]
ONE = [" ",
" |",
" |"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
if digit == ZERO:
return "0"
elif digit == ONE:
return "1"
else:
return None
def split_scan(self, scan, width):
lines = scan.splitlines()
if (lines == []):
return []
result = []
for i in range(0, len(lines[0]), width):
digit = []
for line_index in range(len(lines)):
digit.append(lines[line_index][i:i+width])
result.append(digit)
return result
def convert(self, scan):
if scan == all_zeros_scan:
return "".join([self.convert_digit(digit) for digit in self.split_scan(scan, 3)])
else:
return "".join([self.convert_digit(digit) for digit in self.split_scan(scan, 3)])
run_tests()
There are still more digits to implement, but to cut a long story short at this point, we indicate, how a final convert
method would look like.
ZERO = [" _ ",
"| |",
"|_|"]
ONE = [" ",
" |",
" |"]
class BankOcr():
def todo(self, f, result):
return result
def convert_digit(self, digit):
if digit == ZERO:
return "0"
elif digit == ONE:
return "1"
else:
return None
def split_scan(self, scan, width):
lines = scan.splitlines()
if (lines == []):
return []
result = []
for i in range(0, len(lines[0]), width):
digit = []
for line_index in range(len(lines)):
digit.append(lines[line_index][i:i+width])
result.append(digit)
return result
def convert(self, scan):
return "".join([self.convert_digit(digit) for digit in self.split_scan(scan, 3)])
run_tests()
Further reading
Visit the website for more information and an additional demo.