Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/guides/javascript/react/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ This section provides an end-to-end overview of React development in Moodle, inc

The build documentation explains how React source code is compiled, bundled, and prepared for use in Moodle. It also covers the supporting build tools and common setup requirements.

## See also {/* #see-also */}
## Unit testing

Jest is the JavaScript unit testing framework for React and ESM TypeScript components. The testing guide covers running tests, writing mocks for AMD modules and language strings, module path aliases, and CI integration.

## See also

- [Build tools](./buildtools.md)
- [JavaScript unit testing](./testing.md)
176 changes: 176 additions & 0 deletions docs/guides/javascript/react/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
---
title: JavaScript unit testing
tags:
- react
- javascript
- jest
- testing
- typescript
description: How to write and run Jest unit tests for React and ESM TypeScript components in Moodle.
---

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](https://tracker.moodle.org/browse/MDL-87781)). It targets ESM TypeScript source only. AMD modules cannot run directly in Jest (see [Mocking AMD modules](#mocking-amd-modules)).

:::

## Running tests

```bash
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:

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

To collect coverage:

```bash
npm test -- --coverage
```

## Project structure

| File | Purpose |
|---|---|
| `jest.config.js` | Jest configuration: test environment, module name mapper, transformer, setup files, coverage scope |
| `tsconfig.jest.json` | TypeScript config for Jest: extends the generated aliases, targets CommonJS, includes `jest` and `@testing-library/jest-dom` types |
| `.jest/globalSetup.ts` | Loaded 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`.

```typescript
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.

```typescript
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`.

```typescript
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]`:

```typescript
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

- [Build tools](./buildtools.md)
- [Modules](../modules.md)
- [Writing PHPUnit tests](../../testing/index.md)
- [Jest](https://jestjs.io)
Loading