-
Notifications
You must be signed in to change notification settings - Fork 98
Unit Tests
The following article provides a general understanding of the best practices which are expected for writing unit tests in Zowe Explorer. We will start from the set of rules and anti-patterns and move to an example of a “bad” unit test and how it should be fixed. We use Jest for the project unit testing and its documentation is available here: https://jestjs.io/en/
Here you will find what you should take into account when writing unit tests.
There are three steps every unit test should have and these should be clearly separated. Adding a one line comment just before each of them helps increase the test comprehension:
- Arrange - create the environment for the test, typically creating mocks and test data;
- Act - doing the action we want to test, typically calling a function or a method, clicking in UI, triggering some kind of event;
- Assert - validating the result of the action, typically comparing a return value, checking if mocks were called with particular arguments, inspecting the UI for the presence or absence of an element;
It bears mentioning that unit tests should fail when something goes wrong. It may sound like the simplest thing in the universe——because that is the reason tests exist——but it is possible to write a test that passes while the test case fails:
function increment(number) {
// Let's imagine the condition below is a failed scenario
if (number === 7) {
return 123;
}
number++;
return number;
}
describe("Test Something", () => {
it("Test increment", () => {
const res = doSomething(7);
expect(typeof res).toBe('number');
});
});
Here you can see a definitively failing scenario, where the function works incorrectly (number equals to 7), but the Unit Test will always simply pass. So to summarize: be sure that you actually test something in the end.
Another thing which may be obvious, but which many people forget: one test should verify one specific thing. If you ignore this rule and test many things in one Test Case then your code will become unreadable very quickly and even worse: later cases will fail from any minor change in the chain of logic, and it will be very difficult to understand why. Tests will not tell exactly where the problem lies. Let’s take another look:
function increment(number) {
if (number === 7) {
return 123;
}
number++;
return number;
}
describe("Test Something", () => {
it("Test increment", () => {
const res1 = doSomething(1);
expect(typeof res1).toBe('number');
expect(res1).toBe(2);
const res2 = doSomething(7);
expect(typeof res2).toBe('number');
expect(res2).toBe(123);
const res3 = doSomething('2');
expect(typeof res3).toBe('number');
expect(res3).toBe(3);
});
});
Here you can clearly see 3 test cases to be covered inside of 1 unit test:
- Common case of execution;
- Outstanding case of execution;
- Execution with string parameter;
What we would like to see is: Describe => 3 x it
Just to be very specific - I’m not saying you can’t call a testable function more than 1 time in a test case - you can, but you should have a very specific reason for doing that. If you want an example: authorization should be done incorrectly 3 times in a row to give a user a lockout, after which all further requests should be locked. Here you will need to call the same function more than 1 time to generate the testable behavior, but as I said it's a very specific case.
The right way would be:
function increment(number) {
if (number === 7) {
return 123;
}
number++;
return number;
}
describe("Test Something", () => {
it("Test Common execution", () => {
const res = doSomething(1);
expect(typeof res).toBe('number');
expect(res).toBe(2);
});
it("Test Outstanding execution", () => {
const res = doSomething(7);
expect(typeof res).toBe('number');
expect(res).toBe(123);
});
it("Test String parameter execution", () => {
const res = doSomething('2');
expect(typeof res).toBe('number');
expect(res).toBe(3);
});
});
In brief——your unit tests shouldn’t share dependencies, otherwise they will run incorrectly. Here are some guidelines:
- Never share state between different test cases (it blocks);
- If you mock/spy on a function be sure that you reset mocks before other test cases run;
If you still need to share some mocks between test cases, be sure that you move them outside of the exact test case (to describe block level at the very least, or preferably to _mocks_
folder and a logically named file.
If your tests are isolated properly you can use simple resetAllMocks in beforeEach/afterEach to be sure that your mocks are reset.
When you have just implemented a new functionality or are trying to cover an old one, be sure that you test real things: no need to invent impossible cases just to cover it with tests. Here below you can see a test which verifies incorrect behavior in a case which the target function wasn’t implemented for: increment function should increment numbers but for some reason we check how it runs with an object as the input.
function increment(number) {
if (number === 7) {
return 123;
}
number++;
return number;
}
describe("Test Something", () => {
it("Test increment with object", () => {
const res = doSomething({});
expect(isNaN(res)).toBe(true);
});
});
Another problem might be if you’re testing a thing which has no logical value, like:
expect(registerCommand.mock.calls.length).toBe(68);
The number of registration calls has no value or meaning, it’s just a number, so avoid such checks.
Here I would like to give you some tips about the exact implementation of specific checks/structures/mocks.
Don’t verify the number of calls, but instead how the target function was called. I’ve shown a bad example above, but generally speaking you should segregate possible scenarios on:
- Function wasn’t executed - here you shouldn't worry about the function arguments, because execution didn’t occur. The correct way would be to verify it using toHaveBeenCalled;
-
Function was executed - here the arguments/results are more important than simple function calls, because the number of calls give you no information about how exactly the function was executed. For better testing, verify the arguments which were passed into the function(
expect(executeCommand.mock.calls.map((call) => call[0])).toEqual(["workbench.action.nextEditor", "workbench.action.nextEditor", "workbench.action.nextEditor"]);
) or check the results of the call, if you don’t have direct access to the function in the test;
When you want to mock data or a function, check under which category of usage it falls and mock it accordingly:
- Mock is specific to a single test case (e.g. you want to test handling of a specific API error and so you mock the error object for this purpose) - keep the mock inside of the test case (it block), but be sure to use proper variable names and include comments;
- Mock is specific to a group of test cases (e.g. you verify how a function runs in several use cases, so you mock the function in more than one test case...for example some util function) - move the mock to the level of the group (describe block) and be sure that you reset it between test case runs;
-
Mock is used between different groups/files (e.g. you have a mock for an API provider which is used application-wide) - move it to the
_mocks_
folder and sort it into a logically-named file. And, of course, be sure that you reset it between usages;
The principal question for this topic sounds like: do I need to override the function?
- If yes, it means you would like to intentionally cut off the existing function implementation. One good example might be to prevent attempts to connect to the server. Or perhaps you don't care about the logic inside the function at all and you just want to mock the return value;
- If no, it means you need to keep the function's existing implementation, because what it does is valuable for the unit test (e.g. you are trying to verify calls to some util which formats data, and those results are used later);
In the first case you should use jest.fn(); in the second jest.spyOn. Also a good substitute for spyOn may be mockRestore which will allow you to mock one function call in sequence, but will restore original implementation for others. Also keep in mind that the recommended way of mocking functions is to keep mocks on the level of the module where they’re used, e.g.:
const fs = require('fs');
Object.defineProperty(fs, "writeSync", {value: jest.fn()});
fs.writeSync.mockReturnValue(true);
So when you reset/mock return/etc. it will be visible where exactly you have done it.
If you’re not sure about something - feel free to contact the community and ask about your tests!
zowe/vscode-extension-for-zowe
Welcome
Using Zowe Explorer
Roadmaps
Development Process
Testing process
Release Process
Backlog Grooming Process
How to Extend Zowe Explorer
- Extending Zowe Explorer
- Using Zowe Explorer Local Storage
- Error Handling for Extenders
- Secure Credentials for Extenders
- Sample Extender Repositories
Conformance Criteria
v3 Features and Information