Introduction to Unit Tests


Unit tests test units of code in isolation from other parts. Along with functional tests and integration tests, they play an important role in software testing, Test Driven Development (TDD) and continuous delivery — a software development methodology that focuses on delivering software in small cycles, known as iterations.

Your tests are your first and best line of defense against software defects.

For much of the time, developers work at the module level, which is where unit tests shine. During that time, unit tests can provide continual real-time feedback, helping you think carefully about the problem and catch errors before they become a problem.

Unit tests combine many features that make them your secret weapon to application success:

  • Design aid: Writing tests first gives you a clearer perspective on the ideal API design.
  • Feature documentation (for developers): Test descriptions enshrine in code every implemented feature requirement.
  • Test your developer understanding: Does the developer understand the problem enough to articulate in code all critical component requirements?
  • Quality Assurance: Manual QA is error prone. In my experience, it’s impossible for a developer to remember all features that need testing after making a change to refactor, add new features, or remove features.
  • Continuous Delivery Aid: Automated QA affords the opportunity to automatically prevent broken builds from being deployed to production.

What is TDD?

TDD stands for Test Driven Development. The idea is that before you implement a feature, you write a test that will run the code and prove whether or not it works as expected.

TDD works in a cycle:

Red, Green, Refactor

  1. Write a test
  2. Watch the test fail (Red)
  3. Write the implementation & watch the test pass (Green)
  4. Improve the code & run the tests again (Refactor)

It’s important to watch the test fail before you start writing the implementation because it’s possible to write invalid tests. By watching the test fail before the implementation, and then pass after the implementation, you’ve tested both states of the test. In other words, your implementation acts like unit tests for your tests. That can’t happen if you write the implementation first.

Again, if you don’t watch a test fail before you implement the code, you haven’t thoroughly tested both the pass and fail states of the test itself. Watching your tests fail before you make them pass gives you increased evidence that the tests are valid, which provides better evidence that the code you’re testing is valid.

The Science of TDD

According to case studies by Microsoft Research, IBM, and Springer:

TDD has been shown by numerous studies to reduce bug density, and that is very significant, because the cost of finding and fixing bugs after releasing software to users is orders of magnitude higher than the cost of finding and fixing bugs during the course of normal development.

TDD helps catch bugs as you’re working, which means that you maintain the context of your work as you fix the bugs you find.

Test Before vs Test After

Many studies have compared test-first methodology (TDD) to test-after methology, where tests are added to code after the code implementation has been written. Most of them found that test-first methodology results in better code coverage (more tests), which correlates with decreased bug density.

Failing Unit Tests are Bug Reports

When a test fails, that test failure report is often your first and best clue about exactly what went wrong. The secret to tracking down a root cause quickly is knowing where to start looking. That process is made much easier when you have a really clear bug report.

A failing test should read like a high-quality bug report.

What’s in a good test failure bug report?

  1. What are you testing?
  2. What should it do?
  3. What was the output (actual behavior)?
  4. What was the expected output (expected behavior)?
  5. How do you reproduce the failure?

The first four questions should be answered by the failure message, without even looking at the test code. The last question should be clearly answered when you look at the code for the failed test.

TDD by Example

We’ll start with something trivial at first: let’s write a range() function that takes a start number and an end number, and returns an array with every number from start to end, inclusive. Here’s the signature:

So, how can we test this?

Start with this simple test:

This looks pretty good. Before we implement anything though, we should watch the test fail.

If you tried to run this, you’d get ReferenceError: range is not defined. For now, let’s make range a noop function at the top of the file:

const range = () => {};

Now you can watch the test run and see the output:

Let’s write something that satisfies the test:

This time we get:

But I’m sensing that we’re not testing enough, yet. What happens if we add another test and start from 3?

Oops. Looks like our first crack didn’t account for this edge case. Let’s fix it. Math to the rescue!:

Sure enough, when we run the tests again, they pass!

Just to be thorough, when dealing with numbers, I like to see how things work with zero:

With our new code, this one passes, but it would have failed with our first implementation. Good thing we caught that bug.

What about a range of 0 - 0? That should be [0]:

Sure enough, that old code would have failed this test, too:

But with the fixed code, we pass that test, as well:

Great. Much better test coverage. At this point, I’d feel pretty safe trying out different implementations without worrying that I’m going to forget to test an edge case.

You can play with the range example and try your own implementations by forking it on CodePen.

Live Developer Console

I like to set up a developer console to give me feedback every time I save a source file in the project. To set that up in your project:

Then add a new watch script in your package.json file:

To start the dev console, just type:

Now when you save a file, your tests will run again automatically.

If you want to browse the new code:

A Generator Library

Let’s turn the range example into an ES6 generator library. A generator is a function capable of producing multiple values by returning an iterator object. To learn more, read 7 Surprising Things I Learned Writing a Fibonacci Generator.

Step 1: Clone the repository

git clone git@github.com:ericelliott/grange.git && cd grange
git checkout step1 && npm install
npm run watch

You should see an error message: “Error: No tests!”.

Let’s take a look at Package.json. You should see the following scripts:

As you can see, the npm run watch command runs the watch script, which clears the screen and runs the tests. Leave that console open and the tests will run again every time you save file changes.

Your mission, should you choose to accept it, is to write tests and implementations for the following features:

Tests can be added in source/test/index.js. Write one test, watch it fail, implement the feature, then watch the test pass prior to moving on to the next step. To look at solutions, you can browse the tags labeled step2, step2,… step6.

To check out a tag:

To get your changes back:

Walkthroughs






Conclusion

Remember:

  • Unit tests test individual units (or modules) of functionality independent from the rest of the app.
  • Unit tests are great for live developer feedback, to help you remember, and automatically test all the edge cases every time you make a change.
  • Write tests first for better test coverage and up to 80% fewer bugs.
  • Always watch your test fail before you implement the code to ensure that your test is valid.

Use unit tests to give you continuous feedback while you code. It’s a great way to catch errors early and be sure that your code does what you think it does. Give TDD a couple weeks. Chances are, you’ll want to stick to it.