Skip to content

Writing your first mock

In this tutorial, we will write a source module that accepts fs as a parameter, and write tests that pass a mock proxy in place of the real module. By the end you will understand how mock.inject() works and why dependency injection makes mocking simpler.


What we will build

A source module src/banner.uc that accepts fs as a parameter, and a test file test/unit/banner_test.uc that uses mock.inject() to supply a virtual filesystem. No config file is needed.


Prerequisites


Step 1 — Create the source module

Write src/banner.uc:

export function get_banner(fs) {
    return fs.readfile('/etc/banner');
};

get_banner receives fs as a parameter instead of importing it directly. In production it is called with the real fs module; in tests we will pass a mock proxy.


Step 2 — Create the test file

Create test/unit/banner_test.uc:

import { describe, it, mock, assert } from 'utest';
import { get_banner } from 'banner';

describe("get_banner()", () => {
    it("returns the seeded banner content", () => {
        mock.inject('fs', {
            data: { '/etc/banner': 'Welcome to OpenWrt\n' }
        }, (m_fs) => {
            assert.match('Welcome to OpenWrt\n', get_banner(m_fs));
        });
    });

    it("returns null when the banner file is absent", () => {
        mock.inject('fs', { data: { '/etc/banner': null } }, (m_fs) => {
            assert.match(null, get_banner(m_fs));
        });
    });
});

mock.inject('fs', ...) builds a proxy and passes it as m_fs to the callback. We hand that proxy directly to get_banner, so the function reads from virtual data instead of the real filesystem. No utest.config.uc is needed.

Seeding a path to null marks it explicitly absent, so readfile returns null. This matters here: for a path you simply leave unseeded, a non-strict mock falls through to the real filesystem — and /etc/banner exists on a real OpenWrt system — so seeding null is how you assert "this file is not there."


Step 3 — Run the tests

utest -l src test/unit/banner_test.uc

You should see (tests run in a randomized order, so yours may differ):

[test/unit/banner_test.uc] test/unit/banner_test.uc (2 tests)
  [PASS] returns the seeded banner content
  [PASS] returns null when the banner file is absent

Summary:
  Suites:  1
  Total:   2
  Passed:  2
  Failed:  0
  Errors:  0
  Time:    5 ms
  Seed:    ...

Step 4 — Confirm the mock is bounded to the callback

The proxy is only valid inside its callback. Add a third test that saves the proxy and tries to use it after the callback has exited:

    it("the proxy dies if used after the callback exits", () => {
        let saved = null;
        mock.inject('fs', { data: { '/etc/banner': 'test content' } }, (m_fs) => {
            saved = m_fs;
            assert.match('test content', get_banner(m_fs));
        });
        // The mock layer has been popped. Using the leaked proxy now dies loudly
        // rather than silently falling through to the real filesystem.
        assert.throws(() => get_banner(saved), /used outside its scope/);
    });

Run again and confirm all three tests pass. The guard means a proxy can never quietly escape its scope and start returning real data where you expected mock data — a leaked proxy fails fast instead.


What we just built

  • A source module that accepts fs as a parameter so tests control which implementation it receives.
  • Tests that use mock.inject() to build a proxy and pass it directly to the function under test.
  • Confirmation that the mock layer is bounded: once the callback exits, using the proxy dies instead of leaking real data into your test.
  • No utest.config.uc required when code receives its dependencies as parameters.

When your code imports fs directly

If your module uses import * as fs from 'fs' rather than accepting fs as a parameter, see How-to: Mock a module with mock.inject() for that pattern.


Next steps