We often design our code so that one procedure comprises many classes, often grouped by layers. For example, in web dev, that could be controllers, models, repositories, etc:

Layers are good. Often at least two layers are desirable for maintainability: domain and low-level. If you’re like me, you like 2-5 layers but not too many.

Now how do you test such code? It comes naturally to want to test each component separately and mock out the collaborators from other layers. I believe it’s because unit tests are said to be run “in isolation”:

My advice – don’t automatically do this. Aim for this instead:

Why is it better?

More certainty

The goal1 of automated tests is to ensure the app works as intended. In your app, the classes are not run in isolation from each other. The execution of a procedure goes through all the layers, and so should the tests.

Sheer class tests don’t make enough of a safety net or an oracle. I bet you can remember code having 100% test coverage by singular class tests, yet still failing in production.

While asserting the behavior of a single class can give some certainty, asserting the behavior of the entire execution flow gives much more of it.

Better specification

One class can be relatively easy to understand, especially if you have small classes.

Testing small methods of small classes often looks ridiculous:

describe('.toString()', () => {
  it('formats number', () => {
    expect(toString(5)).toEqual('5');
  })
})

Such tests are better than none but don’t help too much in understanding the code. We can often do better.

Good spec offers a triangulation – a different way of looking at code instead of blindly repeating what’s in the code.

The collaboration of classes is usually harder to comprehend. That’s when the tests can help too. Expecting a certain behavior from a group of classes in the form of a test can be massively helpful in understanding what’s going on in the system.

Allows easier refactoring

Now that’s a biggie.

You want to test behavior, not implementation. Ian Cooper explained it best.

You want your tests to treat your SUT as a black box.

If you class-test your system, you do the opposite. Class tests effectively cement your implementation. They effectively discourage changes to particular classes, because it takes more hassle.

Think about what happens when you want to bypass one class by removing one layer. You not only need to change tests of that class but also mocks of collaborator’s tests:

If instead, you access your classes by a top-level class, you do not need to change the tests at all:

Imagine how easy it is to change internals in the latter case. You can change your “black box” to a different implementation, merge classes, split classes, use 3rd parties, etc. You can do all that anytime with no changes to the tests. They constantly serve you as a safety net. How cool is that?

This is the game changer if you play long-term. Implementation should be subject to frequent change. You want your tests to support you in this, not push you away! In order to achieve that you should do away testing every single class – a ball and chain when it comes to refactoring.


But is it unit test?

A common reaction is that it’s not a unit test if you test more than one class. You’re testing integration.

I strongly disagree. In my opinion, the most common mistake devs make when it comes to testing is equating unit tests with class tests.

unit ≠ class
unit test ≠ class test

What’s a unit anyway?

There is no consensus on what a unit is. The definitions are vague. More often instead of definitions you can find traits of unit tests. Unit tests should be:

  • fast
  • repeatable
  • deterministic
  • cheap

None of this is violated by testing more classes at once.

There is also one more trait that is more questionable and it’s isolation. As I said in the beginning, unit tests are also said to be isolated and this word must carry more confusion.

I’d argue the isolation of unit tests means the isolation from other tests. You should be able to run a particular test case without depending on others.

You are not meant to always isolate all collaborators.

Yes, units should not always go through the entire system. The unit can’t cross something. But this something is bigger than just a class.

Tests should be useful

After all, if they are not useful, why use them?

From my experience, the useful definition of unit tests is:

Unit test – the most granular test in your system that fits in memory and doesn’t cross modules.

In contrast – integration tests indicate there are more granular tests below them.

Unit tests should not cross well-defined modules and should not use external 3rd parties like databases and networking.

If such a unit test covers your whole system – great!


Avoid combinatorial explosion

Okay, so you test as many classes at once as possible. But should you always test all of the layers? Of course not. Your main enemy is the combinatorial explosion of test cases.

Imagine you have a domain that can produce 5 different numbers and another class that can present those numbers in 5 different ways.

Each class separately can be covered by 5 test cases. Testing them both combined with an equal level of coverage would take 5*5 = 25 test cases:

You may want to:

  1. Focus on writing exhaustive class tests (these will be your unit tests)
  2. Write only some black-box tests as integration tests

This is a very good idea. My point is that a combinatorial explosion is hard to notice from a distance. Like every other design concept should not be predicted but dealt with only after it’s become a real problem.

My rule of thumb is to start with end-to-end. And when combinatorial explosion starts to cripple – only then split the tests into more.


Summary

  • Don’t equate unit tests with class tests. This is lazy.
  • You can write unit tests that go through many classes in multiple layers.
  • Not only you can do it, but you should. Testing cooperation of many classes is more reliable, makes for better documentation, and most of all – allows for safe refactoring.
  • You should aim to test as many real classes at once as possible while still avoiding the combinatorial explosion of test cases. Start with end-to-end testing of your modules.
  • You don’t have to mock every collaborator of your system under test. Mock on boundaries – 3rd parties or modules

  1. One of the goals. But the most important one ↩︎