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

  1. Start on the API-Level

  2. Grow slow and steady

  3. Delegate subproblems to stubs

  4. Replace stubs recursively

  5. Finish off with a validation test

  6. 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
0.4s
Python
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')
0.2s
Python
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)
0.2s
Python

We enter the first TDD cycle with a failing (red) test. We are therefore in the red state.

run_tests()
0.4s
Python

To get as quickly as possible into the green state, we simply return the expected value.

class BankOcr():
  
  def convert(self, scan):
    return '000000000'    
0.1s
Python
run_tests()
0.5s
Python

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')
0.2s
Python
run_tests()
0.6s
Python

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'
0.1s
Python
run_tests()
0.8s
Python

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'
0.5s
Python
run_tests()
0.3s
Python
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'
0.1s
Python
run_tests()
0.7s
Python

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'
0.2s
Python
run_tests()
0.3s
Python

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'
0.2s
Python
run_tests()
0.3s
Python

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"]])
0.2s
Python
run_tests()
0.3s
Python

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'
0.1s
Python
run_tests()
0.4s
Python
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"]])
0.3s
Python
run_tests()
0.4s
Python
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'
0.2s
Python
run_tests()
0.3s
Python

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'
0.2s
Python
run_tests()
0.5s
Python

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'
0.4s
Python
run_tests()
0.4s
Python

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)])
0.2s
Python
run_tests()
0.3s
Python

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'
0.1s
Python
run_tests()
0.3s
Python

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)])
0.2s
Python
run_tests()
0.8s
Python

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)])
0.2s
Python
run_tests()
0.5s
Python

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)])
0.2s
Python
run_tests()
0.4s
Python

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)])
0.2s
Python
run_tests()
0.4s
Python

Further reading

Visit the website for more information and an additional demo.

Runtimes (1)