In the previous post I’ve covered the basic ideas behind property-based testing. Here, I’m going to TDD the diamond kata using that technique.
The post is heavily inspired (i.e. blatantly copied). So be sure to go say hi to Nat Pryce and Mark Seemann doing the same exercise[1][2] (links at the bottom in the references). Luckily I’m going to use JavaScript and JSVerify. That way I can hide myself behind the “but I’m using a different stack” excuse.
Also, I’m gonna keep code snippets to a minimum. Should you be interested into more details, feel free to check the repo.
As well described by Seb Rose, the problem statement is as follows:
Given a letter, print a diamond starting with ‘A’ with the supplied letter at the widest point.
A few examples are
Input: AOutput: AInput: BOutput: A B B AInput: COutput: A B B C C B B A
In the init
commit I want to check the wirings. That’s why I use a generator that always returns 5
to check the isFive
property.
// index.test.js
const jsc = require('jsverify')const mocha = require('mocha')const isFive = require('./index')
describe('TODO', () => {jsc.property('TODO', jsc.constant(5), isFive)})
// index.js
const isFive = number => number === 5module.exports = isFive
which of course is green
$ mocha index.test.js
TODO✓ TODO
1 passing (12ms)
✨ Done in 0.52s.
Everything works, thus I can create the generator for the diamond kata. In particular, I need to generate characters in the A..Z
range.
Since I’m not sure what to use, I decide to check what the jsc.asciichar
generator returns
const debug = x => {console.log(x)return true}
describe('diamond', () => {jsc.property('TODO', jsc.asciichar, debug)})
Notice the return true
. That way the “property” debug
never fails and I can check all the generated asciichar. Since by default JSVerify checks the property 100 times by generating 100 inputs out of the generator, I see
$ mocha index.test.js
diamondTK.E
B<// ... up to 100 asciichars
✓ TODO
1 passing (16ms)
✨ Done in 0.52s.
Not quite right, in fact, I need to generate characters in the A..Z
range only. Unfortunately, JSVerify doesn’t provide any generators out of the box that satisfy that constraint. Therefore, I create a custom one
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')const char = jsc.suchthat(jsc.asciichar, c => alphabet.includes(c))
describe('diamond', () => {jsc.property('TODO', char, debug)})
This time we get the proper values
$ mocha index.test.js
diamondBLXBQVXBJSCPI// ... up to 100 chars in A..Z
✓ TODO
1 passing (19ms)
✨ Done in 0.52s.
Notice that I could have moved the check inside the property
const property = c => {if (!alphabet.includes(c)) return true// ... test the property}
describe('diamond', () => {jsc.property('TODO', jsc.asciichar, property)})
but I would have made a mistake. In fact, in this case, JSVerify would call property
100 times with random jsc.asciichar
s. Therefore, only a subset of the generated input would get past the if
. In other words, I would lose test coverage.
The property that kicks off the exercise just checks the diamond has length different than 0 for any char.
jsc.property('is not empty', char, c => make(c).length !== 0)
Which I make green with
const make = char => 'whatever'
From the REPL
make(c) // for any c// => 'whatever'
jsc.property('first row contains A',char,c => firstRow(make(c)).trim() === 'A')
Which I make green with
const make = char => ' A ' // padding is asymmetric
From the REPL
make(c) // for any c// => ' A '
jsc.property('last row contains A',char,c => lastRow(make(c)).trim() === 'A')
Which is already green.
const firstRowHasSymmetricalContour = diamond => {const leadingElements = leading('A', firstRow(diamond)).lengthconst trailingElements = trailing('A', firstRow(diamond)).lengthreturn leadingElements === trailingElements}
jsc.property(‘first row has symmetrical contour’,char,c => firstRowHasSymmetricalContour(make(c)))
Which I make green with
const make = char => ' A ' // padding is symmetric
From the REPL
make(c) // for any c// => ' A '
Well, not only the first row has a symmetrical contour. Let’s modify the property so that all of the rows are checked
const rowsHaveSymmetricalContour = diamond =>diamond.split('\n').map(rowHasSymmetricalContour).reduce((acc, x) => acc && x) // [].every would be better here
jsc.property('rows have symmetrical contour',char,c => rowsHaveSymmetricalContour(make(c)))
Which is already green.
const rowsContainsCorrectLetters = (char, diamond) => {const pre = alphabetUntilBefore(char)const post = pre.slice().reverse()const expected = pre.concat([char]).concat(post)const actual = diamond.split('\n').map(row => row.trim())return expected.join() === actual.join()}
jsc.property(‘rows contains the correct letters’,char,c => rowsContainsCorrectLetters(c, make(c)))
Which I make green with
const make = char => {const pre = alphabetUntilBefore(char)const post = pre.slice().reverse()const chars = pre.concat([char]).concat(post)return chars.join('\n')}
The duplication between test and production code is a bad smell. But I decide to leave it there.
From the REPL
make('C')// => 'A\nB\nC\nB\nA'
const rowsAreAsWideAsHigh = diamond => {const height = rows(diamond).lengthreturn all(rows(diamond).map(hasLength(height)))}
jsc.property('rows are as wide as high',char,c => rowsAreAsWideAsHigh(make(c)))
which I make green with
const makeRow = width => char => {if (char === 'A') {const padding = ' '.repeat(width / 2)return `${padding}A${padding}`} else {return char.repeat(width)}}
const make = char => {const pre = alphabetUntilBefore(char)const post = pre.slice().reverse()const chars = pre.concat([char]).concat(post)return chars.map(makeRow(chars.length)).join('\n')}
and from the REPL
make('C')// => ' A \nBBBBB\nCCCCC\nBBBBB\n A '
jsc.property('rows except top and bottom have two identical letters',char,c => internalRowsHaveTwoIdenticalLetters(make(c)))
which I make green with
const makeRow = width => char => {if (char === 'A') {const padding = ' '.repeat(width / 2)return `${padding}A${padding}`} else {const padding = ' '.repeat(width - 2)return `${char}${padding}${char}`}}
const make = char => {const pre = alphabetUntilBefore(char)const post = pre.slice().reverse()const chars = pre.concat([char]).concat(post)return chars.map(makeRow(chars.length)).join('\n')}
and from the REPL
make('C')// => ' A \nB B\nC C\nB B\n A '
jsc.property('rows have the correct amount of internal spaces',char,c => rowsHaveCorrectAmountOfInternalSpaces(make(c)))
which I make green with
const internalPaddingFor = char => {const index = alphabet.indexOf(char)return Math.max((index * 2) - 1, 0)}
const makeRow = width => char => {if (char === 'A') {const padding = ' '.repeat(width / 2)return `${padding}A${padding}`} else {const internalSpaces = internalPaddingFor(char)const internalPadding = ' '.repeat(internalSpaces)const externalSpaces = width - 2 - internalSpacesconst externalPadding = ' '.repeat(externalSpaces / 2)return `${externalPadding}${char}${internalPadding}${char}${externalPadding}`}}
const make = char => {const pre = alphabetUntilBefore(char)const post = pre.slice().reverse()const chars = pre.concat([char]).concat(post)return chars.map(makeRow(chars.length)).join('\n')}
and from the REPL
make('C')' A \n B B \nC C\n B B \n A '
Unfortunately,rowsHaveCorrectAmountOfInternalSpaces
in the test uses the following
const index = alphabet.indexOf(char)return Math.max((index * 2) - 1, 0)
I don’t like this duplication. Therefore, I decide to test the external space (and not the internal one).
jsc.property('rows have the correct amount of external spaces',char,c => rowsHaveCorrectAmountOfExternalSpaces(make(c)))
This time rowsHaveCorrectAmountOfExternalSpaces
internally uses a different calculation:
const index = alphabet.indexOf(char)return ((width - 1) / 2 - index) * 2
which means I’ve removed the duplication. Plus, the tests are already green since the production code for the internal spaces takes care of the external too.
As shown above, the last REPL test gave us
make('C')// => ' A \n B B \nC C\n B B \n A '
which means
AB BC CB BA
And these are all the properties I have discovered:
is not empty
first row contains A
last row contains A
rows have symmetrical contour
rows contain the correct letters
rows are as wide as high
rows except top and bottom have two identical letters
rows have the correct amount of external spaces
The first thing I’ve noticed is how hard property-based TDD makes you think. In fact, it’s really easy to come up with examples for this kata. But the same cannot be said for invariants.
At the same time, knowing what properties your problem space has, means having a deep understanding of it. And with property-based TDD, it’s necessary to discover them before writing the actual production code.
Not only that, I found myself writing a property that conflicted with previous ones. In fact, the code that made it green, also turned red some of the existing. The diamond kata is a simple exercise but this happens frequently in the specs we are given on everyday work.
Also, I’ve built my way up from generic properties first and then specialised (i.e. diamond is not empty
to rows have the correct amount of external spaces
). Which is the opposite of what happens in example-based TDD: from specific to generic[3].
Unfortunately, I cannot compare much with example-based TDD since I haven’t tried the kata that way. Should you be interested into that, please check out the references.
If you liked the post and want to help spread the word, please consider tweeting, clapping or sharing this. But only if you really liked it. Otherwise, please feel free to comment or tweet me with any suggestions or feedback.