Jest
Vortex uses Jest to unit-test the JavaScript behaviors in your custom Drupal modules. This page is for the developers who write those behaviors. It covers running the tests, writing a test for a behavior and reading the coverage report. Jest covers custom modules only, so theme JavaScript needs its own test setup. To test JavaScript in a real browser against the running site, see Behat.
Each test loads a behavior into jsdom, which simulates a browser, then checks what the behavior does in isolation.
Vortex comes with a pre-configured Jest
setup that
finds the tests in every custom module with a js/ directory, so a new
test file runs without any configuration change.
Usage
Run the tests with the following commands:
- Ahoy
- Docker Compose
ahoy test-js # Run all Jest tests.
docker compose exec cli bash -c "npm test" # Run all Jest tests.
Run tests matching a pattern
ahoy test-js doesn't forward extra arguments, so run subsets through
ahoy cli. Jest then runs only the test files whose path matches the
pattern, ys_demo in this example:
- Ahoy
- Docker Compose
ahoy cli "npm test -- ys_demo"
docker compose exec cli bash -c "npm test -- ys_demo"
Run a specific test by name
Jest's -t option runs only the tests whose name matches:
- Ahoy
- Docker Compose
ahoy cli "npm test -- -t 'should increment the value'"
docker compose exec cli bash -c \
"npm test -- -t 'should increment the value'"
Write tests
Drupal JavaScript files wrap their code in an immediately invoked function
expression (IIFE): ((Drupal) => { ... })(Drupal), where Drupal is a
global object. A test sets up the globals the source file needs, then loads
the file with require().
Test file structure
Jest finds a .test.js file anywhere under a module's js/ directory. By
convention, each test file goes in a tests/ subdirectory of js/:
web/modules/custom/my_module/
└── js/
├── my_module.js # Source file (Drupal behavior)
└── tests/
└── my_module.test.js # Jest test file
Test template
Start each test file from the following template:
/**
* @jest-environment jsdom
*/
describe('Drupal.behaviors.myModule', () => {
beforeEach(() => {
localStorage.clear();
global.Drupal = { behaviors: {} };
jest.resetModules();
require('../my_module.js');
});
afterEach(() => {
delete global.Drupal;
});
it('should attach behavior to the context', () => {
document.body.innerHTML = '<div data-my-module></div>';
Drupal.behaviors.myModule.attach(document);
const el = document.querySelector('[data-my-module]');
expect(el.classList.contains('processed')).toBe(true);
});
});
Load Drupal behaviors
The require() call executes the source file's IIFE, which receives
global.Drupal as its Drupal parameter and registers the behavior. After
require(), the behavior is accessible through Drupal.behaviors.myModule.
The jest.resetModules() call before require() clears the module cache,
so the IIFE runs again for each test with a fresh global.Drupal object.
Mock globals
Set globals in beforeEach and clean them up in afterEach:
| Global | Setup | When needed |
|---|---|---|
Drupal | global.Drupal = { behaviors: {} } | Always (required by all Drupal behaviors) |
jQuery | A mock, or global.jQuery = require('jquery') after you add jquery to the root package.json | When the source file uses jQuery or $ |
drupalSettings | global.drupalSettings = { path: { baseUrl: '/' } } | When the source file reads drupalSettings |
localStorage | Provided by jsdom; call localStorage.clear() | When the source file uses localStorage |
Test DOM interactions
The jsdom environment provides document and window. Set up HTML before
each test:
document.body.innerHTML = `
<div data-my-widget>
<button data-action="save">Save</button>
<span data-status></span>
</div>
`;
Drupal.behaviors.myModule.attach(document);
document.querySelector('[data-action="save"]').click();
expect(document.querySelector('[data-status]').textContent).toBe('Saved');
Test timed behavior
Use Jest fake timers for setTimeout and setInterval:
jest.useFakeTimers();
Drupal.behaviors.myModule.startPolling();
jest.advanceTimersByTime(5000);
expect(fetchSpy).toHaveBeenCalledTimes(5);
jest.useRealTimers();
ESLint compatibility
The eslint.config.mjs file includes an override for *.test.js files, so
test files need no ESLint configuration of their own. The override makes the
following changes:
- Adds the Jest globals
- Raises the
max-nested-callbackslimit from 3 to 5, for the nesting that test suites build up - Turns off
jsdoc/check-tag-names, so the@jest-environmentdocblock passes
The lint script runs ESLint with --max-warnings=0, so a warning fails the
lint like an error does. That includes a callback nested deeper than 5
levels and an eslint-disable comment for a rule the configuration doesn't
turn on, which ESLint reports as an unused directive.
Boilerplate
Vortex provides a Jest test boilerplate,
ys_demo.test.js
in the Demo custom module. It tests a counter block through DOM
manipulation, localStorage, click events and fake timers.
This boilerplate test runs in the continuous integration pipeline from the moment you install Vortex. It makes a good starting point for your own JavaScript tests. The installer keeps it only when you keep the Demo module at its Custom modules prompt.
Configuration
See the Jest configuration reference.
All global configuration takes place in the
jest.config.js
file.
By default, Jest discovers and runs test files with the .test.js extension
in web/modules/custom/*/js/ directories and their subdirectories.
The configuration uses the jsdom test environment to provide browser
globals such as document, window and localStorage.
Test discovery
The jest.config.js file scans web/modules/custom/ for modules that
contain a js/ directory, and adds each as an absolute root path for test
discovery. When no module has a js/ directory, web/modules/custom itself
becomes the only root.
The test script in package.json runs Jest with --passWithNoTests, so a
project without any JavaScript tests still passes. Discovery silently misses
a test outside a module's js/ directory or a test whose name doesn't end
in .test.js. The missed file doesn't fail the run, even when it's the only
test file. To confirm that your test ran, check that Jest prints a
PASS or FAIL line with its path, and that the Test Suites: count in
the summary includes it.
Reports
Jest writes only coverage reports and no JUnit test report.
Coverage
ahoy test-js runs the test script in package.json, which passes
--coverage to Jest, so every run collects code coverage. Coverage is
configured in
jest.config.js
with the following settings:
- Source files: all
*.jsfiles inweb/modules/custom/*/js/directories and their subdirectories, excluding*.test.jsfiles - Reporters: text (terminal summary), lcov, HTML and Cobertura XML
- Output directory:
.logs/coverage/jest
After a run, the coverage reports are in the following locations:
| Report | Location |
|---|---|
| Terminal summary | Printed to stdout during test run |
| HTML report | .logs/coverage/jest/index.html, with a copy from the lcov reporter in .logs/coverage/jest/lcov-report/index.html |
| LCOV data | .logs/coverage/jest/lcov.info |
| Cobertura XML | .logs/coverage/jest/cobertura.xml |
Continuous integration
In the continuous integration pipeline, Jest runs on the first container
only (see Test
parallelism).
A failing Jest test fails the build unless VORTEX_CI_JEST_IGNORE_FAILURE
is 1.
The pipeline stores the coverage reports as artifacts. Jest's coverage
doesn't count toward the coverage
threshold or the coverage comment on pull
requests, which read only the PHPUnit report. If you use Codecov, its upload
covers the whole .logs/coverage directory, Jest's cobertura.xml
included.
Ignore failures
Set VORTEX_CI_JEST_IGNORE_FAILURE to 1 to let the build pass despite
failing tests. Jest still runs and reports every failure in the job log. See
Ignore tool
failures.