Skip to main content
Version: main (5.3)

JavaScript unit testing

Since 5.3

Moodle uses Jest as the JavaScript unit testing framework. Tests run against TypeScript source files in public/**/js/esm/ and are integrated into the CI pipeline alongside PHPUnit and Grunt.

note

JavaScript unit testing with Jest was introduced in Moodle 5.3 (MDL-87781). It targets ESM TypeScript source only. AMD modules cannot run directly in Jest (see Mocking AMD modules).

Running tests

npm test

The pretest script runs grunt jsconfig first to regenerate tsconfig.aliases.json. This is required because the alias file is gitignored and Jest needs it to resolve module path mappings.

To run a single file or pattern:

npm test -- --testPathPatterns=public/lib/js/esm/tests/String.test.ts

To collect coverage:

npm test -- --coverage

Where to put tests

Test files must match the glob **/esm/tests/**/*.test.{ts,tsx}. Place them alongside the source they test:

└── public
└── lib
└── js
└── esm
├── src
│ └── output
│ └── ExampleComponent.tsx # The source file being tested
└── tests
└── output
└── ExampleComponent.test.ts # The test for ExampleComponent.tsx

The same convention applies to plugin components:

└── public
└── mod
└── forum
└── js
└── esm
├── src
│ └── output
│ └── ExampleComponent.tsx # The source file being tested
└── tests
└── output
└── ExampleComponent.test.ts # The test for ExampleComponent.tsx

Writing a test

Tests use standard Jest describe/it/expect syntax. TypeScript source is transformed by ts-jest and the test environment is jsdom.

import {getString} from '@moodle/lms/core/String';

describe('getString', () => {
it('returns the resolved string', async () => {
mockString('pluginname', 'mod_forum', 'Forum');

await expect(getString('pluginname', 'mod_forum')).resolves.toBe('Forum');
});
});

Mocking

Mocking of class and module dependencies of your unit under test is strongly encouraged. In some cases it is mandatory.

Some functionality, such as the ability to mock strings and AMD modules, is provided as part of the Moodle core and these mocks are reset between tests.

You should not need to clean mocks up manually. Each test starts with a fresh state.

You can clear up any additional test state within your test file using:

  • beforeEach()
  • afterEach()

See the Jest Setup and Teardown documentation for further information.

Mocking strings

Because of the way in which Moodle's string module fetches strings using a web service, all strings are mocked to a standard value of:

[identifier, component]

For cases where your tests expect a specific string value, you can mock values using the global mockString method:

describe('@moodle/lms/mod_example/Example', () => {
beforeEach(() => {
mockString('dofabuluousthings', 'more_example', 'Do something fabulous!!');
});
it('Renders an Example component', () => {
await act(async() => {
render(<Example />);
});

expect(screen.getByText('Do something fabulous!!!')).toBeInTheDocument();
});
});

Mocking AMD modules

AMD modules (anything loaded via requirejs) cannot run inside Jest. The Jest module system and the AMD loader are completely separate environments, so requirejs, M, jQuery, and other Moodle globals are not available.

The correct approach is to test the ESM layer and mock everything below it. The global mockAmdModule() helper registers a mock object for any AMD module identifier. Jest's mock of core/amd intercepts calls to requireAsync and requireManyAsync and returns the registered object.

import {requireAsync} from '@moodle/lms/core/amd';

describe('my component', () => {
it('fetches data via core/ajax', async () => {
const mockAjax = {call: jest.fn().mockResolvedValue([{data: 'ok'}])};
mockAmdModule('core/ajax', mockAjax);

// code under test that calls requireAsync('core/ajax')...

expect(mockAjax.call).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({methodname: 'my_ws_method'})]),
);
});
});
Unmocked AMD modules

If code under test calls requireAsync or requireManyAsync with a module that has not been registered using mockAmdModule then the test will throw an error:

Error: Unexpected call to requireAsync with module name: core/notification

This is intentional: missing mocks produce a hard failure rather than silent wrong behaviour.

Handling redirects

If your code causes the page to redirect, then it must use the @moodle/lms/core/location module's redirect method, for example:

import {redirect} from '@moodle/lms/core/location';

export default function () {
redirect('https://example.com');
}

Moodle automatically mocks the redirect function and allows you to specify the expected value before calling your method using the expectRedirect() helper:

import Example from '@moodle/lms/mod_example/Example';

describe('@moodle/lms/mod_example/Example', () => {
it('Redirects to the user documentation', () => {
expectRedirect({urlContains: 'example.com'});

Example();
});
});

The expectRedirect method accepts:

  • a url parameter with an exact matching URL; or
  • a urlContains parameter with a partial match.

If a redirect occurs without an expectRedirect() call, an Error will be thrown.

Expected redirects are reset between tests.

Module path aliases

TypeScript path aliases (such as @moodle/lms/core/String) are resolved at test time from tsconfig.aliases.json, which is generated by grunt jsconfig and gitignored. If you encounter import resolution errors, run grunt jsconfig first.

CI integration

A Jest job runs in the GitHub Action pipeline (.github/workflows/push.yml) in parallel with Grunt and PHPUnit.

See also