Skip to main content
Version: main (5.3)

JavaScript unit testing

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

Project structure

FilePurpose
jest.config.jsJest configuration: test environment, module name mapper, transformer, setup files, coverage scope
tsconfig.jest.jsonTypeScript config for Jest: extends the generated aliases, targets CommonJS, includes jest and @testing-library/jest-dom types
.jest/globalSetup.tsLoaded through setupFilesAfterEnv offers shared mock infrastructure for AMD modules and language strings.

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/
String.tsx ← source
tests/
String.test.ts ← test

The same convention applies to plugin components:

public/
mod/
forum/
js/
esm/
src/
MyComponent.tsx
tests/
MyComponent.test.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 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'})]),
);
});
});

Calling an unmocked module throws

If code under test calls requireAsync or requireManyAsync with a module that was not registered via mockAmdModule, the test will throw:

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

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

Registrations reset between tests

Both the AMD module map and the string map are cleared in afterEach. You do not need to clean up manually. Each test starts with a fresh state.

Mocking language strings

mockString(identifier, component, resolved) registers a resolved value for a specific (identifier, component) pair. This delegates to the default core/str mock that is already registered in .jest/globalSetup.ts.

mockString('submit', 'core', 'Submit');
mockString('cancel', 'core', 'Cancel');

await expect(getString('submit', 'core')).resolves.toBe('Submit');

For any string that was not registered, the default mock returns [identifier, component]:

await expect(getString('other', 'core')).resolves.toBe('[other, core]');

This default is useful for snapshot tests and assertions that only care whether a string key was requested, not its exact value.

core/amd coverage exclusion

public/lib/js/esm/src/amd.ts is annotated with /* istanbul ignore file */ and excluded from coverage reports. It is a thin wrapper around requirejs which cannot execute in Jest, so there is nothing meaningful to measure.

Do not remove this annotation.

Module path aliases

TypeScript path aliases (e.g. @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.

The pretest script does this automatically when you run npm test, but you may need to run it manually when working with your IDE's language server.

CommonJS and top-level await

Jest runs under CommonJS ("module": "CommonJS" in tsconfig.jest.json). This means modules that use top-level await (such as core/ajax and core/fetch) cannot be imported directly into tests and must be mocked at the requireAsync boundary.

This is the reason --experimental-vm-modules is not used: running Jest under CommonJS is simpler, avoids the flag entirely, and is sufficient for the ESM-layer testing approach Moodle uses.

CI integration

A Jest job runs in the CI pipeline (.github/workflows/push.yml) in parallel with Grunt and PHPUnit. It installs dependencies and runs npm test.

See also