Skip to main content

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);
  });
});