Test-Driven Development (TDD) seems like a great concept, but it's hard to fully understand and appreciate until you see it in action. In this blog post, we're going to implement a JavaScript object validator using TDD.
Please give this post a 💓, 🦄, or 🔖 if it you learned something!
I make other easy-to-digest tutorial content! Please consider:
- Subscribing to my DevTuts mailing list
- Subscribing to my DevTuts YouTube channel
A Quick Primer on Test-Driven Development
TDD flips a lot of "conventional" software development processes upside-down by writing tests first and then writing code that will satisfy those tests. Once the tests are passing, the code is refactored to make sure it's readible, uses consistent style with the rest of the codebase, is efficient, etc. My perferred way to remember this process is Red, Green, Refactor:
Red ❌ -> Green ✔️ -> Refactor ♻️
- Red ❌ - Write a test. Run your tests. The new test fails since you haven't written any code to pass the test yet.
- Green ✔️ - Write code that passes your test (and all previous tests). Don't be clever, just write code so your tests pass!
- Refactor ♻️ - Refactor your code! There are many reasons to refactor, such as efficiency, code style, and readibility. Make sure your code still passes your tests as you refactor.
The beauty in this process is that, as long as your tests are representative of your code's use cases, you'll now be developing code that (a) doesn't include any gold-plating and (b) will be tested each time you run tests in the future.
Our TDD Candidate: An Object Validator
Our TDD candidate is an object validation function. This is a function that will take an object and some criteria as inputs. Initially, our requirements will be as follows:
- The validator will take two arguments: an object to be validated and an object of criteria
- The validator will return an object with a boolean
valid
property that indicates if the object is valid (true
) or invalid (false
).
Later, we will add some more complex criteria.
Setting Up Our Environment
For this exercise, let's create a new directory and install jest
, which is the test framework we'll using.
mkdir object-validator
cd object-validator
yarn add jest@24.9.0
Note: The reason you're installing jest specifically at version 24.9.0 is to make sure your version matches the version I'm using in this tutorial.
The last command will have created a package.json
file for us. In that file, let's change the scripts section to enable us to run jest with the --watchAll
flag when we run yarn test
. This means all test will re-run when we make changes to our files!
Our package.json
file should now look like this:
{
"scripts": {
"test": "jest"
},
"dependencies": {
"jest": "24.9.0"
}
}
Next, create two files: validator.js
and validator.test.js
. The former will contain the code for our validator and the latter will contain our tests. (By default, jest will search for tests in files that end with .test.js
).
Creating an Empty Validator and Initial Test
In our validator.js
file, let's start by simply exporting null
so we have something to import into our test file.
validator.js
module.exports = null;
validator.test.js
const validator = require('./validator');
An Initial Test
In our initial test, we'll check that our validator considers an object valid if there are no criteria provided. Let's write that test now.
validator.test.js
const validator = require('./validator');
describe('validator', () => {
it('should return true for an object with no criteria', () => {
const obj = { username: 'sam21' };
expect(validator(obj, null).valid).toBe(true);
});
});
Now we run the test! Note we haven't actually written any code for our validator
function, so this test better fail.
Do not skip this step! It's always tempting to skip the "red" part of the red-green-refactor cycle, but you should always take the time to fail your tests first. This is so you can test your test... in other words, you need to confirm your test fails when it should, otherwise it's not testing your software correctly.
yarn test
If all is well, you should see that our test failed:
validator
✕ should return true for an object with no criteria (2ms)
Make the Test Pass
Now that we've confirmed the test fails, let's make it pass. To do this, we'll simple have our validator.js
file export a function that returns the desired object.
validator.js
const validator = () => {
return { valid: true };
};
module.exports = validator;
Our tests should still be running in the console, so if we take a peek there we should see our test is now passing!
validator
✓ should return true for an object with no criteria
Continue the Cycle...
Let's add a couple more tests. We know that we want to either pass or fail an object based on criteria. We will now add two tests to do this.
validator.test.js
it('should pass an object that meets a criteria', () => {
const obj = { username: 'sam123' };
const criteria = obj => obj.username.length >= 6
};
expect(validator(obj, criteria).valid).toBe(true);
});
it('should fail an object that meets a criteria', () => {
const obj = { username: 'sam12' };
const criteria = obj => obj.username.length >= 6,
};
expect(validator(obj, criteria).valid).toBe(false);
});
Now we run our tests to make sure the two new ones fail... but one of them doesn't! This is actually fairly normal in TDD and can often occur because of generalized solutions coincidentally matching more specific requirements. To combat this, I recommend temporarily changing the returned object in validator.js
to verify the already-passing test can indeed fail. For example, we can show every test fails if we return { valid: null }
from our validator function.
validator
✕ should return true for an object with no criteria (4ms)
✕ should pass an object that meets a criteria (1ms)
✕ should fail an object that meets a criteria
Now, let's pass these tests. We will update our validator function to return the result of passing obj
to criteria
.
validator.js
const validator = (obj, criteria) => {
if (!criteria) {
return { valid: true };
}
return { valid: criteria(obj) };
};
module.exports = validator;
Our tests all pass! We should consider refactoring at this point, but at this point I don't see much opportunity. Let's continue on creating tests. Now, we'll account for the fact that we'll need to be able to evaluate multiple criteria.
it('should return true if all criteria pass', () => {
const obj = {
username: 'sam123',
password: '12345',
confirmPassword: '12345',
};
const criteria = [
obj => obj.username.length >= 6,
obj => obj.password === obj.confirmPassword,
];
expect(validator(obj, criteria).valid).toBe(true);
});
it('should return false if only some criteria pass', () => {
const obj = {
username: 'sam123',
password: '12345',
confirmPassword: '1234',
};
const criteria = [
obj => obj.username.length >= 6,
obj => obj.password === obj.confirmPassword,
];
expect(validator(obj, criteria).valid).toBe(false);
});
Our two new tests fail since our validator
function doesn't expect criteria
to be an array. We could handle this a couple ways: we could let users either provide a function or an array of functions as criteria and then handle each case within our validator
function. That being said, I would rather our validator
function have a consistent interface. Therefore, we will just treat criteria as an array and fix any previous tests as necessary.
Here's our first attempt at making our tests pass:
validator.js
const validator = (obj, criteria) => {
if (!criteria) {
return { valid: true };
}
for (let i = 0; i < criteria.length; i++) {
if (!criteria[i](obj)) {
return { valid: false };
}
}
return { valid: true };
};
module.exports = validator;
Our new tests pass, but now our old tests that treated criteria
as a function fail. Let's go ahead and update those tests to make sure criteria
is an array.
validator.test.js (fixed tests)
it('should pass an object that meets a criteria', () => {
const obj = { username: 'sam123' };
const criteria = [obj => obj.username.length >= 6];
expect(validator(obj, criteria).valid).toBe(true);
});
it('should fail an object that meets a criteria', () => {
const obj = { username: 'sam12' };
const criteria = [obj => obj.username.length >= 6];
expect(validator(obj, criteria).valid).toBe(false);
});
All our tests pass, back to green! This time, I think we can reasonably refactor our code. We recall we can use the every
array method, which is in line with our team's style.
validator.js
const validator = (obj, criteria) => {
if (!criteria) {
return { valid: true };
}
const valid = criteria.every(criterion => criterion(obj));
return { valid };
};
module.exports = validator;
Much cleaner, and our tests still pass. Note how confident we can be in our refactor due to our thorough testing!
Handling a Relatively Large Requirement Change
We're happy with how our validator is shaping up, but user testing is showing that we really need to be able to support error messages based on our validations. Furthermore, we need to aggregate the error messages by field name so we can display them to the user next to the correct input field.
We decide that our output object will need to resemble the following shape:
{
valid: false,
errors: {
username: ["Username must be at least 6 characters"],
password: [
"Password must be at least 6 characters",
"Password must match password confirmation"
]
}
}
Let's write some tests to accommodate the new functionality. We realize pretty quickly that criteria
will need to be an array of objects rather than an array of functions.
validator.test.js
it("should contain a failed test's error message", () => {
const obj = { username: 'sam12' };
const criteria = [
{
field: 'username',
test: obj => obj.username.length >= 6,
message: 'Username must be at least 6 characters',
},
];
expect(validator(obj, criteria)).toEqual({
valid: false,
errors: {
username: ['Username must be at least 6 characters'],
},
});
});
We now run our tests and find this last test fails. Let's make it pass.
validator.test.js
const validator = (obj, criteria) => {
if (!criteria) {
return { valid: true };
}
const errors = {};
for (let i = 0; i < criteria.length; i++) {
if (!criteria[i].test(obj)) {
if (!Array.isArray(errors[criteria[i].field])) {
errors[criteria[i].field] = [];
}
errors[criteria[i].field].push(criteria[i].message);
}
}
return {
valid: Object.keys(errors).length === 0,
errors,
};
};
module.exports = validator;
Now, the first test and the last test pass, but the others are failing. This is because we changed the shape of our criteria
input.
validator
✓ should return true for an object with no criteria (2ms)
✕ should pass an object that meets a criteria (3ms)
✕ should fail an object that meets a criteria
✕ should return true if all criteria pass
✕ should return false if only some criteria pass
✓ should contain a failed test's error message
Since we know the criteria
implementation in the final test case is correct, let's update the middle four cases to pass. While we're at it, let's create variables for our criteria objects to reuse them.
validator.test.js
const validator = require('./validator');
const usernameLength = {
field: 'username',
test: obj => obj.username.length >= 6,
message: 'Username must be at least 6 characters',
};
const passwordMatch = {
field: 'password',
test: obj => obj.password === obj.confirmPassword,
message: 'Passwords must match',
};
describe('validator', () => {
it('should return true for an object with no criteria', () => {
const obj = { username: 'sam21' };
expect(validator(obj, null).valid).toBe(true);
});
it('should pass an object that meets a criteria', () => {
const obj = { username: 'sam123' };
const criteria = [usernameLength];
expect(validator(obj, criteria).valid).toBe(true);
});
it('should fail an object that meets a criteria', () => {
const obj = { username: 'sam12' };
const criteria = [usernameLength];
expect(validator(obj, criteria).valid).toBe(false);
});
it('should return true if all criteria pass', () => {
const obj = {
username: 'sam123',
password: '12345',
confirmPassword: '12345',
};
const criteria = [usernameLength, passwordMatch];
expect(validator(obj, criteria).valid).toBe(true);
});
it('should return false if only some criteria pass', () => {
const obj = {
username: 'sam123',
password: '12345',
confirmPassword: '1234',
};
const criteria = [usernameLength, passwordMatch];
expect(validator(obj, criteria).valid).toBe(false);
});
it("should contain a failed test's error message", () => {
const obj = { username: 'sam12' };
const criteria = [usernameLength];
expect(validator(obj, criteria)).toEqual({
valid: false,
errors: {
username: ['Username must be at least 6 characters'],
},
});
});
});
And if we check our tests, they're all passing!
validator
✓ should return true for an object with no criteria
✓ should pass an object that meets a criteria (1ms)
✓ should fail an object that meets a criteria
✓ should return true if all criteria pass
✓ should return false if only some criteria pass (1ms)
✓ should contain a failed test's error message
Looks good. Now let's consider how we can refactor. I'm certainly no fan of the nested if
statement in our solution, and we're back to using for
loops when our code still tends towards array methods. Here's a better version for us:
const validator = (obj, criteria) => {
const cleanCriteria = criteria || [];
const errors = cleanCriteria.reduce((messages, criterion) => {
const { field, test, message } = criterion;
if (!test(obj)) {
messages[field]
? messages[field].push(message)
: (messages[field] = [message]);
}
return messages;
}, {});
return {
valid: Object.keys(errors).length === 0,
errors,
};
};
module.exports = validator;
Our tests our still passing and we're pretty happy with how our refactored validator
code looks! Of course, we can and should keep building out our test cases to make sure we can handle multiple fields and multiple errors per field, but I'll leave you to continue this exploration on your own!
Conclusion
Test-Driven Development gives us the ability to define the functionality our code needs to have prior to actually writing the code. It allows us to methodically test and write code and gives us a ton of confidence in our refactors. Like any methodology, TDD isn't perfect. It's prone to error if you fail to make sure your tests fail first. Additionally, it can give a false sense of confidence if you're not thorough and rigorous with the tests you write.
Please give this post a 💓, 🦄, or 🔖 if it you learned something!
Top comments (2)
Always good to see someone spread some TDD love ❤️
I was just about writing a TDD example as well, so I might steal some concepts here 😅
Please do! I fully endorse spreading the good TDD word.