Skip to content

Mock Testing

Edward Dean edited this page Sep 8, 2019 · 1 revision

Unit testing

As always, you can look at existing files for reference when adding new unit tests.

Add new unit tests

Where possible, have a separate file for unit testing. If not possible, then the tests may exist in the same file. If there are tests in the same source file, still add a test file in test/unittests/ and then import the source file. See test/unittests/kernel/test_tty.zig as an example of this. This should be avoided as it makes the source files very large.

Add a file at test/unittests/ with the same name and file path of the file being tested but prefixed with 'test_'. For example, test/unittests/kernel/test_vga.zig'. Then add the test file to the test_all.zig.

The mocking framework

This framework allows the user to mock out function calls that are imported not with in the same file as this needs language support. It also allows to test parameters passed into mocked functions.

Add mock file

If one isn't already created, add a file at test/mock/ with the same name and file path of the file being tested but suffixed with '_mock'. For example, test/mock/kernel/vga_mock.zig'.

Set up

As overriding functions aren't a feature in Zig yet, you will then need to copy the contents for the original file into the mocked file then:

  • Add to the top of the mock file:

    const mock_framework = @import("mock_framework.zig");
    pub const initTest = mock_framework.initTest;
    pub const freeTest = mock_framework.freeTest;
    pub const addTestParams = mock_framework.addTestParams;
    pub const addConsumeFunction = mock_framework.addConsumeFunction;
    pub const addRepeatFunction = mock_framework.addRepeatFunction;

    This will set up the mocking for the file.

  • Delete the content of all functions and replace with:

    return mock_framework.performAction(FUNCTION_NAME, RETURN_TYPE, FUNCTION_PARAMS);

    Where:

    • FUNCTION_NAME: The string representation of the function.
    • RETURN_TYPE: The return type of the function being mocked.
    • FUNCTION_PARAMS: The list of parameters for the function.

    For example, If you have a function with declaration of:

    pub fn entry(uc: u8, colour: u8) u16

    Then the mock call will look like this:

    return mock_framework.performAction("entry", u16, uc, colour);
  • Then in the file: test/kernel/mocking.zig add your new mocked file.

  • In the original file where you want to mock out a imported file, originally have:

    const vga = @import("vga.zig");

    But instead will have:

    const vga = if (is_test) @import("mocking").vga else @import("vga.zig");
  • If you are stuck, you can look at existing mock files as a guide.

Using the mocking framework

Here will explain the features of the mocking framework and how it can be used to help with unit testing. This biggest usage of this would be to mock out architecture and kernel specific functions, such as calls to the OUT instruction as this is a I/O operation and can't be done in user land (which the tests are run at). Other features include:

  • Test values passed to a mocked function. As explained above, some function cannot be called so you can check the arguments passed to the mocked function to ensure they are correct.
  • Returning specific values from mocked functions. So this will allow all code paths to be tested where you can force a mocked function to return a specific value that you control.
  • Mock out function calls. These are two ways of doing this bellow, but in short, allows you to call another function (of the same function type as the function being mocked) instead of the original function.

As the import is different in testing (if using the import method above), then the original function won't be available to the file being tested. So if you would like the original function to be called, then follow the steps below for adding user defined mocked functions.

Initialise the mocking framework

All tests that need to use the mocking framework will need at least the following at the start of the unit test:

MOCKED.initTest();
defer MOCKED.freeTest();

Where MOCKED is the import name of the mocked file.

Add test parameters

MOCKED.addTestParams(FUNCTION_NAME, FUNCTION_ARGS, FUNCTION_RETURN);

Here you specify the name of the function being mocked and the expected arguments of the function to be called then a return value from the mocked function (if the mocked function has a return type other than void). The function name will be the string representation, for example, "foo". You can combine multiple arguments into one addTestParams for the same function.

For example, we have a file: foo_mock.zig: (The original code isn't important as we are mocking it out, but to continue with the example, lets say foo computes i + j).

fn foo(i: u32, j: u32) u32 {
    return mock_framework.performAction("foo", u32, i, j);
}

To test its parameters, you will supply the expected i and j values with an additional return value. For example, a file bar.zig:

const foo = if (is_test) @import("mocking").foo else @import("foo.zig");

fn bar() u32 {
    return foo.foo(10, 20);
}

Then in the unit testing file for bar.zig: (test_bar.zig)

const bar = @import("/src/to/bar.zig");

// Import the mocking of foo so can set up the mocking.
const foo = @import("foo_mock.zig");

const expectEqual = @import("std").testing.expectEqual;

test "bar" {
    // This will initialise the mocking framework for foo then free it when the test if finished
    foo.initTest();
    defer foo.freeTest();

    // Set up the expected values, where foo(10, 20) would return 30
    foo.addTestParams("foo", 10, 20, 30);

    // bar() will call the mocked foo() with arguments 10 and 20. Then the mocking framework will
    // compare i with 10 and j with 20 and return a value of 30.
    expectEqual(30, bar.bar());
}

Now lets say we have baz.zig:

const foo = if (is_test) @import("mocking").foo else @import("foo.zig");

fn baz(init: u32) u32 {
    const x = foo.foo(init, 2);
    const y = foo.foo(init + 1, 6);

    return x + y;
}

Now we want to test to multiple calls to foo(): test_baz.zig

const baz = @import("/src/to/baz.zig");

// Import the mocking of foo so can set up the mocking.
const foo = @import("foo_mock.zig");

const expectEqual = @import("std").testing.expectEqual;

test "baz" {
    // This will initialise the mocking framework for foo then free it when the test if finished
    foo.initTest();
    defer foo.freeTest();

    const init_val: u32 = 1;

    // Set up the expected values
    // Here we can chain multiple calls to the same function
    // So here we are expecting the init value to be passed to the foo call with a return values
    // of 3 and 11 respectively.
    foo.addTestParams("foo",
        init_val, 2, 3,
        init_val + 1, 6, 11
    );

    // This is equivalent to: if you want to separate them out
    //foo.addTestParams("foo", init_val, 2, 3);
    //foo.addTestParams("foo", init_val + 1, 6, 11);

    // bar() will call the mocked foo() twice and return 14.
    expectEqual(14, baz.baz(init_val));
}

Add function mock

Here we can mock out a function call all together and call a different function. These functions should be added to the mocking version of the file. There can be two kinds of mocked functions, content of the mocked function is:

  • the same as the original.
  • different to the original.

Mocked function that have the same content of the original function should be prefixed with orig_. For example:

fn orig_foo(i: u32, j: u32) u32 {
    return i + j;
}

Mocked function that have different content of the original function should be prefixed with mock_. For example:

fn mock_foo(i: u32, j: u32) u32 {
    return i - j;
}

If you want multiple mocked functions of the same name, then just suffix with a number.

There are two types of mocking a function:

Consume

This will mock out a function for one call only.

MOCKED.addConsumeFunction(FUNCTION_NAME, FUNCTION);

Here you specify the name of the function being mocked and the function that will be called instead of the mocked function. The function name will be the string representation, for example, "foo".

Here is an example for test of bar.zig (test_bar.zig): We are using the same example for foo as above

const bar = @import("/src/to/bar.zig");

// Import the mocking of foo so can set up the mocking.
const foo = @import("foo_mock.zig");

const expectEqual = @import("std").testing.expectEqual;

test "bar" {
    // Initialise as before
    foo.initTest();
    defer foo.freeTest();

    // Set up the expected values, where foo(10, 20) would return 30
    foo.addConsumeFunction("foo", foo.org_foo);

    // bar() will call the mocked foo: 'org_foo()' with arguments 10 and 20. Then the mocking framework
    // will instead call the org_foo with the argument: 10,20.
    // You can see the mocked function is prefixed with 'orig_' which mean it has the same behavior
    // as the original foo.
    expectEqual(30, bar.bar());
}

In foo_mock.zig:

pub fn orig_foo(i: u32, j: u32) u32 {
    return i + j;
}

So here, when foo is called, the mocking framework will instead call orig_foo and remove it from the mapping so if there was another fall to foo within bar(), then the test will fail.

We could have a mock_ version:

pub fn mock_foo(i: u32, j: u32) u32 {
    expect(10, i);
    expect(20, j);
}

Here we can perform other actions within the mocked function that can check the parameters or do what ever the user would like to do instead of the original behavior.

Repeat

This is similar to the consume mocking but won't remove the mapping of the mocked function when called. Instead, can repeatedly call the same mocked function.

MOCKED.addRepeatFunction(FUNCTION_NAME, FUNCTION);

For example: baz.zig

const baz = @import("/src/to/baz.zig");

// Import the mocking of foo so can set up the mocking.
const foo = @import("foo_mock.zig");

const expectEqual = @import("std").testing.expectEqual;

test "baz" {
    // This will initialise the mocking framework for foo then free it when the test if finished
    foo.initTest();
    defer foo.freeTest();

    const init_val: u32 = 1;

    // Here as foo is called multiple times in baz(), we can set up foo to be called as many time as needed.
    foo.addRepeatFunction("foo", foo.orig_foo);

    // bar() will call the mocked foo() twice and return 14.
    expectEqual(14, baz.baz(init_val));
}

It is always good to test the expected number of calls to a function, so use addConsumeFunction() as many times as expected. As you could have a large number of expected calls, there may be a function called: addFunctionCallN (or something similar). This will allow to specify the number of time a function will be called.

Adding new types

As all type are hard coded in the mocking framework file to make having a list of types easier, if there is a type that isn't available, you will have to add it to the list and update the relevant parts of the code to accommodate the new type. This should be easy.