Jest
Vortex uses Jest to unit-test the JavaScript behaviors in your custom Drupal modules, and this page is for the developers who write those behaviors. It shows how to run the tests, write a test for a behavior, and read 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 a simulated browser environment and checks what it does in isolation, without a real browser or a Drupal bootstrap.
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.
Running 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"
Running 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'"
Writing 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. Put
each test file in a tests/ subdirectory of that js/ directory:
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);
});
});
Loading 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.
Mocking 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 |
Testing 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');
Testing 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. It
adds the Jest globals, raises the max-nested-callbacks limit from 3 to 5
for the nesting that test suites routinely build up, and turns off
jsdoc/check-tag-names so the @jest-environment docblock passes. Test
files need no ESLint configuration of their own.
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
enable, 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, and it makes a good starting point for your own JavaScript tests. The Vortex CLI 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. It
covers custom modules only: theme JavaScript sits outside its roots, so a
theme needs its own test setup.
The configuration uses the jsdom test environment to provide browser
globals like 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. Adding a new custom module with JavaScript tests
requires no configuration changes.
The test script in package.json runs Jest with --passWithNoTests, so a
project without any JavaScript tests still passes. Jest doesn't warn about a
file that discovery misses, such as a test outside a module's js/
directory or one whose name doesn't end in .test.js. The skipped 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 coverage reports only, 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.
Ignoring 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.