Diamond kata via property-based TDD in JavaScript

Riccardo Odone
Jan 24, 2018 · 7 min read
Image for post
Image for post

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.

The diamond kata

As well described by Seb Rose, the problem statement is as follows:

A few examples are

Input: A
Output: A

Input: B
Output: A
B B
A

Input: C
Output: A
B B
C C
B B
A

Ready, set, rock and roll

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.jsconst jsc = require('jsverify')
const mocha = require('mocha')
const isFive = require('./index')
describe('TODO', () => {
jsc.property('TODO', jsc.constant(5), isFive)
})
// index.jsconst isFive = number => number === 5
module.exports = isFive

which of course is green

$ mocha index.test.jsTODO
✓ TODO
1 passing (12ms)✨ Done in 0.52s.

The generator

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.jsdiamond
T
K
.
E
B
<
// ... up to 100 asciichars
✓ TODO1 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.jsdiamond
B
L
X
B
Q
V
X
B
J
S
C
P
I
// ... up to 100 chars in A..Z
✓ TODO1 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.asciichars. Therefore, only a subset of the generated input would get past the if. In other words, I would lose test coverage.

Property: diamond is not empty

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'

Property: first row contains A

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 '

Property: last row contains A

jsc.property(
'last row contains A',
char,
c => lastRow(make(c)).trim() === 'A'
)

Which is already green.

Property: first row has symmetrical contour

const firstRowHasSymmetricalContour = diamond => {
const leadingElements = leading('A', firstRow(diamond)).length
const trailingElements = trailing('A', firstRow(diamond)).length
return 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 '

Property: rows have symmetrical contour

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.

Property: rows contains the correct letters

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'

Property: rows are as wide as high

const rowsAreAsWideAsHigh = diamond => {
const height = rows(diamond).length
return 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 '

Property: rows except top and bottom have two identical letters

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 '

Property: rows have the correct amount of internal spaces

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 - internalSpaces
const 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).

Property: rows have the correct amount of external spaces

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.

And.. We are done

As shown above, the last REPL test gave us

make('C')
// => ' A \n B B \nC C\n B B \n A '

which means

  A  
B B
C C
B B
A

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

Outro

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.

References

  1. Diamond kata with FsCheck by Mark Seemann
  2. Diamond Kata — TDD with only Property-Based Tests by Nat Pryce
  3. Diamond Kata — Thoughts on Incremental Development by Nat Pryce

More Pointers

Get the latest content via email from me personally. Reply with your thoughts. Let’s learn from each other. Subscribe to my PinkLetter!

HackerNoon.com

#BlackLivesMatter

Sign up for Get Better Tech Emails via HackerNoon.com

By HackerNoon.com

how hackers start their afternoons. the real shit is on hackernoon.com. Take a look

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Riccardo Odone

Written by

🏳️‍🌈 Pronoun.is/he 💣 Maverick & Leader @Lunar_Logic ✉️ PinkLetter Odone.io/#newsletter 🎓 Student & Teacher of Timeless Software Skills

HackerNoon.com

Elijah McClain, George Floyd, Eric Garner, Breonna Taylor, Ahmaud Arbery, Michael Brown, Oscar Grant, Atatiana Jefferson, Tamir Rice, Bettie Jones, Botham Jean

Riccardo Odone

Written by

🏳️‍🌈 Pronoun.is/he 💣 Maverick & Leader @Lunar_Logic ✉️ PinkLetter Odone.io/#newsletter 🎓 Student & Teacher of Timeless Software Skills

HackerNoon.com

Elijah McClain, George Floyd, Eric Garner, Breonna Taylor, Ahmaud Arbery, Michael Brown, Oscar Grant, Atatiana Jefferson, Tamir Rice, Bettie Jones, Botham Jean

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store