Skip to content

Unit Tests

Billie Simmons edited this page Apr 21, 2023 · 2 revisions

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/

Best practices

Here you will find what you should take into account when writing unit tests.

The AAA pattern

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:

  1. Arrange - create the environment for the test, typically creating mocks and test data;
  2. Act - doing the action we want to test, typically calling a function or a method, clicking in UI, triggering some kind of event;
  3. 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;

Unit Tests should be trustworthy

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.

One Unit Test - one use case

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:

  1. Common case of execution;
  2. Outstanding case of execution;
  3. 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);
    });
});

Unit Tests should be isolated

In brief——your unit tests shouldn’t share dependencies, otherwise they will run incorrectly. Here are some guidelines:

  1. Never share state between different test cases (it blocks);
  2. 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.

Each Unit Test should have a clear purpose

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.

Practical advice

Here I would like to give you some tips about the exact implementation of specific checks/structures/mocks.

Verifying functions

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:

  1. 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;
  2. 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;

Know your mocks

When you want to mock data or a function, check under which category of usage it falls and mock it accordingly:

  1. 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;
  2. 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;
  3. 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;

Mocking functions

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!

Clone this wiki locally