Skip to main content
Version: v1

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 test-js # 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 cli "npm test -- ys_demo"

Running a specific test by name​

Jest's -t option runs only the tests whose name matches:

ahoy cli "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:

GlobalSetupWhen needed
Drupalglobal.Drupal = { behaviors: {} }Always (required by all Drupal behaviors)
jQueryA mock, or global.jQuery = require('jquery') after you add jquery to the root package.jsonWhen the source file uses jQuery or $
drupalSettingsglobal.drupalSettings = { path: { baseUrl: '/' } }When the source file reads drupalSettings
localStorageProvided 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 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. 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 *.js files in web/modules/custom/*/js/ directories and their subdirectories, excluding *.test.js files
  • 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:

ReportLocation
Terminal summaryPrinted 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.