How to Do JavaScript Unit Test with Node.js Test Runner and JSDOM
I recently watched a meetup talk from Luke Downing about the benefits of writing tests, and it’s one of the best talks that I’ve seen so far in my tech career. I strongly suggest you to check it out: Write Tests by Luke Downing
Now back to our topic. We want to test this vanilla JavaScript Menu
class that I wrote to open and hide the navigation menu on mobile.
// menu.js
export default class Menu {
constructor({ toggleButtonClass, navigationOpenClass, container }) {
this.toggleButtonClass = toggleButtonClass;
this.navigationOpenClass = navigationOpenClass;
this.container = container;
}
run() {
if (!this.container) {
throw new Error(`Container is invalid.`);
}
this.toggleButton = this.container.querySelector(`.${this.toggleButtonClass}`);
if (!this.toggleButton) {
// eslint-disable-next-line
throw new Error(`No toggle button found with this class: '${this.toggleButtonClass}'`);
}
const ariaControls = this.toggleButton.getAttribute('aria-controls');
if (!ariaControls) {
// eslint-disable-next-line
throw new Error(`Toggle button is missing 'aria-controls' attribute or attribute value is empty.`);
}
this.navigation = this.container.getElementById(ariaControls);
if (!this.navigation) {
// eslint-disable-next-line
throw new Error(`Missing nav element with id of '${ariaControls}'`);
}
this.toggleButton.addEventListener('click', this.toggleNavigation);
}
toggleNavigation = () => {
const ariaExpanded = this.toggleButton.getAttribute('aria-expanded');
if (ariaExpanded === 'false') {
this.toggleButton.setAttribute('aria-expanded', 'true');
this.navigation.classList.add(this.navigationOpenClass);
} else {
this.toggleButton.setAttribute('aria-expanded', 'false');
this.navigation.classList.remove(this.navigationOpenClass);
}
}
}
We can create a new file called menu.test.js
along side our menu.js
file, under the same folder.
/js
├── app.js
├── components
├── menu.js // Menu class.
└── menu.test.js // Menu test file.
We’re using native Node.js test runner, but that is not enough to test our Menu class as it involves with DOM manipulation, so we need a third-party package called JSDOM.
npm install jsdom --save-dev
# If you're using pnpm
pnpm install jsdom --save-dev
Once JSDOM installed, we can start writing tests. Inside menu.test.js
, add these imports at the top and below that I added few test cases for this class.
// menu.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { JSDOM } from 'jsdom';
import Menu from './menu.js';
describe('Menu', () => {
it('should throw error when container is not valid', async () => {
// Create a new JSDOM instance and extract window object.
const { window } = new JSDOM(`
<button class="toggle" aria-controls="nav" aria-expanded="false">Menu</button>
<nav id="nav"></nav>
<script>${Menu.toString()}</script>
`, {
runScripts: 'dangerously',
resources: 'usable',
});
await new Promise(resolve => window.addEventListener('load', resolve));
const { document } = window;
const menu = new Menu({
toggleButtonClass: 'toggle',
navigationOpenClass: 'nav--open',
});
assert.throws(() => menu.run());
});
it('should throw error when toggle button is not found', async () => {
// Create a new JSDOM instance and extract window object.
const { window } = new JSDOM(`
<button class="toggle" aria-controls="nav" aria-expanded="false">Menu</button>
<nav id="nav"></nav>
<script>${Menu.toString()}</script>
`, {
runScripts: 'dangerously',
resources: 'usable',
});
await new Promise(resolve => window.addEventListener('load', resolve));
const { document } = window;
const menu = new Menu({
toggleButtonClass: 'no-this-toggle',
navigationOpenClass: 'nav--open',
container: document,
});
assert.throws(() => menu.run());
});
it('should throw error when toggle button aria-controls attribute is not set or empty', async () => {
// Create a new JSDOM instance and extract window object.
const { window } = new JSDOM(`
<button class="toggle" aria-controls="" aria-expanded="false">Menu</button>
<nav id="nav"></nav>
<script>${Menu.toString()}</script>
`, {
runScripts: 'dangerously',
resources: 'usable',
});
await new Promise(resolve => window.addEventListener('load', resolve));
const { document } = window;
const menu = new Menu({
toggleButtonClass: 'toggle',
navigationOpenClass: 'nav--open',
container: document,
});
assert.throws(() => menu.run());
});
it('should throw error when navigation element is not found', async () => {
// Create a new JSDOM instance and extract window object.
const { window } = new JSDOM(`
<button class="toggle" aria-controls="nav" aria-expanded="false">Menu</button>
<nav id="wrong-nav-id"></nav>
<script>${Menu.toString()}</script>
`, {
runScripts: 'dangerously',
resources: 'usable',
});
await new Promise(resolve => window.addEventListener('load', resolve));
const { document } = window;
const menu = new Menu({
toggleButtonClass: 'toggle',
navigationOpenClass: 'nav--open',
container: document,
});
assert.throws(() => menu.run());
});
it('should update toggle button aria-expanded to true and nav to have open class', async () => {
// Create a new JSDOM instance and extract window object.
const { window } = new JSDOM(`
<button class="toggle" aria-controls="nav" aria-expanded="false">Menu</button>
<nav id="nav"></nav>
<script>${Menu.toString()}</script>
`, {
runScripts: 'dangerously',
resources: 'usable',
});
await new Promise(resolve => window.addEventListener('load', resolve));
const { document } = window;
const menu = new Menu({
toggleButtonClass: 'toggle',
navigationOpenClass: 'nav--open',
container: document,
}).run();
const toggle = document.querySelector('.toggle');
const nav = document.querySelector('#nav');
// toggle.click(); // Click to expand the navigation.
toggle.dispatchEvent(new window.MouseEvent('click'));
assert.strictEqual(toggle.getAttribute('aria-expanded'), 'true');
// assert.match(nav.className, /nav--pen/);
assert.strictEqual(nav.classList.contains('nav--open'), true);
toggle.click(); // Click again to collapse the navigation.
assert.strictEqual(toggle.getAttribute('aria-expanded'), 'false');
assert.strictEqual(nav.classList.contains('nav--open'), false);
});
});