5 step method to make test-driven development and unit testing easy

Tags:

What’s the hardest thing in test-driven development or unit testing in general?

Writing tests!

The syntax or the tools aren’t the problem – you can learn those enough to get started in 15 minutes.

The problem is how to take some vague idea in your head about what you want to do, and turn it into something that verifies some function works… before you even wrote the damn thing!

People tell you to use TDD. But how can you possibly write a test for something that doesn’t exist? I don’t even know what the function is going to do yet – or if I actually want two functions instead of one – and instead you want me to think of a test for it? Are you crazy?

How do all those people who tell you to use TDD do it?

That’s the thing – test-driven development requires thinking of your code in a different way. And nobody ever tells you how to do that.

Until now.

How to take a vague idea and turn it into tests

Let me walk you through a system that you can use to do just that – take any kind of idea for what you want to do, and turn it into something tangible, and thus, testable.

I use test-driven development a lot. This is based on exactly the kind of thought process that I go through as I write tests for my code.

This isn’t going to be about the technical details, such as “red-green-refactor”. Rather, I want to focus on the thought process that goes on when a seasoned developer writes code with TDD, and which makes it easy to do.

Remember: All it takes is adjusting your thinking. Yes – it might take some conscious effort at first, but once you do it enough, it becomes routine. Just like writing loops and conditionals, writing tests becomes something that you just do without having to think about it a lot.

Let’s start with our first example: Calculating password strength.

As I started writing this article, I just pulled that idea out of a hat. I’ve never written code to do that until now – I’m going into this as blindly as you would go into writing any real code.

Before we go into the process, there’s one important thing to know. Perfection isn’t the goal! Test-driven development is an iterative process, meaning you work in small repeating steps. Yes, we want to make a good educated guess, but it doesn’t have to be exactly right. Don’t get stuck thinking of some tiny detail, because in software, things always change. One of the great things about TDD is that it makes change easier, so if we don’t get this 100% right on the first try, we’ll just do it again. And that’s exactly how it should be!

Step 1: Decide the inputs and outputs

We start this process from a high level. We don’t care about the implementation just yet.

We have the goal: Calculate password strength. To get to that goal, we usually need some inputs… and then, we get some output based on them.

When you normally start writing code to get to some goal, you’d probably start with a function. You’d probably think of what data the function needs to work, and what kind of results it will give back. We can start this process by doing exactly that – we just won’t write any code for it yet.

So how would this work?

  • The input is easy: It has to be the password.
  • The output is also easy: It has to be some value describing the strength of the password. To keep things simple, let’s say the password is either strong or it isn’t – so we can use a boolean value for the output.

Step 2: Choose function signature

Now that we know what data goes in and what comes out, we need to choose the function signature – that is, what parameters the function takes, and whether it returns something.

This step is again similar to how you would approach writing code without TDD. Before you can write any code for a function, you need to decide what its parameters and return values are.

First, the parameters. What does our function need to work? In this case, it’s simple – all it requires is the password. We can do the whole calculation based entirely on that value, and that value alone.

What about the return value? Simple, since this is a calculation, we can return the result directly. In some more complex cases, the return value might be a promise. Or, instead of returning a value, the function might take a callback parameter – or it might just not return anything at all.

Either way, at this point, we can now decide what calling the function would look like in code:

var strong = isStrongPassword('password string goes here');

Step 3: Decide on one tiny aspect of the functionality

We now know the goal, the data involved and the function signature.

In a non-TDD workflow, you’d jump into writing code for the function now. You might already have some ideas on how this would work – we need to check for this, we need to check for that, the return value is affected by X…

This is where most people run into trouble with TDD. Your head is filled with all these ideas on how to write the function… but you’re not sure of exactly how to lay out the code until you start writing it.

Instead of thinking of all the choices… let’s focus on one tiny thing only.

What is the simplest possible behavior that we need to get a tiny bit closer to our goal?

A common problem is to try and tackle a chunk of behavior that’s really big. If we think of password strength, there’s ideas of different rules like special characters, numbers, password length, etc… Of course it’s hard to think of a test that would cover all of that!

So what’s the simplest possible step we can take to make this function be closer to the ultimate goal of validating a password?

What would be the very first line (or two) of code you would write if you built this function without TDD?

What is the smallest amount of code we can add to bring the function closer to working?

The very simplest rule for password strength might be the empty password. That’s really easy – the output should always be false when the password is empty.

Step 4: Implement test

And just like that, we’ve arrived into implementing the test. I hope that was easier than you expected :)

Notice how all of the previous steps were actually similar to writing code without TDD?

The main difference is that instead of focusing on implementing the function, we’re focusing on how the function would be called, and what happens as a result. That is – we’re thinking about how the function behaves under some conditions.

How the function behaves is what we want to test. Once you start testing behavior under some conditions (such as certain parameters, time of day, whatever), testing becomes a lot easier, because we can look at behavior from the outside. We don’t need to know the implementation if we’re just choosing behavior.

We decided the function takes a password as its only parameter. We also decided it returns a boolean to indicate whether the password was strong or not.

We also chose that for an empty password, the result should always be false – to indicate an empty password is weak.

Let’s plug all of that into a test:

describe('isPasswordStrong', function() {
  it('should give negative result for empty string', function() {
    var password = '';
 
    var result = isPasswordStrong(password);
 
    expect(result).to.be.false;
  });
});

Notice that we easily wrote that without knowing what the exact lines of code in the function are going to be. We decided that given an empty string as a parameter, the result should be false. One simple behavior, which easily translated into a test.

Step 5: Implement code

Very self explanatory. We’ll just add the smallest amount of code that makes the test pass.

function isPasswordStrong(password) {
  if(!password) {
    return false;
  }
}

If we were to continue developing the password strength function, all we do is just repeat this. We’ll go back to step 3, and choose the next tiny step to take. Step 4, add test. Step 5, implement. Repeat.

If you keep advancing in small steps like this, TDD suddenly becomes a lot easier. Yes – you might end up with several tests for a fairly small amount of code, but that’s not a bad thing. TDD helps you in this way to reduce the amount of useless code you might otherwise write, because every line of code you add is verified by a test.

A more elaborate example

Can this system really work for more complex problems? It seem really simple doesn’t it.

Spoiler alert: The answer is yes, it can!

Let’s take a look at a slightly more elaborate example, so you can get a better intuition on how the system would work for something which has more moving parts.

What would be suitably annoying to test?

How about a debounce function? The idea with a debounce function is that it ensures some other function doesn’t get called if it has already been called within a certain amount of time. This is convenient for example if you need to handle scroll events, as typically you would only want the event handling to trigger once the user stops scrolling.

Since this involves time, it should provide a bit more challenge for the 5 step method I laid out.

Let’s start at step 1 again. What are the inputs and outputs of a debounce function?

Since the goal is to create a version of an existing function which doesn’t get called except some amount of time later, the first input should probably be a function. The second input can be the amount of time we want to debounce it.

As its output, the debounce function needs to return the delayed version of the original function, so that it can be called.

Into step 2: Function signature. We’ll pass the two inputs as parameters into the function, and it returns a new function. Simple.

So we end up with something like this:

var delayedFunction = debounce(targetFunction, delayInMilliseconds);

Now the more interesting parts… with step 3, we need to choose a tiny part of the function to implement. There are many possible parts to debounce: There’s a delay, if we call the returned function multiple times, it shouldn’t get called.. unless we call it with a long enough break… etc.

But let’s try to find the simplest thing to start with. If we call the delayed function returned by debounce, it should wait some amount of time, and then run the original function. I think this seems like a suitable place to start from.

And we’re in step 4 already. What would the test for this look like?

Same as before, let’s start by plugging what we chose into a test:

describe('debounce', function() {
  it('should call returned function after delay passes', function(done) {
    var delay = 5;
    var targetFn = function() {
      done();
    };
 
    var delayedFn = debounce(targetFn, delay);
 
    delayedFn();
  });
});

We know that we need the delay in milliseconds, so we’ll start by that. We also need the target function. Since we know the target function needs to be called after the delay, we can use this for a quick and easy way to verify the test passes by calling the done callback within the target function. No call to done – test fails.

Next, we call debounce. As we chose earlier, we pass in the two parameters and grab the output. Lastly, we call the output to test this behavior: After calling the delayed function and a delay, the target function should get called.

We don’t need to know the exact implementation for this – we just plugged in the information from our previous steps directly into a test. The only thing we need to know is that in JavaScript, delays are asynchronous, so we need an asynchronous test. Yes, this is perhaps an implementation detail, but it follows naturally when you know how JavaScript works and is in no way specific to this particular function.

We can go ahead to step 5 and implement the code now:

function debounce(targetFn, delay) {
  return function() {
    targetFn();
  };
}

Wait a minute! That isn’t delaying the function at all!

Yes – we’re doing TDD! Arguably all we need to do is satisfy the test we wrote… and this code makes the test pass.

One might call this a bit cheaty, after all we know this isn’t the correct behavior. One interpretation of TDD only calls for implementing just enough code to make the test pass, so let’s play along.

We’ll go back into step 3 and choose another tiny behavior to implement. A very important behavior would be that the function doesn’t get called too early, like our code does right now.

OK, that’s our second tiny step forwards. Step 4, implement test:

it('should not run debounced function too early', function() {
  var delay = 100;
  var targetFn = function() { };
 
  var delayedFn = debounce(targetFn, delay);
 
  delayedFn();
 
  //but how do we verify it now?
});

We’re going to need Sinon’s fake timers. We can use them to create a fake timer and then advance it forwards, and then ensure the delayed function isn’t called earlier than it’s supposed to.

it('should not run debounced function too early', function() {
  var clock = sinon.useFakeTimers();
 
  var delay = 100;
  var targetFn = sinon.spy();
 
  var delayedFn = debounce(targetFn, delay);
 
  delayedFn();
  clock.tick(delay - 1);
 
  clock.restore();
  sinon.assert.notCalled(targetFn);  
});

First, we enable Sinon’s fake timers. Notice I changed the target function into a Sinon spy. This allows us to later easily verify if the function was called or not.

After we call delayedFn, we use clock.tick to advance the time. However, we only advance it 1 millisecond less than is required for delay. This way, as we call sinon.assert.notCalled, we can ensure the target function didn’t get triggered too early.

If you want to learn more about Sinon’s functionality or fake timers, you should grab my free Sinon.js in the Real-World guide, as it covers this in much more detail.

Conclusion

As you can see from the examples, we can apply the same five steps to all sorts of functions.

If you’re looking for some practice, you could take either of the two functions we started implementing here, and seeing if you can apply the 5 steps to make those functions fully functional.

Test-Driven Development is not difficult once you get the hang of the basics. The challenge is that it requires you to flip your thinking around: Without TDD, you think directly of how you implement something. But with TDD, you think of how you want something to behave.

  1. What are the inputs to our function and what is the output (behavior) we want from calling the function?
  2. Decide how calling the function from code works
  3. Choose the smallest possible piece of behavior for some inputs that you can think of
  4. Write a test which uses those inputs to call the function, and verify the behavior
  5. Implement enough code to make the test pass

If we follow these kinds of simple steps, writing tests up front becomes much easier. As you continue working on the code, you can just repeat between step 3 to 5.

Remember – if you implement some tests and code only to later find out it has to work differently, that’s fine! Go ahead and redo it – We don’t need perfection on the first try, seeking it only gets you stuck. This isn’t just a TDD thing either: you’ll probably need to redo and refactor parts of your code anyway, TDD simply makes it safer because you have tests that verify your code doesn’t break as a result of changing it.

If you want to learn more practical tips on things that make the life of a professional JavaScript developer easier (including stuff on testing), you should sign up for my newsletter with the form below.