TypeScript unit tests best practices part 3: definitions and rules

Thiago Oliveira Santos
6 min readJan 8, 2020

--

Basic definitions and rules to write easy-to-maintain unit tests for TypeScript projects

Psst!

You can find part 1 here: Introduction,
And part 2 here: IDE and Project Setup!
And part 4 here: clean test suites structure,
And part 5 here: how to “unit test” (almost) everything in TypeScript!

Let’s start talking about what is a unit test: we’ll define it here as an automated test of a unit of code, testing a single case in an isolated and totally controlled environment, but:

  • what is understood by unit?
  • What is a test case?
  • How we isolate this unit?
  • How we exercise control of a test environment for this specific unit?

Have in mind that all we’ll talk about here is specific for TypeScript but may be applicable for others languages. Depending on the programming language, is less or more difficult to achieve the level of rigor we’ll propose here. Fortunately, in TypeScript/JavaScript we have enough freedom to stress this concept to the limits!

Definition of unit and test case

Let’s define a unit as a method, function or runnable source code (when the file have runnable code outside any type of function).

Okay, so, specifying like this, we have:

A unit test must test a single case of a function or similar structure.

A case, on the other hand, is a possibility of execution. For example:

if (value) {
return 'foo';
}
return 'bar';

This piece of code have just two possibilities: when the if evaluates true, and when it evaluates false. Writing unit tests for a function like this, so, must imply in two test cases.

But we don’t have to start from code to define test cases: we can define our cases from the business rule we’re dealing with, for example, for a blender:

  • It should not blend when the power plug is not connected;
  • It should not blend when the power plug is connected, but the knob is in 0;
  • It should not blend when the power plug is connected and the knob is not in 0, but it is not capped;
  • It should blend when the power plug is connect, the knob is not in 0 and it is capped;

You know, we can think this is far too meticulous, but what could go wrong if a blender factory doesn’t care enough to certify that their blenders will not blend when it is not capped? I’m pretty sure you can think about it.

Definition of isolation

The basic rule we propose is: we must isolate our unit from everything that is not part of it, that is, everything else. Everything else? Everything else. Private methods, methods of the same class, utility functions and, most of all, external services. Why?

  • If you have created tests for unit A and unit B, and made a maintenance in unit A, if your tests are truly isolated, the only test cases that should break are the ones from unit A;
  • Databases, streaming services, file system, external APIs, all of this can be unavailable, can break, can fail. If the tests for your unit do not isolate it from all of this, you can’t guarantee your test will always validate what it is supposed to and, most important, when these tests fails, you can’t be sure that the problem is your unit;
  • If you create a test that tests three methods, when it fails, how can you be sure which one must be fixed? Unit tests must give a precise diagnostic: the unit and the case with problem are the unit and the case that the test refers to;

Well, there are things, of course, you don’t need to worry about, like:

  • Native JavaScript methods, like methods from string, array, numbers, etc... But, of course, if it is interesting for your test, you can;
  • I can’t think on anything else, sorry. Let’s keep it rigorous;

So, if you have a project with, by that definition, 500 units and each one have 10 cases, you must have 5000 test cases? Yes. That’s it.

Sounds exhaustive? Believe me: it will be worth it. A well written code allows us to write well written unit tests, which will prevent a lot a bugs and headaches that otherwise would come in the most inadequate moments.

Unit test are even an enforcing factor for clean code. They’ll make we question more our code so we make less repetition, more decoupling and a code easier to test. As you can infer, a code easier to test is a code easier to understand and to maintain.

Also, with good tests covering your code, you can do difficult changes with much more confidence. Once you get familiar to work with unit tests, you will not want to work without them anymore.

How to have control of the test environment

That’s where the test libraries comes in. With them, you can work with the concept of test doubles. Test doubles are objects that you use to replace the external elements of the subject of your test and also can have a specific behavior programmed. Take this example:

method1() {
if (this.method2()) {
return this.method3();
}
return this.method4();
}

If we want to test method1 isolating it from any other method, we must replace method2, method3 and method4 with tests doubles so we can:

  • isolate method1 from external influences;
  • control the environment of test;

There’re many types of test doubles:

  • Dummy objects: object with no implementation whatsoever, many of them just used to fill parameters and often are not expected to be used by the code being tested in that specific case;
  • Fake objects: objects with a implementation which satisfy the method that will use it, but not suiting for production (for example, you can create a method sum that will always return 2, which will be correct if the parameters received are 1 and 1, but wrong in many others);
  • Stubs or Spies: objects that monitor how many times they have been accessed, called and with which parameters. Also, they can return a fake result. This fake result may be just a value or the result of a fake function;
  • Mocks: this type of object are pre-programmed with expectations, which is, what they expect to receive, and also, what they will answer for each expected value;

There are many test framework that gives more or less emphasis in each type of test double. In our experience, you really don’t need to use all these types. Depending on the style of your test code, you can have different preferences, and different acceptable solutions. In these stories, however, we want to recommend the writing of unit tests focused not only in the result of each test case, but also in how your tested method behaved with the other methods, that is:

  • How many times they have been called?
  • Which parameters have been passed to them?
  • In which order they have been called?
  • How the results received plays with the other calls?

This is what we understand by tests following the idea of Behavior Driven Development. It’s not only to describe with grammatical logic our tests focusing on the expected behavior (which is also very important), but also make sure how the subjects of the tests behave with every other method it uses.

In the next story, we’ll propose a default organization for tests cases and we’ll use the test libraries:

  • mocha as the test runner;
  • sinon for the creation of stubs as the standard test double and the most suitable for ours definitions;
  • chai for assertion of the test expectations;

Also, we’ll often use dummy objects for ours test doubles and will go deeper in this behavior enforcement.

I hope you enjoyed so far and see you in the next story!

--

--