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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
class SetOfNumbers {
public numbers: Array<number>;
public constructor(initialValues: Array<number> = []) {
this.numbers = initialValues;
}
public add(n: number): void {
if (!this.has(n)) {
this.numbers.push(n);
}
}
public has(n: number): boolean {
return this.numbers.includes(n);
}
}
|
Let’s write some tests (I know, in reality, we would write tests first, shame on me…).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
describe('SetOfNumbers', () => {
describe('when adding a new number to the set', () => {
it('should return the set contains the number', () => {
const setOfNumbers = new SetOfNumbers();
setOfNumbers.add(42);
expect(setOfNumbers.has(42)).to.be.true;
});
});
describe('given empty set of numbers', () => {
it('should return the set does not contain any given number', () => {
const setOfNumbers = new SetOfNumbers();
expect(setOfNumbers.has(0)).to.be.false;
expect(setOfNumbers.has(-5)).to.be.false;
expect(setOfNumbers.has(42)).to.be.false;
});
});
});
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
describe('SetOfNumbers', () => {
const setOfNumbers = new SetOfNumbers();
describe('when adding a new number to the set', () => {
it('should return the set contains the number', () => {
setOfNumbers.add(42);
expect(setOfNumbers.has(42)).to.be.true;
});
});
describe('given empty set of numbers', () => {
it('should return the set does not contain any given number', () => {
expect(setOfNumbers.has(0)).to.be.false;
expect(setOfNumbers.has(-5)).to.be.false;
expect(setOfNumbers.has(42)).to.be.false;
});
});
});
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
describe('SetOfNumbers', () => {
let setOfNumbers: SetOfNumbers;
beforeEach(() => {
setOfNumbers = new SetOfNumbers();
});
describe('when adding a new number to the set', () => {
it('should return the set contains the number', () => {
setOfNumbers.add(42);
expect(setOfNumbers.has(42)).to.be.true;
});
});
describe('given empty set of numbers', () => {
it('should return the set does not contain any given number', () => {
expect(setOfNumbers.has(0)).to.be.false;
expect(setOfNumbers.has(-5)).to.be.false;
expect(setOfNumbers.has(42)).to.be.false;
});
});
});
|
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
|
describe('SetOfNumbers', () => {
let setOfNumbers: SetOfNumbers;
beforeEach(() => {
setOfNumbers = new SetOfNumbers();
});
describe('when adding a new number to the set', () => {
it('should return the set contains the number', () => {
setOfNumbers.add(42);
expect(setOfNumbers.has(42)).to.be.true;
});
});
describe('given empty set of numbers', () => {
it('should return the set does not contain any given number', () => {
expect(setOfNumbers.has(0)).to.be.false;
expect(setOfNumbers.has(-5)).to.be.false;
expect(setOfNumbers.has(42)).to.be.false;
});
});
describe('given Elon Musk is a villain', () => {
beforeEach(() => (setOfNumbers = new SetOfNumbers([1])));
it('should destroy the world eventualy', () => {
// test something
});
});
setOfNumbers = new SetOfNumbers([6]);
describe('given Jef Bezos is a cool guy', () => {
beforeEach(() => (setOfNumbers = new SetOfNumbers([2])));
beforeEach(() => setOfNumbers.add(12));
it('should do blabla', () => {
// test something
});
describe('given inital value ', () => {
beforeEach(() => (setOfNumbers = new SetOfNumbers([3])));
it('should do blabla', () => {
setOfNumbers = new SetOfNumbers([4]);
});
});
describe('please help me', () => {
beforeEach(() => setOfNumbers.add(8));
it('should be written in some cleaner way than this', () => {
// what are the values of setOfNumbers at this line?
console.log(setOfNumbers.numbers);
});
});
describe('given another initial value', () => {
it('should do blabla ', () => {
// test something
});
});
describe('given more complicated tests', () => {
it('should do blabla ', () => {
// test something
});
});
beforeEach(() => (setOfNumbers = new SetOfNumbers([7])));
it('should blabla', () => {
// test something
});
describe('please stop :(', () => {
it('make it stop!', () => {});
});
});
});
|
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
describe('SetOfNumbers', () => {
describe('when adding a new number to the set', () => {
it('should return the set contains the number', () => {
const setOfNumbers = new SetOfNumbers();
setOfNumbers.add(42);
expect(setOfNumbers.has(42)).to.be.true;
});
});
describe('given empty set of numbers', () => {
it('should return the set does not contain any given number', () => {
const setOfNumbers = new SetOfNumbers();
expect(setOfNumbers.has(0)).to.be.false;
expect(setOfNumbers.has(-5)).to.be.false;
expect(setOfNumbers.has(42)).to.be.false;
});
});
});
|
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?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
describe('SetOfNumbers', () => {
describe('when adding a new number to the set', () => {
it('should return the set contains the number', () => {
const setOfNumbers = getSetOfNumbers();
setOfNumbers.add(42);
expect(setOfNumbers.has(42)).to.be.true;
});
});
describe('given empty set of numbers', () => {
it('should return the set does not contain any given number', () => {
const setOfNumbers = getSetOfNumbers();
expect(setOfNumbers.has(0)).to.be.false;
expect(setOfNumbers.has(-5)).to.be.false;
expect(setOfNumbers.has(42)).to.be.false;
});
});
function getSetOfNumbers(initialValues: Array<number> = []): SetOfNumbers {
return new SetOfNumbers(initialValues);
}
});
|
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 beforeEach
:
1
2
3
4
5
6
|
function getSetOfNumbers(initialValues: Array<number> = []): SetOfNumbers {
const factory = new MadeUpFactory();
const setOfNumbers = factory.createSetOfNumbers(initialValues);
setOfNumbers.init();
return setOfNumbers;
}
|
You can even create a mini-test-framework:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
describe('SetOfNumbers', () => {
describe('given some initial value', () => {
it('should return the set contains the value', () => {
givenInitialValues([1]).expectSetHasValue(1);
});
});
function givenInitialValues(values: Array<number>) {
return {
expectSetHasValue(value: number): void {
const setOfNumbers = new SetOfNumbers(values);
const errMsg = `set is supposed to contain value "${value}"`;
expect(setOfNumbers.has(value), errMsg).to.be.true;
},
};
}
});
|
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
let
variable, it shows you just the initial declaration let setOfNumbers: SetOfNumbers;
which tells you nothing.
- You don’t write many
expect
calls 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
it
can be read as a well-written poem.
- You can always add a new functionality and add new asserts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
describe('SetOfNumbers', () => {
describe('given some initial values', () => {
it('should return the set contains the values', () => {
givenInitialValues([1, 2])
.expectSetHasValue(1)
.expectSetHasValue(2)
.expectSetDoesNotHaveValue(3);
});
});
describe('when adding a value', () => {
it('should return the set contains the added value', () => {
givenInitialValues([])
.whenAddingValue(5)
.expectSetHasValue(5);
});
});
function givenInitialValues(values: Array<number>) {
const setOfNumbers = new SetOfNumbers(values);
const methods = {
expectSetHasValue(value: number) {
const errMsg = `set is supposed to contain value "${value}"`;
expect(setOfNumbers.has(value), errMsg).to.be.true;
return methods;
},
expectSetDoesNotHaveValue(value: number) {
const errMsg = `set is not supposed to contain value "${value}"`;
expect(setOfNumbers.has(value), errMsg).to.be.false;
return methods;
},
whenAddingValue(value: number) {
setOfNumbers.add(value);
return methods;
},
};
return methods;
}
});
|
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.
Summary
- 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
let
variables in tests unless necessary.
- As
beforeEach
promotes using let
variable, we should think twice before refactoring the code with the beforeEach
function.
- 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.
Further reading