The idea behind Test-Driven Development (TDD) is that you always write your tests first instead of leaving it until the end of a coding task.
It helps you to think and decide on how your piece of software will behave before you write it, which helps you stay laser-focused on the task at hand and not let the mind wander off and invent some big wonderful solution. Once you are done with your piece of software you are working on, the best part is you automatically have got some level of test coverage. Although this in itself is not an answer to all of the testing requirements your system may need, it provides quite a good starting point.
Test-Driven Development is a very powerful tool in the arsenal of a developer. We will try to learn and understand it using the basics of JavaScript without the world of NodeJS or npm
.
Instead, we are going to use good plain JavaScript and something like JSBin
Test Driven Development: Why do it?
Quality
One of the main reasons to write tests is to increase the quality of the software you are writing. TDD makes you think about how the code can be used and how it should behave in different scenarios based on different inputs which should lead to a lower number of bugs in code.
Helps document code
Tests can be a great way to document an intent behind the code and will help new developers get on board with the code a lot faster as well as allow them to change it with confidence.
Helps produce cleaner code
As the tests are not an after-thought but more of a first-class citizen it becomes harder to over-engineer a solution and mix concerns. This is all due to the simplicity of the rules and focus.
Enables refactoring
When you have tests in place they give you confidence to change implementation details safe in the know that the tests will tell when you are about to break something.
Test Driven Development: What is it?
Test-Driven Development is a practice that helps you navigate a problem and come its solution using code.
The workflow is the following:
- Write a test - Red (write an assertion that will fail)
- Make it pass - Green (write some code to pass the assertion)
- Refactor the code - Refactor (change the code you are testing without changing behaviour)
- Repeat until done
You will often hear people refer to it as:
Red -> Green -> Refactor -> Repeat
It is that simple in its core. So to get our head into the right headspace let's dive into an example.
Test-Driven Development: Practice
Now we are going to dive into some practice, and the task at hand is the following:
Write a function that returns a sum of numbers passed to it
As we learned so far, the first thing we have to do is write a failing test. Just before we do that we need to understand what "test" means and how it works.
How the code is tested
So what happens when we run a test?
When a test is running, it will execute a piece of code, capture the output, and will verify that the output is equal to what it is expected to be.
When the result meets the expectation, it is marked as green or passing.
When the result does not meet the expectation, it fails and it is marked as red or failing.
The code that is testing our code needs to know 3 things:
- Test description - to communicate intent
- Expected result
- Result of executing our code
And at the very basic level that is all there is to a test. Now to help us remember this we will write the test function that we will use going forward in this tutorial to test the code we will write.
Code to test code
function test(description, expectedResult, result)
Now we need to make that function tell us if our expectation matched the result or if it failed.
function test(description, expectedResult, result) {
if(expectedResult === result) {
console.log(`${description} passed`);
} else {
console.log(`${description} failed. Expected ${result} to be ${expectedResult}`);
}
}
Check the test can fail
First, let's write something that is a "Red" or failing test:
test('result is 2', 2, 3);
// description: result is 2
// expectedResult: 2
// result: 3
// Output: result is 2 failed. Expected 3 to be 2
Test can succeed
Now let us write a "Green" or passing test:
test('result is 2', 2, 2);
// description: result is 2
// expectedResult: 2
// result: 2
// Output: result is 2 passed
As you can see we now have a simple test function that can validate if the result was what we expected it to be, and if it fails, also tells us what the outcome was meant to be.
Now that we have a function that can test our code, let's get back to our task at hand.
Test Driven Development Practice
As mentioned earlier the requirement we have is the following:
Write a function that returns a sum of numbers passed to it
First failing test: sum of 2 and 2
As per TDD rules, let's write our first failing test. Let's say because we need to return a sum of the numbers we are going to call our function sum
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
// Output: Uncaught ReferenceError: sum is not defined
Make it pass
This is a great start, we have our first test and what it is telling us is that we are trying to call sum
but it is not defined. Let's go and define it.
function sum() {}
If we try and run all of this code now the outcome will be different:
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
// sum of following numbers: "2,2" is 4 failed. Expected undefined to be 4
At this point, you may be tempted to go ahead and implement the function parameters and add them up, but that is not what we are going to do.
What we need to do instead is to write the minimum amount of code to make the test pass. And at this point, the code does not have to be pretty.
So what we are going to do is update our function to just return 4
:
function sum() { return 4; }
When we run our test now it will say the following
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
// sum of following numbers: "2,2" is 4 passed
This is great, we have our test passing, but we are not done just yet. We know the code is only good to handle the sums where it comes to 4
.
Next failing test: sum of 2 and 3
So let's write the next test where the result is something but 4
.
test('sum of following numbers: "2,3" is 5', 5, sum(2, 2));
// output: sum of following numbers: "2,3" is 5 failed. Expected 4 to be 5
Making second test pass
We have a new failing test. Now in order to make this pass, we have to update the sum
to take in some parameters and add them up for us.
function sum(number1, number2) {
return number1 + number2;
}
Run the test again:
test('sum of following numbers: "2,3" is 5', 5, sum(2, 2));
// Output: sum of following numbers: "2,3" is 5 passed
Where are we so far
Wonderful! We have 2 passing tests now! The code we have written so far should look something like this.
function test(description, expectedResult, result) {
if(expectedResult === result) {
console.log(`${description} passed`);
} else {
console.log(`${description} failed. Expected ${result} to be ${expectedResult}`);
}
}
function sum(number1, number2) {
return number1 + number2;
}
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
test('sum of following numbers: "2,3" is 5', 5, sum(2, 3));
// Output: sum of following numbers: "2,2" is 4 passed
// Output: sum of following numbers: "2,3" is 5 passed
You can play around with this code on JSBin: https://jsbin.com/yahubukane/edit?js,console
Next test: sum of more than two numbers
However, what happens if I pass more than two numbers? Remember we did not specify how many numbers we need to sum, we may need to sum more than two. With this said let's go ahead and write a test where we pass three numbers to the function.
test('sum of following numbers: "1,2,3" is 6', 6, sum(1, 2, 3));
// Output: sum of following numbers: "1,2,3" is 6 failed. Expected 3 to be 6
Working out how to access all function parameters
So how can we make the next piece work? The number of parameters can be anything, so passing a bunch of named arguments is not going to work. Well, you could add 100+ of them but that code would be quite hard to follow.
Luckily in JavaScript, a function has access to all the arguments that have been passed to it, even if they were not named (see Function arguments).
If you open that link and read, you will see that the arguments
inside a function is an Array-like parameter that does not support any array methods or properties apart from length
. As we can be sure we will need to iterate on the values in some form, a real array could be quite useful.
Luckily for us, there is a piece of code on that page that tells how to convert the arguments
to a real Array.
const args = Array.prototype.slice.call(arguments);
Let's add this to our sum
function and remove the named parameters:
function sum() {
const args = Array.prototype.slice.call(arguments);
return args;
}
If we run all our tests now we will see that they all fail:
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
test('sum of following numbers: "2,3" is 5', 5, sum(2, 3));
test('sum of following numbers: "1,2,3" is 6', 6, sum(1, 2, 3));
// Output: sum of following numbers: "2,2" is 4 failed. Expected 2,2 to be 4
// Output: sum of following numbers: "2,3" is 5 failed. Expected 2,3 to be 5
// Output: sum of following numbers: "1,2,3" is 6 failed. Expected 1,2,3 to be 6
Now although we do not have the right result yet, we can see that we get back an array of parameters, which is a step in the right direction. What we need to do now is to find a way to sum up all numbers in an array.
As we have now converted our parameters to an array, we can use forEach
to iterate.
Let's update our code:
function sum() {
let result = 0;
const args = Array.prototype.slice.call(arguments);
args.forEach(function(num) {
result = result + num;
});
return result;
}
Now let's run our tests one more time:
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
test('sum of following numbers: "2,3" is 5', 5, sum(2, 3));
test('sum of following numbers: "1,2,3" is 6', 6, sum(1, 2, 3));
// Output: sum of following numbers: "2,2" is 4 passed
// Output: sum of following numbers: "2,3" is 5 passed
// Output: sum of following numbers: "1,2,3" is 6 passed
Testing edge cases
Now to be completely happy that we have done the right thing, let's try to add 2 more tests. One where we pass only a single number. And another one where we pass let's say... 7 numbers. Something that covers a case for a single number and a lot of numbers.
test('sum of following numbers: "1" is 1', 1, sum(1));
test('sum of following numbers: "1,2,3,4,5,6,7" is 28', 28, sum(1,2,3,4,5,6,7));
// Output: sum of following numbers: "1" is 1 passed
// Output: sum of following numbers: "1,2,3,4,5,6,7" is 28 passed
One more edge case we could test is what would happen if you would pass no numbers at all?
How would you do that? In theory, the total number of no numbers is equal to 0
So we can go ahead and write the following test:
test('sum of following numbers: "" is 0', 0, sum());
// Output: sum of following numbers: "" is 0 passed
Refactoring
Now comes the best part of Test-Driven Development. We have our function, we have our tests, but we want to update the code to use ES6 syntax like all the cool kids.
On the arguments documentation, it suggests that to access arguments in ES6 we can use rest parameters.
Let's go ahead and do that.
function sum(...args) {
let result = 0;
args.forEach((num) => {
result = result + num;
});
return result;
}
Run all the tests:
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
test('sum of following numbers: "2,3" is 5', 5, sum(2, 3));
test('sum of following numbers: "1,2,3" is 6', 6, sum(1, 2, 3));
test('sum of following numbers: "" is 0', 0, sum());
test('sum of following numbers: "1" is 1', 1, sum(1));
test('sum of following numbers: "1,2,3,4,5,6,7" is 28', 28, sum(1,2,3,4,5,6,7));
// Output: sum of following numbers: "2,2" is 4 passed
// Output: sum of following numbers: "2,3" is 5 passed
// Output: sum of following numbers: "1,2,3" is 6 passed
// Output: sum of following numbers: "" is 0 passed
// Output: sum of following numbers: "1" is 1 passed
// Output: sum of following numbers: "1,2,3,4,5,6,7" is 28 passed
All the tests are green! That was nice, we updated our code syntax and still know that the code behaves the same as before.
Now, finally, curiosity has taken over and we decide to turn to StackOverflow to tell us about how to sum numbers in an array in Javascript:
StackOverflow - How to find the sum of an array of numbers
Let's go ahead and update our function with the suggested answer implementation using Array.reduce
(Interesting that an example of summing numbers can be seen implemented here too: Function rest parameters)
const sum = (...args) => args.reduce(
(accumulator, currentValue) => accumulator + currentValue, 0
);
And run tests one more time:
test('sum of following numbers: "2,2" is 4', 4, sum(2, 2));
test('sum of following numbers: "2,3" is 5', 5, sum(2, 3));
test('sum of following numbers: "1,2,3" is 6', 6, sum(1, 2, 3));
test('sum of following numbers: "" is 0', 0, sum());
test('sum of following numbers: "1" is 1', 1, sum(1));
test('sum of following numbers: "1,2,3,4,5,6,7" is 28', 28, sum(1,2,3,4,5,6,7));
// Output: sum of following numbers: "2,2" is 4 passed
// Output: sum of following numbers: "2,3" is 5 passed
// Output: sum of following numbers: "1,2,3" is 6 passed
// Output: sum of following numbers: "" is 0 passed
// Output: sum of following numbers: "1" is 1 passed
// Output: sum of following numbers: "1,2,3,4,5,6,7" is 28 passed
The final outcome of our exercise can be found here: https://jsbin.com/vakikudomu/1/edit?js,console
As you can see we can make changes to our code and be confident that it still works the way we intended it to in the first place.
Arguably the readability of the final example is not as good, but the main point here is that we can change code confidently!
Homework
Before we part:
- Think of other examples that we may have missed.
- Think about how you would approach a scenario where the inputs can contain letters or strings and not just numbers.
Top comments (3)
Nice, Vlad! Nice to see you writing 😁 I've been thinking for a long time to try and write up some fairly extensive run through of JS test mocking etc. Covering things like philosophy of mocking and what not. That was a huge hurdle for me to get into TDD, as you need to have all the "tools" before you begin.
That said, I literally now cannot code without tests. The prospect of compiling and then refreshing a browser or API and manually verifying fills me with horror. I'd much rather spend the first X amount of time writing some decent tests before I begin. I can't often fathom the idea of not doing that.
Also nice that you wrote your own little test runner. I remember watching some tutorial where someone did that and I remember being like... "oh... I can do that?". Then I started kicking myself since the true barrier to entry for tests is just one small test function (as you did). There's pretty much no excuse or reason not to.
Thanks Lou!
I wanted to show that writing tests is all about a way of thinking and that it helps you navigate the problem. I like the idea around mocking/not mocking as have fallen to that trap too before where mocked out things to the point - so if will find more time for writing may write some more stuff around testing :)
I had to recently try and explain how testing works and realised that we are too used to tooling and in reality all it is just a function that makes sure that result matches the expectation. The frameworks are out there to make a lot of things easier but the basics are still the same.
And as you said - I think once it clicks and you see the benefit of it you just don't want to go back to not doing it :)
As someone on LinkedIn said: "I never understood why someone wouldn't write lots of tests! What kind of person doesn't like to consistently prove themselves right?"