A Gentle Introduction to Test-Driven Development: Creating an Object Validator

nas5w

Nick Scialli (he/him)

Posted on July 27, 2020

A Gentle Introduction to Test-Driven Development: Creating an Object Validator

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:


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 ♻️

  1. Red ❌ - Write a test. Run your tests. The new test fails since you haven't written any code to pass the test yet.
  2. Green βœ”οΈ - Write code that passes your test (and all previous tests). Don't be clever, just write code so your tests pass!
  3. 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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

validator.test.js

const validator = require('./validator');
Enter fullscreen mode Exit fullscreen mode

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If all is well, you should see that our test failed:

validator
  βœ• should return true for an object with no criteria (2ms)
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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'],
    },
  });
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'],
      },
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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!

πŸ’– πŸ’ͺ πŸ™… 🚩
nas5w
Nick Scialli (he/him)

Posted on July 27, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related