Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"files": ["*.test.js"],
"env": { "jest": true },
"rules": {
"no-eval": "off",
"global-require": "off",
"import/extensions": "off",
"max-nested-callbacks": ["warn", 5],
"jsdoc/check-tag-names": "off"
}
Expand Down
63 changes: 44 additions & 19 deletions .vortex/docs/content/development/jest.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,26 @@ For running tests, configuration, and CI settings, see the

## Test file structure

Test files are co-located with source files in the `js/` directory of each
custom module:
Test files are placed in a `tests/` subdirectory within the `js/` directory of
each custom module:

```text
web/modules/custom/my_module/
└── js/
├── my_module.js # Source file (Drupal behavior)
└── my_module.test.js # Jest test file
├── my_module.js # Source file (Drupal behavior)
└── tests/
└── my_module.test.js # Jest test file
```

Jest automatically discovers `*.test.js` files in `web/modules/custom/*/js/`
directories. Adding a new module with tests requires no configuration changes.
directories and their subdirectories. Adding a new module with tests requires
no configuration changes.

## Writing tests

Drupal JavaScript uses the IIFE pattern `((Drupal) => { ... })(Drupal)` where
`Drupal` is a global object. Tests load these source files using `eval()` after
setting up the required globals.
`Drupal` is a global object. Tests load these source files using `require()`
after setting up the required globals.

### Test template

Expand All @@ -40,17 +42,14 @@ setting up the required globals.
* @jest-environment jsdom
*/

const fs = require('fs');
const path = require('path');

describe('Drupal.behaviors.myModule', () => {
beforeEach(() => {
localStorage.clear();
global.Drupal = { behaviors: {} };

const filePath = path.resolve(__dirname, 'my_module.js');
const code = fs.readFileSync(filePath, 'utf8');
eval(code);
jest.resetModules();
// eslint-disable-next-line global-require
require('../my_module.js');
});

afterEach(() => {
Expand All @@ -69,9 +68,12 @@ describe('Drupal.behaviors.myModule', () => {

### Loading Drupal behaviors

The `eval(fs.readFileSync(...))` pattern executes the source file's IIFE, which
receives `global.Drupal` as its `Drupal` parameter and registers the behavior.
After `eval()`, the behavior is accessible via `Drupal.behaviors.myModule`.
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 via `Drupal.behaviors.myModule`.

The `jest.resetModules()` call before `require()` clears the module cache so
the IIFE re-executes on each test with a fresh `global.Drupal` object.

### Mocking globals

Expand Down Expand Up @@ -121,12 +123,35 @@ jest.useRealTimers();
### ESLint compatibility

The `.eslintrc.json` includes an override for `*.test.js` files that enables
the `jest` environment and allows `eval()`. No additional ESLint configuration
is needed for test files.
the `jest` environment and allows `global-require`. No additional ESLint
configuration is needed for test files.

## Coverage

Jest collects code coverage automatically when running tests via `ahoy test-js`.
Coverage is configured in [`jest.config.js`](https://github.com/drevops/vortex/blob/main/jest.config.js)
with the following settings:

- **Source files**: all `*.js` files in `web/modules/custom/*/js/` directories,
excluding `*.test.js` files
- **Reporters**: text (terminal summary), lcov, HTML, and Cobertura XML
- **Output directory**: `.logs/coverage/jest`

After running tests, coverage reports are available at:

| Report | Location |
|--------|----------|
| Terminal summary | Printed to stdout during test run |
| HTML report | `.logs/coverage/jest/lcov-report/index.html` |
| LCOV data | `.logs/coverage/jest/lcov.info` |
| Cobertura XML | `.logs/coverage/jest/cobertura.xml` |

The Cobertura XML report is used by continuous integration to track coverage
trends and can be consumed by CI tools that support the Cobertura format.

## Boilerplate

**Vortex** provides a Jest test boilerplate for the [demo module](https://github.com/drevops/vortex/blob/main/web/modules/custom/ys_demo/js/ys_demo.test.js)
**Vortex** provides a Jest test boilerplate for the [demo module](https://github.com/drevops/vortex/blob/main/web/modules/custom/ys_demo/js/tests/ys_demo.test.js)
that demonstrates testing a counter block with DOM manipulation, localStorage
interaction, and event handling.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"files": ["*.test.js"],
"env": { "jest": true },
"rules": {
"no-eval": "off",
"global-require": "off",
"import/extensions": "off",
"max-nested-callbacks": ["warn", 5],
"jsdoc/check-tag-names": "off"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,7 @@ module.exports = {
'<rootDir>/web/themes/',
'<rootDir>/.vortex/',
],
collectCoverageFrom: ['web/modules/custom/**/js/**/*.js', '!**/*.test.js'],
coverageReporters: ['text', 'lcov', 'html', ['cobertura', { file: 'cobertura.xml' }]],
coverageDirectory: '.logs/coverage/jest',
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"lint-fix-js": "eslint web/modules/custom --ext .js --no-error-on-unmatched-pattern --fix",
"lint-fix-css": "stylelint --allow-empty-input \"web/modules/custom/**/*.css\" --fix",
"lint-fix": "yarn run lint-fix-js && yarn run lint-fix-css",
"test": "jest"
"test": "jest --coverage"
},
"devDependencies": {
"@homer0/prettier-plugin-jsdoc": "__VERSION__",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@
this.removeUpdatedClassAfterDelay(valueElement);

// Log action for debugging.
// eslint-disable-next-line no-console
console.log(`Counter ${action}: ${currentValue}`);
this.log(`Counter ${action}: ${currentValue}`);
});
});
});
Expand Down Expand Up @@ -97,5 +96,15 @@
getCounterValue() {
return parseInt(localStorage.getItem(this.storageKey), 10) || 0;
},

/**
* Log a message to the console.
*
* @param {string} message The message to log.
*/
log(message) {
// eslint-disable-next-line no-console
console.log(message);
},
};
})(Drupal);
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
* @jest-environment jsdom
*/

const fs = require('fs');
const path = require('path');

describe('Drupal.behaviors.ysDemo', () => {
beforeEach(() => {
localStorage.clear();
global.Drupal = { behaviors: {} };

const filePath = path.resolve(__dirname, 'sw_demo.js');
const code = fs.readFileSync(filePath, 'utf8');
eval(code);
jest.spyOn(console, 'log').mockImplementation(() => {});

jest.resetModules();
// eslint-disable-next-line global-require
require('../sw_demo.js');
});

afterEach(() => {
delete global.Drupal;
jest.restoreAllMocks();
});

function createCounterBlockHtml() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@
this.removeUpdatedClassAfterDelay(valueElement);

// Log action for debugging.
// eslint-disable-next-line no-console
console.log(`Counter ${action}: ${currentValue}`);
this.log(`Counter ${action}: ${currentValue}`);
});
});
});
Expand Down Expand Up @@ -97,5 +96,15 @@
getCounterValue() {
return parseInt(localStorage.getItem(this.storageKey), 10) || 0;
},

/**
* Log a message to the console.
*
* @param {string} message The message to log.
*/
log(message) {
// eslint-disable-next-line no-console
console.log(message);
},
};
})(Drupal);
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
* @jest-environment jsdom
*/

const fs = require('fs');
const path = require('path');

describe('Drupal.behaviors.ysDemo', () => {
beforeEach(() => {
localStorage.clear();
global.Drupal = { behaviors: {} };

const filePath = path.resolve(__dirname, 'sw_demo.js');
const code = fs.readFileSync(filePath, 'utf8');
eval(code);
jest.spyOn(console, 'log').mockImplementation(() => {});

jest.resetModules();
// eslint-disable-next-line global-require
require('../sw_demo.js');
});

afterEach(() => {
delete global.Drupal;
jest.restoreAllMocks();
});

function createCounterBlockHtml() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
const roots = [];

dirs.forEach((dir) => {
@@ -20,13 +20,13 @@
@@ -20,16 +20,16 @@

module.exports = {
testEnvironment: 'jest-environment-jsdom',
Expand All @@ -25,4 +25,8 @@
+ '<rootDir>/docroot/themes/',
'<rootDir>/.vortex/',
],
- collectCoverageFrom: ['web/modules/custom/**/js/**/*.js', '!**/*.test.js'],
+ collectCoverageFrom: ['docroot/modules/custom/**/js/**/*.js', '!**/*.test.js'],
coverageReporters: ['text', 'lcov', 'html', ['cobertura', { file: 'cobertura.xml' }]],
coverageDirectory: '.logs/coverage/jest',
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
+ "lint-fix-js": "eslint docroot/modules/custom --ext .js --no-error-on-unmatched-pattern --fix",
+ "lint-fix-css": "stylelint --allow-empty-input \"docroot/modules/custom/**/*.css\" --fix",
"lint-fix": "yarn run lint-fix-js && yarn run lint-fix-css",
"test": "jest"
"test": "jest --coverage"
},
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@
this.removeUpdatedClassAfterDelay(valueElement);

// Log action for debugging.
// eslint-disable-next-line no-console
console.log(`Counter ${action}: ${currentValue}`);
this.log(`Counter ${action}: ${currentValue}`);
});
});
});
Expand Down Expand Up @@ -97,5 +96,15 @@
getCounterValue() {
return parseInt(localStorage.getItem(this.storageKey), 10) || 0;
},

/**
* Log a message to the console.
*
* @param {string} message The message to log.
*/
log(message) {
// eslint-disable-next-line no-console
console.log(message);
},
};
})(Drupal);
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
* @jest-environment jsdom
*/

const fs = require('fs');
const path = require('path');

describe('Drupal.behaviors.ysDemo', () => {
beforeEach(() => {
localStorage.clear();
global.Drupal = { behaviors: {} };

const filePath = path.resolve(__dirname, 'sw_demo.js');
const code = fs.readFileSync(filePath, 'utf8');
eval(code);
jest.spyOn(console, 'log').mockImplementation(() => {});

jest.resetModules();
// eslint-disable-next-line global-require
require('../sw_demo.js');
});

afterEach(() => {
delete global.Drupal;
jest.restoreAllMocks();
});

function createCounterBlockHtml() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
const roots = [];

dirs.forEach((dir) => {
@@ -20,13 +20,13 @@
@@ -20,16 +20,16 @@

module.exports = {
testEnvironment: 'jest-environment-jsdom',
Expand All @@ -25,4 +25,8 @@
+ '<rootDir>/docroot/themes/',
'<rootDir>/.vortex/',
],
- collectCoverageFrom: ['web/modules/custom/**/js/**/*.js', '!**/*.test.js'],
+ collectCoverageFrom: ['docroot/modules/custom/**/js/**/*.js', '!**/*.test.js'],
coverageReporters: ['text', 'lcov', 'html', ['cobertura', { file: 'cobertura.xml' }]],
coverageDirectory: '.logs/coverage/jest',
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
+ "lint-fix-js": "eslint docroot/modules/custom --ext .js --no-error-on-unmatched-pattern --fix",
+ "lint-fix-css": "stylelint --allow-empty-input \"docroot/modules/custom/**/*.css\" --fix",
"lint-fix": "yarn run lint-fix-js && yarn run lint-fix-css",
"test": "jest"
"test": "jest --coverage"
},
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
* @jest-environment jsdom
*/

const fs = require('fs');
const path = require('path');

describe('Drupal.behaviors.ysDemo', () => {
beforeEach(() => {
localStorage.clear();
global.Drupal = { behaviors: {} };

const filePath = path.resolve(__dirname, 'the_force_demo.js');
const code = fs.readFileSync(filePath, 'utf8');
eval(code);
jest.spyOn(console, 'log').mockImplementation(() => {});

jest.resetModules();
// eslint-disable-next-line global-require
require('../the_force_demo.js');
});

afterEach(() => {
delete global.Drupal;
jest.restoreAllMocks();
});

function createCounterBlockHtml() {
Expand Down
Loading
Loading