Whenever we write a production code we follow certain procedures or techniques. One of the techniques is: do not mutate data unless necessary. Hence – in TypeScript – we try to use as many
const statements as possible and as few
let statements as possible. The main reason for doing it is clarity: if you know the variable
foo is defined as a constant, you can count on it. Whenever you debug something, you know the variable has its initial value and the value is not going to change. On the other hand, if you let the variable be… variable, you need to think about it constantly. The content of the variable can be changed anytime. Sometimes it’s hard to even find the line that changed the data. The code is usually more complex with mutations than without mutations. Hence using immutable data and immutable “variables” almost always produces code that is cleaner.
Not Applying the Technique in Tests
Do we apply the same rule in the tests? Not really. Or not always. Imagine code like this. It is a naive implementation of the native Set class. You can add a new number there and then you can ask if the number is included in the set:
Let’s write some tests (I know, in reality, we would write tests first, shame on me…).
We wrote the two simplest use cases. Everything is looking good, the tests pass. But hey! I see duplicated code! We instantiate the empty set twice. What a horror! Let’s fix it by extracting the empty set to a constant variable:
O-oh. The tests don’t pass. Because we share the
setOfNumbers variable. The variable itself is constant, but we share the reference hence we add a number to the same object we test in the second test. How to fix it?
beforeEach to the rescue!
beforeEach takes a callback that is run before each test, that is, before each
it. We can now fix it:
Uf. The tests pass. The crisis is gone. But hey! Do you see the
let variable on line 2? Suddenly, because we create a new instance of
SetOfNumber per each test, we cannot define the variable as constant. We need to define it as a mutable
let variable. No big deal, you might say. It still is pretty clear because the test file is quite short, it has 22 lines. But in reality, we have bigger test files. The bigger the test file, the bigger the scope in which the variable is accessible. And in general, we don’t want to have variables that are accessible in large scopes. We want variables that are accessible in the shortest, smallest scope possible. Why? Well, imagine we write more tests. And we use slightly more
beforeEach calls (You can nest them! Yay!)
Let’s play a game. I have a riddle for you. Look at this test file. It’s long but it’s still shorter than a regular test file. This one has less than 100 lines.
And my question is: what values are contained in
setOfNumbers variable on line 54? Can you tell? How quickly? Be aware, the variable
setOfNumbers can be changed anywhere between lines 2 and 80. You need to find all the lines re-assigning the
setOfNumbers variable such as line 5 or 24. Sometimes it defines initial values, sometimes not. Moreover, sometimes we add a new number to the set such as on line 35. So, can you tell the resulting value on line 54?
Spoiler: it’s [7, 8] and I had to really execute it to be sure. The
beforeEach on line 70 is the last
beforeEach with reassignment executed and then the
beforeEach on line 50 is executed.
It is horrible, right? The IDE usually doesn’t help much, you just need to know the right order and you must not miss any
beforeEach. The more lines in tests the more likely we end up in a similar situation. This code is not very well-written, on the other hand, it’s easy to find more complicated code in our codebase. We have test files with hundreds of lines and 10+
beforeEach calls. There are ways how to make the tests with
beforeEach calls clearer. E. g. we can always put
beforeEach at the beginning of the block instead of in the middle. But these are minor improvements that does not change the major flaw: we mutate data. And that is evil.
Can we do better?
Take a look at the first example I gave you:
The tests are perfect! No mutations! But there is still little duplication. Can we get rid of it without using mutations and
beforeEach calls? If only a programming language had a way how to solve duplication! What about a simple function?
We introduced a new function
getSetOfNumbers. Obviously, calling this function or calling the constructor directly makes almost no difference. But most of the times, we need to do more setup than this. Imagine we need to create the
SetOfNumbers via some
MadeUpFactory and then we need to initialize the set. We can do all of that in the function and we still don’t need any
You can even create a mini-test-framework:
By doing so, you gain many benefits:
- There is no mutation.
- There is no duplication.
- You can always “follow the code”. You can always see where the values come from. You can always ”go-to-definition” in your favorite IDE and you can see where the variable is defined and what the function does with the values.
- If you use “go-to-definition” for your
letvariable, it shows you just the initial declaration
let setOfNumbers: SetOfNumbers;which tells you nothing.
- If you use “go-to-definition” for your
- You don’t write many
expectcalls in the tests directly, you write a few of them in the framework thus it’s usually easier to write your own error message. If the test above fails, the message would be set is supposed to contain value “3” instead of hazy expected: true, actual: false.
- You get new instances for each test run. No instance is shared between the two tests.
- The code tends to be clearer. The code inside the
itcan be read as a well-written poem.
- You can always add a new functionality and add new asserts:
Don’t like the syntax with the nested functions (lines 19—40)? You can use a different syntax. You can probably use some
chai extension. You can create a dedicated test class for the test case. There are many ways how to write similar code.
- Code that does not mutate anything is usually better, cleaner, and clearer than the code that does mutate data.
- Tests are still code and we should follow the same principles there. If we do not mutate data in production code, we should follow the same rule in tests.
- Hence we should not use
letvariables in tests unless necessary.
letvariable, we should think twice before refactoring the code with the
- There are better ways anyway — we can remove duplication and increase clarity simply by using the most basic instrument of any programming language: by writing a function.