Introduction to Unit Tests

Get introduced to Unit Tests in Elixir.

For most engineers, the testing experience starts with the unit test. Unit tests are the easiest to write because:

  • They have a limited focus.
  • They are less complicated than other tests.
  • They form the basis for all testing.

In this chapter, we’ll learn how to define the scope of our tests, write tests for functional code, and structure a test file. Then we’ll explore some ways to isolate our code under tests.

Defining the unit

A unit test is a software test that focuses on a subset of an application, often removing the overarching context for how the code is used.

It’s common to find a unit test focused on a single function and just the logic in that function, possibly encapsulating private functions available to it. Sometimes it makes more sense to include code from multiple modules, or processes, inside the scope of our unit tests, expanding the scope of the unit.

Why do we test?

We need to keep four goals in mind when designing our tests:

  • Prove that our code does what it’s supposed to do.
  • Prevent breaking changes (regressions).
  • Make it easy to find where our code is broken.
  • Write the least amount of test code possible.

The first two goals are fairly common, but the last two goals will guide us in choosing how much of our code should constitute the unit in the scope of our tests.

Our tests should draw a black box around the code in their scope, which we’ll call the code under test or the application code. This black box defines the places where our test code interacts with our application code. In the case of a unit test, this black box would often treat a single function as the unit.

Scope of Tests

Keeping the scope narrow has its pros and cons.

Advantage Disadvantage
Quickly identifies where the issue is Requires more test code to be written to isolate the code under test

Allowing our test scope (black box) to include a well-tested, functional dependency can help us take advantage of the narrow scope while avoiding some of the steps of isolating the code that uses that dependency.

Isolate our code under test

Our tests need to interact with the code under test in two ways.

  • Our test will call a function (or send a message) with parameters, and it will get a return value.
  • Our tests might need to intercept the calls out and return responses, stepping in for a dependency, if our code depends on other code in our codebase.

The latter interaction is more complex and is called isolating our code under test.

We’ll cover isolating code later in this course, but first, let’s look at when we can get away without strict isolation. The following diagram shows the black box drawn around the code under test.

Because the code under test depends on other code in the codebase, the tests will have to address isolating the code. There are different methods of isolating code, but all of them come with costs, typically in terms of time and test complexity. The presence of purely functional code can impact what needs to be isolated.

Expanding the black box

When our dependency is purely functional and well tested on its own, we can expand our black box to include that dependency, as seen in the following diagram:

Expanding the black box allows us to skip the extra work of isolating our code while not giving up the ability to locate our broken code easily. Our tests are still narrowly focused on the code under test, and because our code is purely functional, the environment in which we’re testing our code is completely controlled, preventing inconsistent results.

By including the dependencies in this fashion:

  • Our tests are easier to write, understand and maintain.
  • We can write less test code while still identifying where our code is broken when a test fails.
  • If our dependency’s tests don’t fail, but we have failures in the tests where we pulled the dependency in, we know that our failures are in the new code and not in our dependency.

Note: The challenge is finding the balance of when we should include our purely functional dependencies in our test scope. Tuning this sense will take some trial and error and learning from experience. A good technique is to initially expand our black box to include those dependencies, and then tighten the scope if we find ourselves having issues debugging our code. Practice will help us hone our sense of when this is the right choice.