Skip to main content
Version: v1

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

Run 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'"

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:

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

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-callbacks limit from 3 to 5, for the nesting that test suites build up
  • Turns off jsdoc/check-tag-names, so the @jest-environment docblock 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 *.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.

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.