6 Tips for Beginners to Write Good Unit Tests
Zhiyue Yi
Posted on October 11, 2020
Visit my Blog for the original post: 6 Tips for Beginners to Write Good Unit Tests
TLDR;
Unit tests are one of the fundamental components which safeguard the quality of our applications. It takes little efforts to write but produces much values in term of validating the correctness of the codes.
There are many articles talking about what unit test is and why it is important and necessary to write unit tests. In this blog post, I would not talk about these because I assume you are already aware of them :) So let's just shorten the long story and get to the tips directly.
1. Make Your Function Short for Easier Testing
I remember the time I just started with programming, I tended to put everything inside of one function. As long as I could make it work, I was satisfied. However, in reality, such function with a long long procedures may result that the function is hard to test.
Just imagine, a function with tens of condition checking and so many if-else blocks turn your codes into Lasagne. There can be so many possible outcomes from your function. To test this function, you have to write 20 or 30 unit tests to test all the branches of the conditions. That just sounds super tedious!
// Codes
function superLongFunction() {
if (conditionA) {
// A bunch of operations
// ...
// ...
if(conditionB) {
// A bunch of operations
// ...
// ...
return;
}
// A bunch of operations
// ...
// ...
} else if (conditionC) {
someList.forEach(item => {
if (item.flag) {
// A bunch operations
// ...
// ...
}
if(item.flag2) {
// A bunch of operations
// ...
// ...
}
});
}
// A bunch of operations
// ...
// ...
}
// Tests
describe('superLongFunction' () => {
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition A', () => { /* ... */ })
it('should ... condition B', () => { /* ... */ })
it('should ... condition B', () => { /* ... */ })
it('should ... condition B', () => { /* ... */ })
it('should ... condition B', () => { /* ... */ })
it('should ... condition B', () => { /* ... */ })
it('should ... condition C', () => { /* ... */ })
it('should ... condition C', () => { /* ... */ })
it('should ... condition C', () => { /* ... */ })
it('should ... condition C', () => { /* ... */ })
it('should ... condition C', () => { /* ... */ })
it('should ... condition C', () => { /* ... */ })
it('should ... condition Others', () => { /* ... */ })
it('should ... condition Others', () => { /* ... */ })
it('should ... condition Others', () => { /* ... */ })
it('should ... condition Others', () => { /* ... */ })
});
What is worse is that, if you update some of the logics, or refactor the function in future, it can be a real disaster for you to update so many unit tests!
How should we improve that? Well, it is simple by just breaking the super giant function into a multiple of small functions. In this way, you turn a large scope into several smaller scopes, with smaller sets of unit tests. Every set of unit tests just focuses on that particular function only, so that they don't have to bother about the changes in other functions!
// Codes
function shortFunction() {
if (conditionA) {
doA();
checkB();
doRestOfA();
} else if (conditionC) {
someList.forEach(item => {
doC(item);
});
}
doOthers();
}
function checkB() {
if (conditionB) {
doB();
}
doA();
}
function doC(item) {
if (item.flag) {
itemDo1();
}
if(item.flag2) {
itemDo2();
}
}
function doA() { /* A bunch of operations */ }
function doRestOfA() { /* A bunch of operations */ }
function doB() { /* A bunch of operations */ }
function doOthers() { /* A bunch of operations */ }
function itemDo1() { /* A bunch of operations */ }
function itemDo2() { /* A bunch of operations */ }
// Tests
describe('shortFunction' () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
describe('doA', () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
describe('doRestOfA', () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
describe('doB', () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
describe('doOthers', () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
describe('itemDo1', () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
describe('itemDo2', () => {
it('should ...', () => { /* ... */ })
it('should ...', () => { /* ... */ })
});
2. Don't Forget about Sad Paths
Sometimes we tend to be optimistic about our applications, just like we believe users would do exactly what we assume they would do. But in reality, there are always surprise from either your code or your users (LOL).
In unit tests, we should not only care about happy paths, but also we should consider sad paths.
So what are happy path and sad path?
It's just like the 2 sides of coins. If there is an if
, then most likely you would have at least 2 test cases.
// Codes
function check() {
if (flag) {
// do something
} else {
// do something
}
}
// Tests
describe('check', () => {
it('should ... when flag is true', () => { /** some test codes **/ })
it('should ... when flag is false', () => { /** some test codes **/ })
});
Or if your function is possible to throw some errors, you would have situation when function is operating normally and function is throwing errors.
function haveATry() {
try {
// do something
} catch {
// handle error
}
}
// Tests
describe('check', () => {
it('should ...', () => { /** some test codes **/ })
it('should ... when error is thrown', () => { /** some test codes **/ })
});
When we are writing tests, if we always reminds ourselves about testing both happy paths and sad paths, are also forced to consider unexpected situations and how we will handle those cases gracefully. Eventually, we can build our application as robust as possible.
3. Tests Should Remain Dumb
When we are doing development, we try to be smart in implementations because smart codes may probably improve our code readability, flexibility or extensibility.
But when it comes to testings, we should instead be dumb, in terms of not writing logical conditions inside of our tests.
I have seen some for loops and if else blocks in tests such as
describe('some test suite', () => {
it('should ...', () => {
// Some testing codes...
for (let i = 0; i < list.length; i++) {
if (someCondition) {
expect(someVariable).toBe(someValueA);
} else if (someOtherCondition) {
expect(someVariable).toBe(someValueB);
} else {
expect(someVariable).toBe(someValueC);
}
}
// Some testing codes...
});
});
Well, one of the reasons why we have tests is because we are human and we make mistakes when we write logics, especially complex logics.
And now, in tests, we are writing complex logics, which is possibly introducing bugs to your tests. And the sad thing is that we don't have more tests to test our tests (LOL).
Therefore, make your tests remain dumb and try not to write "smart" codes in your tests. Instead, you should do
describe('some test suite', () => {
it('should ... when someCondition is true', () => {
// Some testing codes...
expect(someVariable).toBe(someValueA);
// Some testing codes...
});
it('should ... when someOtherCondition is true', () => {
// Some testing codes...
expect(someVariable).toBe(someValueB);
// Some testing codes...
});
it('should ... when both someCondition and someOtherCondition are false', () => {
// Some testing codes...
expect(someVariable).toBe(someVariable);
// Some testing codes...
});
});
Or you could try data driven testing, which we are going to discuss about in tip 6.
4. Mock Functions for Dependencies
When you are building modern applications, inevitably you have to deal with dependencies, such as external libraries or plugins. Then, you call their functions inside of your own functions and then you have to test it.
The question is, how are we going to deal with them in our unit tests?
Take a look at the following codes:
// Codes
function greetings() {
const today = dayjs();
const hour = today.hour();
if (hour >= 5 && hour < 12) {
return 'morning';
}
if (hour >= 12 && hour < 18) {
return 'afternoon';
}
if (hour >= 18 && hour < 22) {
return 'evening';
}
return 'midnight';
}
// Tests
describe(() => {
expect(greetings()).toBe('afternoon');
})
Do you think such tests are reliable and stable? If you run tests at 3pm, your tests are just fine and you can enjoy your afternoon tea, but if you run tests at 7pm, your tests are going to break and you will have to work overtime (LOL).
So no, such tests are not stable, because it depends on an external library called dayjs. How are we going to solve it?
We are going to mock the behaviour of dayjs by forcing it to return the value we want to test. We can use jest.fn()
or sinon.stub()
depending on which testing framework you are using.
// Tests
jest.mock("dayjs");
describe("greetings", () => {
const mockDayjsHour = jest.fn();
beforeAll(() => {
dayjs.mockImplementation(() => ({
hour: mockDayjsHour,
}));
});
afterEach(() => {
jest.clearAllMocks();
});
it("should return morning when the time is 5:00", () => {
mockDayjsHour.mockImplementation(() => 5);
expect(greetings()).toBe("morning");
});
it("should return morning when the time is 12:00", () => {
mockDayjsHour.mockImplementation(() => 12);
expect(greetings()).toBe("afternoon");
});
it("should return morning when the time is 18:00", () => {
mockDayjsHour.mockImplementation(() => 18);
expect(greetings()).toBe("evening");
});
});
As you can see from the code snippets, in each test, we mock the dayjs().hour()
to return different values, so that we can ensure in that test, the hour returned is determined, not varied by our actual time. And then, we can test the string returned by function given the determined hour here.
5. Use Boundary Testing Approach
Boundary testing is a very useful technique to test functions with inputs as range of values. When we have a range of values to be tested, such as the hours in the previous example, which is ranged from 0 to 23, instead of randomly picking up values in the range, we can use boundary testing approach to determine what the values are the ones we should tests.
For example, there are a total of 4 possible outcomes from this function, namely "morning"
, "afternoon"
, "evening"
and "midnight"
, every one of which has its hour range, with both upper bound and lower bound.
Greeting | Range | Lower bound | Upper bound |
---|---|---|---|
Midnight | [0 - 5) | 0 | 4 |
Morning | [5 - 12) | 5 | 11 |
Afternoon | [12 - 18) | 12 | 17 |
Evening | [18 - 23) | 18 | 21 |
Midnight | [23 - 24) | 22 | 23 |
From this table, we can know that, the minimum and maximum hour which can lead to "afternoon"
are 12 and 17, that means
- We don't need to tests the numbers in between 12 and 17 as they must be
"afternoon"
if tests of 12 and 17 both pass. - Any value outside of 12 and 17 (<12 or >17) are definitely not
"afternoon"
Therefore, we can update our tests to something like:
jest.mock("dayjs");
describe("greetings", () => {
const mockDayjsHour = jest.fn();
beforeAll(() => {
dayjs.mockImplementation(() => ({
hour: mockDayjsHour,
}));
});
afterEach(() => {
jest.clearAllMocks();
});
it("should return morning when the time is 5:00", () => {
mockDayjsHour.mockImplementation(() => 5);
expect(greetings()).toBe("morning");
});
it("should return morning when the time is 11:00", () => {
mockDayjsHour.mockImplementation(() => 11);
expect(greetings()).toBe("morning");
});
it("should return morning when the time is 12:00", () => {
mockDayjsHour.mockImplementation(() => 12);
expect(greetings()).toBe("afternoon");
});
it("should return morning when the time is 17:00", () => {
mockDayjsHour.mockImplementation(() => 17);
expect(greetings()).toBe("afternoon");
});
it("should return morning when the time is 18:00", () => {
mockDayjsHour.mockImplementation(() => 18);
expect(greetings()).toBe("evening");
});
it("should return morning when the time is 22:00", () => {
mockDayjsHour.mockImplementation(() => 21);
expect(greetings()).toBe("evening");
});
it("should return midnight when the time is 22:00", () => {
mockDayjsHour.mockImplementation(() => 22);
expect(greetings()).toBe("midnight");
});
it("should return midnight when the time is 23:00", () => {
mockDayjsHour.mockImplementation(() => 23);
expect(greetings()).toBe("midnight");
});
it("should return midnight when the time is 00:00", () => {
mockDayjsHour.mockImplementation(() => 0);
expect(greetings()).toBe("midnight");
});
it("should return midnight when the time is 4:00", () => {
mockDayjsHour.mockImplementation(() => 4);
expect(greetings()).toBe("midnight");
});
});
6. Use Data-driven Testing
With the previous example, you might notice that there are too many redundant codes for testing this one particular function. Is there any way to optimise it?
Yes, there is. You can use data-driven testing to test different conditions with different consequences. That means, the logic of your testing is not changed, what is changed is only your testing data and result. In Jest, you can use it.each
function to achieve your purpose.
jest.mock("dayjs");
describe("greetings", () => {
const mockDayjsHour = jest.fn();
beforeAll(() => {
dayjs.mockImplementation(() => ({
hour: mockDayjsHour,
}));
});
afterEach(() => {
jest.clearAllMocks();
});
it.each`
hour | greeting
${5} | ${'morning'}
${11} | ${'morning'}
${12} | ${'afternoon'}
${17} | ${'afternoon'}
${18} | ${'evening'}
${21} | ${'evening'}
${22} | ${'midnight'}
${23} | ${'midnight'}
${0} | ${'midnight'}
${4} | ${'midnight'}
`('should return $greeting when the time is $hour:00', ({hour, greeting}) => {
mockDayjsHour.mockImplementation(() => hour);
expect(greetings()).toBe(greeting);
})
});
In it.each
, you can pass in a table as a string literal like the code above, or a nested array like this. By providing the conditions and the expected results, you can reuse the same piece of logics to tests. Also, it is more readable than directly using for loops.
Code for Demo
You can see this Gist for the demo code of these unit tests.
Posted on October 11, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 11, 2024