Test Driven Development Exercise for Starters

An exercise on test driven development for beginners. Create a fully functioning class from scratch with tests written at every step.
tdd test-driven-development agile javascript

Not too long ago, I was new to the world of test-driven development (TDD) and writing tests before any implementation seems to be a very daunting task. The feature that needs to be implemented is often complex enough that there was no clear starting point.

I was fortunate enough to be given a training in TDD and I would like to share one of the techniques that were used. Follow along and I hope you’ll learn as much as I had on TDD.

Specialty coffee sourced from sustainable coffee growers are growing in popularity now. Serving coffee may be a decent source of income. So let’s start brewing ColdBrew, using our skills in writing code. ColdBrew is a simple class that gives a boost of caffeine by putting coffee in anytime it sees a vowel in a string of text.

To be more precise, ColdBrew has a functionality called caffeinate which replace contiguous occurrences of vowels with “coffee” and return the transformed string. For example:

• water will become wcoffeetcoffeer
• tea will become tcoffee

In this exercise, I will be taking the small steps approach to TDD. I’ll start with simple tests with hard coded implementation to pass the tests. Incrementally I will add additional test scenarios to make the code more generic. After the new tests pass, I will be able to refactor the code with confidence that the behaviour will not break. The code is written in Javascript with ES2015 and Mocha + Chai test framework.

In the next part of this post, I’ll be breaking down each step with a line break like this:

Whenever you see this line, take a break and think about the next step you are going to take. Write the next smallest test that will move the code towards the desired behaviour mentioned earlier. Then write just enough code to pass the tests. When the test passes, think about how the code can be written better. Do that without adding additional functionality. Use the tests you have written as a safety net to prevent the code from going awry.

This is the starting code of the ColdBrew class and the test.

export default class ColdBrew {
caffeinate(text) {}
}

import ColdBrew from '../src/coldbrew'
import { expect } from 'chai'

describe('ColdBrew#caffeinate', () => {
let coldBrew

before('set up test', () => {
coldBrew = new ColdBrew()
})
})


Let’s start with the first test. We will start with the simplest test possible, which is to test an empty string. Caffeine has no effect on an empty string and it should remain as it is.

it('should keep empty string', () => {
expect(coldBrew.caffeinate('')).to.equal('')
})


Let’s run this test to make sure it’s failing. Indeed it is, since we don’t have anything in caffeinate. It’s a simple case of empty string. We will fake the implementation by just returning an empty string.

caffeinate(text) {
return ''
}


Why fake it, you might ask? The idea here is to get the test passing as quickly as possible with the smallest amount of change in code as possible. We don’t want to over-engineer a solution when the requirement is just to return an empty string. As we move on, we will add more complex test cases and the code will gradually become more robust.

Now let’s get more serious and get some caffeine into some text.

it('should replace vowel with coffee', () => {
expect(coldBrew.caffeinate('a')).to.equal('coffee')
})


So there are only 2 scenarios in the tests, either an empty string or a vowel a. So let’s do just enough to pass these 2 tests.

caffeinate(text) {
return text === '' ? '' : 'coffee'
}


We’ll just return an empty string if the input is an empty string or ’coffee’ otherwise. Now our ColdBrew will also caffeinate all other 4 vowels e, i, o, u. Life is good with coffee.

Now comes the next challenge - consonants which should remain as it is.

it('should keep consonants', () => {
expect(coldBrew.caffeinate('b')).to.equal('b')
})


Our code so far has only encountered vowels and empty string. Now with the new test on consonants, we need to start changing the code to be generic enough to pass all 3 scenarios we have so far.

caffeinate(text) {
if (text === '') return ''
return 'aeiou'.includes(text) ? 'coffee' : text
}


So here we have an empty string returned first for the case of an empty string, before having the logic which checks if the text is a vowel. If so, it returns the text unchanged.

We have been dealing with an empty string or a single letter so far. Let’s start passing words into our function and have some fun.

it('should replace a vowel in a word with coffee', () => {
expect(coldBrew.caffeinate('hi')).to.equal('hcoffee')
expect(coldBrew.caffeinate('up')).to.equal('coffeep')
})


Now we need more complex logic to selectively replace characters in a word. We need to break the word into individual letters and replace each a letter if it’s a vowel.

caffeinate(text) {
let letters = text.split('')
let caffeinated = letters.map(letter => {
return 'aeiou'.includes(letter) ? 'coffee' : letter
})

return caffeinated.join('')
}


All good, we have our tests passing and words made up of only coffee and donuts consonants.

Let’s see if tea plays well with our ColdBrew and becomes tcoffee.

it('should replace contiguous vowels with a single coffee', () => {
expect(coldBrew.caffeinate('tea')).to.equal('tcoffee')
})


tcoffeecoffee That’s too much caffeine. It turns out that we can’t replace every vowel we come across. A vowel should only be replaced

caffeinate(text) {
let letters = text.split('')
let caffeinated = letters.map((letter, index) => {
if ('aeiou'.includes(letter)) {
if (!'aeiou'.includes(letters[index - 1])) {
return 'coffee'
} else {
return ''
}
} else {
return letter
}
})

return caffeinated.join('')
}


This time whenever there is a vowel, we check if the preceding letter is also a vowel. Run the tests and we’re back to green. All is good.

We have reached a point where ColdBrew and the caffeinate function behaves as we wish. However, the function has grown lengthy and there are some repetitions in the code such as the steps to check if a letter is a vowel.

Given that we have passing tests on various scenarios, we now can start refactoring the code to remove duplicated code. Let’s start by extracting the expression to check for a vowel into its own function isVowel(letter). Let’s also place letters[index - 1] in a variable to make the code more descriptive.

caffeinate(text) {
let letters = text.split('')
let caffeinated = letters.map((letter, index) => {
if (this.isVowel(letter)) {
let previousLetter = letters[index - 1]
return this.isVowel(previousLetter) ? '' : 'coffee'
} else {
return letter
}
})

return caffeinated.join('')
}

isVowel(letter) {
return 'aeiou'.includes(letter)
}


The code is now a little bit shorter and easier to read and understand. More importantly, the tests remain green.

ColdBrew has gained fame and it’s becoming the favourite drink of many words. The word has spread to the camel-cased community and new words with capital letters has come to have a taste of ColdBrew. Now, the capitalized vowels need to be caffeinated too.

it('should be case insensitive', () => {
expect(coldBrew.caffeinate('A')).to.equal('coffee')
expect(coldBrew.caffeinate('B')).to.equal('B')
expect(coldBrew.caffeinate('And')).to.equal('coffeend')
expect(coldBrew.caffeinate('Air')).to.equal('coffeend')
})


Unfortunately, the string we used to check for vowels had only lower-cased vowels in it, failing the new tests. We need to add all upper-cased vowels to the check and make sure that they get the coffee they need.

isVowel(letter) {
return 'AEIOUaeiou'.includes(letter)
}


How many lines of code had to change? One, thanks to the refactoring in the previous step. It has made the new change much easier to implement.

The demand for ColdBrew is skyrocketing that we need to be more selective in accepting words as a customer. There is a new criterion in place before a word can receive its share of coffee. Now, a word needs to have more than 30% vowels before its vowels may be replaced with coffee.

it('should only replace vowels if more than 30% of the word are vowels', () => {
expect(coldBrew.caffeinate('his')).to.equal('hcoffees')
expect(coldBrew.caffeinate('tea')).to.equal('tcoffee')
expect(coldBrew.caffeinate('milk')).to.equal('milk')
})


The first logical step to pass these tests is to calculate the ratio of vowels in the word. Let’s create a new function for that called calculateVowelRatio(letters)

calculateVowelRatio(letters) {
let vowels = 0
let consonants = 0

letters.forEach(letter => {
if (this.isVowel(letter)) {
vowels += 1
} else {
consonants += 1
}
})

return vowels / (vowels + consonants)
}


Now we can use this ratio in the main function

caffeinate(text) {
let letters = text.split('')
let result = ''
let vowelRatio = this.calculateVowelRatio(letters)

if (vowelRatio > 0.3) {
let caffeinated = letters.map((letter, index) => {
if (this.isVowel(letter)) {
let previousLetter = letters[index - 1]
return this.isVowel(previousLetter) ? '' : 'coffee'
} else {
return letter
}
})
result = caffeinated.join('')
} else {
result = text
}

return result
}


Back to green. Easy peasy.

Now the ColdBrew class is really lengthy with caffeinate and calculateVowelRatio looking pretty long. Again, we have sufficient tests to give us confidence when making changes. So let’s refactor calculateVowelRatio

calculateVowelRatio(letters) {
let vowelCount = letters.reduce((count, letter) => {
return this.isVowel(letter) ? count += 1 : count
}, 0)

return vowelCount / letters.length
}


The length of the function is reduced quite significantly. Let’s refactor caffeinate next. With the minimum vowel ratio in place, we don’t need to specify an empty string to return anymore.

caffeinate(text) {
let letters = text.split('')
let vowelRatio = this.calculateVowelRatio(letters)

if (vowelRatio <= 0.3) return text
let caffeinated = letters.map((letter, index) => {
if (this.isVowel(letter)) {
let previousLetter = letters[index - 1]
return this.isVowel(previousLetter) ? '' : 'coffee'
} else {
return letter
}
})

return caffeinated.join('')
}


After all the refactoring, we are still confident that the ColdBrew quality remains as the tests would have caught any change in behaviour.

This simplicity of the logic required in this exercise was intentional. It’s meant to focus the attention on the process to go from nothing to a fully functioning code.

We started with very small steps to get into the habit of writing small tests and small implementation. This helps in getting started as we don’t need to think too much about what needs to be tested, which can often be overwhelming and put people off from writing tests first. As one becomes more comfortable with TDD, every test and implementation can be made slightly more complex.

The code for this exercise can be found in my cold-brew repo.

If you haven’t been practicing TDD, this exercise has hopefully guided you through the steps and make it easier to adopt TDD in your next project.

Stubbing Dependencies in Go

A quick guide to stubbing dependencies in Go
software development go test tdd

Avoiding Test Data Mutation

October 24, 2017
ruby test rspec tdd

An Agile Marathon

People talk about agile sprints, but how about an agile marathon? It takes as much responsiveness to change, if not more, in order to complete a marathon, so why not model a project like one?
agile sprints marathon project