Learning JavaScript Test-Driven Development by Example
You’re probably already familiar with automated testing and its benefits. Having a set of tests for your application allows you to make changes to your code with confidence, knowing that the tests have your back should you break anything. It’s possible to take things a step further and write your tests before you “>write the code; a practice known as Test-driven development (TDD).
In this tutorial, we will talk about what TDD is and what benefits it brings to you as a developer. We’ll use TDD to implement a form validator, which ensures that any values input by the user conform to a specified set of rules.
Note that this article will focus on testing front-end code. If you’re looking for something focused on the backend, be sure to check out our course: Test-Driven Development in Node.js
What is TDD?
Test-driven development is a programming methodology with which one can tackle the design, implementation, and testing of units of code, and to some extent the expected functionality of a program.
Complementing the test-first approach of Extreme Programming, in which developers write tests before implementing a feature or a unit, TDD also facilitates the refactoring of code; this is commonly referred to as the Red-Green-Refactor Cycle.
-
Write a failing test – write a test that invokes your logic and assert that the correct behavior is produced
- In a unit test, this would be asserting the return value of a function or verifying that a mocked dependency was called as expected
- In a functional test, this would be ensuring that a UI or an API behaves predictably across a number of actions
-
Make the test pass – implement the minimum amount of code that results in the test passing, and ensure that all other tests continue to pass
-
Refactor the implementation – update or rewrite the implementation, without breaking any public contracts, to improve its quality without breaking the new and existing tests
I’ve used TDD to some extent since I was introduced to it at the beginning of my career, but as I have progressed to working on applications and systems with more complex requirements, I have personally found the technique to be time-saving and conducive to the quality and robustness of my work.
Before proceeding, it might be worth familiarizing yourself with some of the various types of automated tests that can be written. Eric Elliot summarises them well:
- Unit tests – ensure that individual units of the app, such as functions and classes, work as expected. Assertions test that said units return the expected output for any given inputs
- Integration tests – ensure that unit collaborations work as expected. Assertions may test an API, UI, or interactions that may result in side-effects (such as database I/O, logging, etc…)
- End-to-end tests – ensure that software works as expected from the user’s perspective and that every unit behaves correctly in the overall scope of the system. Assertions primarily test the user interface
Benefits of Test-Driven Development
Immediate test coverage
By writing test cases for a feature before its implementation, code coverage is immediately guaranteed, plus behavioral bugs can be caught earlier in the development lifecycle of a project. This, of course, necessitates tests that cover all behaviors, including error handling, but one should always practice TDD with this mindset.
Refactor with confidence
Referring to the red-green-refactor cycle above, any changes to an implementation can be verified by ensuring that the existing tests continue to pass. Writing tests that run as quickly as possible will shorten this feedback loop; while it’s important to cover all possible scenarios, and execution time can vary slightly between different computers, authoring lean and well-focused tests will save time in the long term.
Design by contract
Test-driven development allows developers to consider how an API will be consumed, and how easy it is to use, without having to worry about the implementation. Invoking a unit in a test case essentially mirrors a call site in production, so the external design can be modified before the implementation stage.
Avoid superfluous code
As long as one is frequently, or even automatically, running tests upon changing the associated implementation, satisfying existing tests reduces the likelihood of unnecessary additional code, arguably resulting in a codebase that’s easier to maintain and understand. Consequently, TDD helps one to follow the KISS (Keep it simple, stupid!) principle.
No dependence upon integration
When writing unit tests, if one is conforming to the required inputs, then units will behave as expected once integrated into the codebase. However, integration tests should also be written to ensure that the new code’s call site is being invoked correctly.
For example, let’s consider the function below, which determines if a user is an admin:
'use strict'
function isUserAdmin(id, users) {
const user = users.find(u => u.id === id);
return user.isAdmin;
}
Rather than hard code the users data, we expect it as a parameter. This allows us to pass a prepopulated array in our test:
const testUsers = [
{
id: 1,
isAdmin: true
},
{
id: 2,
isAdmin: false
}
];
const isAdmin = isUserAdmin(1, testUsers);
// TODO: assert isAdmin is true
This approach allows the unit to be implemented and tested in isolation from the rest of the system. Once there are users in our database, we can integrate the unit and write integration tests to verify that we are correctly passing the parameters to the unit.
Test-Driven Development With JavaScript
With the advent of full-stack software written in JavaScript, a plethora of testing libraries has emerged that allow for the testing of both client-side and server-side code; an example of such a library is Mocha, which we will be using in the exercise.
A good use case for TDD, in my opinion, is form validation; it is a somewhat complex task that typically follows these steps:
- Read the value from an
<input>
that should be validated - Invoke a rule (e.g. alphabetical, numeric) against said value
- If it is invalid, provide a meaningful error to the user
- Repeat for the next validatable input
There is a CodePen for this exercise that contains some boilerplate test code, as well as an empty validateForm
function. Please fork this before we start.
Our form validation API will take an instance of HTMLFormElement
(<form>
) and validate each input that has a data-validation
attribute, the possible values of which are:
alphabetical
– any case-insensitive combination of the 26 letters of the English alphabetnumeric
– any combination of digits between 0 and 9
We will write an end-to-end test to verify the functionality of validateForm
against real DOM nodes, as well as against the two validation types we’ll initially support. Once our first implementation works, we will gradually refactor it by writing smaller units, also following TDD.
Here’s the form that our tests will use:
<form class="test-form">
<input name="first-name" type="text" data-validation="alphabetical" />
<input name="age" type="text" data-validation="numeric" />
</form>
Between each test, we create a new clone of the form to remove the risk of potential side effects. The true
parameter passed to cloneNode
ensures that the form’s child nodes are also cloned:
let form = document.querySelector('.test-form');
beforeEach(function () {
form = form.cloneNode(true);
});
Writing our first test case
The describe('the validateForm function', function () {})
suite will be used to test our API. Within the inner function, write the first test case, which will ensure that legal values for both the alphabetical and numeric rules will be recognized as valid:
it('should validate a form with all of the possible validation types', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = 'Bob';
age.value = '42';
const result = validateForm(form);
expect(result.isValid).to.be.true;
expect(result.errors.length).to.equal(0);
});
Upon saving the changes to your fork, you should see the test fail:
Now let’s make this test green! Remember that we should endeavor to write the minimum, reasonable (no return true;
!) amount of code to satisfy the test, so let’s not worry about error reporting for now.
Here’s the initial implementation, which iterates over our form’s input
elements and validates the values of each using regular expressions:
function validateForm(form) {
const result = {
errors: []
};
const inputs = Array.from(form.querySelectorAll('input'));
let isValid = true;
for (let input of inputs) {
if (input.dataset.validation === 'alphabetical') {
isValid = isValid && /^[a-z]+$/i.test(input.value);
} else if (input.dataset.validation === 'numeric') {
isValid = isValid && /^[0-9]+$/.test(input.value);
}
}
result.isValid = isValid;
return result;
}
You should now see that our test passes:
Error handling
Below our first test, let’s write another which verifies that the return result
object’s error
array contains an Error
instance with the expected message when an alphabetical field is invalid:
it('should return an error when a name is invalid', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = '!!!';
age.value = '42';
const result = validateForm(form);
expect(result.isValid).to.be.false;
expect(result.errors[0]).to.be.instanceof(Error);
expect(result.errors[0].message).to.equal('!!! is not a valid first-name value');
});
Upon saving your CodePen fork, you should see the new failing test case in the output. Let’s update our implementation to satisfy both test cases:
function validateForm(form) {
const result = {
get isValid() {
return this.errors.length === 0;
},
errors: []
};
const inputs = Array.from(form.querySelectorAll('input'));
for (let input of inputs) {
if (input.dataset.validation === 'alphabetical') {
let isValid = /^[a-z]+$/i.test(input.value);
if (!isValid) {
result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`));
}
} else if (input.dataset.validation === 'numeric') {
// TODO: we'll consume this in the next test
let isValid = /^[0-9]+$/.test(input.value);
}
}
return result;
}
Now let’s add a test that asserts that numeric validation errors are handled correctly:
it('should return an error when an age is invalid', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = 'Greg';
age.value = 'a';
const result = validateForm(form);
expect(result.isValid).to.be.false;
expect(result.errors[0]).to.be.instanceof(Error);
expect(result.errors[0].message).to.equal('a is not a valid age value');
});
Once you’ve witnessed the test fail, update the validateForm
function:
} else if (input.dataset.validation === 'numeric') {
let isValid = /^[0-9]+$/.test(input.value);
if (!isValid) {
result.errors.push(new Error(`${input.value} is not a valid ${input.name} value`));
}
}
Finally, let’s add a test to ensure that multiple errors are handled:
it('should return multiple errors if more than one field is invalid', function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = '!!!';
age.value = 'a';
const result = validateForm(form);
expect(result.isValid).to.be.false;
expect(result.errors[0]).to.be.instanceof(Error);
expect(result.errors[0].message).to.equal('!!! is not a valid first-name value');
expect(result.errors[1]).to.be.instanceof(Error);
expect(result.errors[1].message).to.equal('a is not a valid age value');
});
Given our error handling implementation for the second and third test, this new case should pass immediately. You can confirm that you’ve followed the steps correctly by verifying your implementation against mine.
Refactoring Our Validator
Although we have a working function that is covered with tests, it emits a number of code smells:
-
Multiple responsibilities
- We’re querying the inner DOM nodes of our input, specifying our ruleset, and computing our overall result in the same function. In terms of the SOLID principles, this violates the Single responsibility principle
- Additionally, a lack of abstraction results in code that is more difficult for other developers to understand
-
Tight coupling
- Our current implementation interweaves the above responsibilities in a way that makes updates to each concern brittle; changes to one detail of our large method will make debugging difficult in the case that we introduce an issue
- Furthermore, we can’t add or alter validation rules without updating the
if
statements. This violates SOLID’s Open/closed principle
-
Duplication of logic – if we wish to update the format of our error messages, or push another object to our array, then we must update this in two places
Fortunately, as we’ve written the functional tests for our validator function, we can make our code better with the confidence that we won’t break it.
Let’s use TDD to write separate functions for:
- Mapping our inputs to validation queries
- Reading our validation rules from an appropriate data structure
The createValidationQueries
function
By mapping our NodeList
of HTMLInputElement
s to objects representing the name of a form field, the type against which it should be validated, and the value of said field, not only will we decouple validateForm
function from the DOM, but we’ll facilitate validation rule lookup when we replace our hard-coded regular expressions.
For example, the validation query object for the first-name
field would be:
{
name: 'first-name',
type: 'alphabetical',
value: 'Bob'
}
Above the validateForm
function, create an empty function called createValidationQueries
. Then, outside of the describe
suite for validateForm
, create another describe suite
named ‘the createValidationQueries function’.
It should include the single test case:
describe('the createValidationQueries function', function () {
it(
'should map input elements with a data-validation attribute to an array of validation objects',
function () {
const name = form.querySelector('input[name="first-name"]');
const age = form.querySelector('input[name="age"]');
name.value = 'Bob';
age.value = '42';
const validations = createValidationQueries([name, age]);
expect(validations.length).to.equal(2);
expect(validations[0].name).to.equal('first-name');
expect(validations[0].type).to.equal('alphabetical');
expect(validations[0].value).to.equal('Bob');
expect(validations[1].name).to.equal('age');
expect(validations[1].type).to.equal('numeric');
expect(validations[1].value).to.equal('42');
}
);
});
Once you’ve witnessed this fail, write the code for the implementation:
function createValidationQueries(inputs) {
return Array.from(inputs).map(input => ({
name: input.name,
type: input.dataset.validation,
value: input.value
}));
}
When this passes, update validateForm
‘s for
loop to call our new function and to use the query objects to determine the validity of our form:
for (let validation of createValidationQueries(form.querySelectorAll('input'))) {
if (validation.type === 'alphabetical') {
let isValid = /^[a-z]+$/i.test(validation.value);
if (!isValid) {
result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`));
}
} else if (validation.type === 'numeric') {
let isValid = /^[0-9]+$/.test(validation.value);
if (!isValid) {
result.errors.push(new Error(`${validation.value} is not a valid ${validation.name} value`));
}
}
}
If both our new test and the existing tests pass, as demonstrated in this pen, then we can make a bigger change; decoupling the validation rules.
The validateItem
function
To remove our hard-coded rules, let’s write a function that takes our rules as a Map
and asserts the validity of our inputs.
Like createValidationQueries
, we’ll write a new test suite before our implementation. Above the implementation of validateForm
, write an empty function called validateItem
. Then in our main describe
suite, write another describe
suite for our new addition:
describe('the validateItem function', function () {
const validationRules = new Map([
['alphabetical', /^[a-z]+$/i]
]);
it(
'should return true when the passed item is deemed valid against the supplied validation rules',
function () {
const validation = {
type: 'alphabetical',
value: 'Bob'
};
const isValid = validateItem(validation, validationRules);
expect(isValid).to.be.true;
}
);
});
We’re explicitly passing a Map
of rules to our implementation from the test as we want to verify its behavior independently of our main function; this makes it a unit test. Here’s our first implementation of validateItem()
:
function validateItem(validation, validationRules) {
return validationRules.get(validation.type).test(validation.value);
}
Once this test has passed, write a second test case to verify that our function returns false
when a validation query is invalid; this should pass due to our current implementation:
it(
'should return false when the passed item is deemed invalid',
function () {
const validation = {
type: 'alphabetical',
value: '42'
};
const isValid = validateItem(validation, validationRules);
expect(isValid).to.be.false;
}
);
Finally, write a test case to determine that validateItem
returns false
when the validation type is not found:
it(
'should return false when the specified validation type is not found',
function () {
const validation = {
type: 'foo',
value: '42'
};
const isValid = validateItem(validation, validationRules);
expect(isValid).to.be.false;
}
);
Our implementation should check if the specified validation type exists in the validationRules
Map before testing any values against their corresponding regular expressions:
function validateItem(validation, validationRules) {
if (!validationRules.has(validation.type)) {
return false;
}
return validationRules.get(validation.type).test(validation.value);
}
Once we see this test passing, let’s create a new Map
above createValidationQueries
, which will contain the actual validation rules used by our API:
const validationRules = new Map([
['alphabetical', /^[a-z]+$/i],
['numeric', /^[0-9]+$/]
]);
Finally, let’s refactor the validateForm
function to use the new function and rules:
function validateForm(form) {
const result = {
get isValid() {
return this.errors.length === 0;
},
errors: []
};
for (let validation of createValidationQueries(form.querySelectorAll('input'))) {
let isValid = validateItem(validation, validationRules);
if (!isValid) {
result.errors.push(
new Error(`${validation.value} is not a valid ${validation.name} value`)
);
}
}
return result;
}
Hopefully, you’ll see that all of the tests pass. Congratulations on using test-driven development to refactor and improve the quality of our code! Your final implementation should resemble this Pen:
See the Pen TDD Form Validation Complete by SitePoint (@SitePoint) on CodePen.
Wrapping Up
By following TDD, we have been able to take the initial implementation of our form validation and separate it into independent and understandable parts. I hope you’ve enjoyed this tutorial and take this practice forward with you into your everyday work.
Have you used TDD on a real-world project? What did you think? If not, has this article persuaded you to give it a try? Let me know in the comments!
If you’d like to learn more about TDD with JavaScript, check out our short mini course Test-Driven Development with Node.js.
This article was peer reviewed by Vildan Softic. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!