Testing | Part 2 — Unit Testing the DOM

Testing environments

Up to this point, we have been using the default Node.js testing environment. But in order for us to test the DOM, we need to utilize a virtual DOM testing environment to simulate the browser APIs.

We'll use Happy DOM as our testing environment. Happy DOM is a fast and lightweight virtual DOM implementation. It's an environment that simulates the web browser APIs, allowing developers to run and test code as if it were running in an actual browser.

Let's first install happy-dom and then modify our test script in our package.json file to use it.

npm i -D happy-dom
{
  "main": "index.js",
  "scripts": {
    "test": "vitest  --run  --globals  --environment happy-dom"
  },
  "devDependencies": {
    "happy-dom": "^10.11.0",
    "vitest": "^0.34.1"
  }
}

Creating a virtual HTML page

We now have our testing environment configured to simulate a virtual DOM. However, we still need to simulate a window and document object to allow us to interact with the DOM as well as set up a virtual HTML page that we can render our HTML into.

dom.test.js:

import fs from 'fs';
import path from 'path';

import { it, vi } from 'vitest';
import { Window } from 'happy-dom';

import { displayMessage } from './dom';

const html_doc_path = path.join(process.cwd(), 'index.html');
const html_document_content = fs.readFileSync(html_doc_path).toString();

const window = new Window(); // create emulated browser
const document = window.document;
document.write(html_document_content);
vi.stubGlobal('document', document);

it ('should display message', () => {

  // create message <p> element in the DOM
  displayMessage('hello');
});

dom.js:

const displayMessage = (text) => {

  const container = document.querySelector('#container');
  container.innerHTML = '';

  const message = document.createElement('p');
  message.classList.add('message');
  message.innerHTML = text;
  container.appendChild(message);
};

export { displayMessage }

Writing a unit test for a DOM node

Let's now test our displayMessage function to make sure that the DOM node is rendered to the page.

it ('should display message', () => {

  // create message <p> element in the DOM
  displayMessage('hello');

  const container = document.querySelector('#container');
  if (!container) throw new Error('container not found');

  const message = container.querySelector('.message');
  expect(message).not.toBeNull();

  const message_text = message.textContent;
  expect(message_text).toBe('hello');
});

Writing multiple unit tests for a DOM node

Note that if we add a second test by just copying the first test and commenting out the call to our displayMessage function the second test passes. The second test passes because the DOM still contains the content that was created in the first call to displayMessage('hello').

it ('should display message', () => {

  // create message <p> element in the DOM
  displayMessage('hello');

  const container = document.querySelector('#container');
  if (!container) throw new Error('container not found');

  const message = container.querySelector('.message');
  expect(message).not.toBeNull();

  const message_text = message.textContent;
  expect(message_text).toBe('hello');
});

// ==============================================

it ('should display another message', () => {

  // create message <p> element in the DOM
  // displayMessage('another message');

  const container = document.querySelector('#container');
  if (!container) throw new Error('container not found');

  const message = container.querySelector('.message');
  expect(message).not.toBeNull();

  const message_text = message.textContent;
  expect(message_text).toBe('hello');
});
> npm run test                                                                                                                   1 ✘  system   07:30:11 

 RUN 

 ✓ dom.test.js (2)

 Test Files  1 passed (1)
      Tests  2 passed (2)

That's not the behavior we want. To make our tests independent of each other, we want to reset our changes made after every test in order to simulate pure functional behavior for each of our tests to ensure that test specific side effects do not affect other tests.

import { beforeEach, expect, it, vi } from 'vitest';

.
.
.

const window = new Window(); // create emulated browser
const document = window.document;
document.write(html_document_content);
vi.stubGlobal('document', document);

beforeEach(() => {
  document.body.innerHTML = '';
  document.write(html_document_content);
});

.
.
.

Our second test now fails as expected (since we have displayMessage('another message') commented out in the second test).

> npm run test                                                                                                                   1 ✘  system   07:30:11 

 RUN 

 ❯ dom.test.js (2)
   ✓ should display message
   × should display another message

 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)

We can modify our second test to actually pass with this desired behavior of not finding the message element when displayMessage is not called.

it ('should not display a message if displayMessage() is not called', () => {

  // create message <p> element in the DOM
  // displayMessage('another message');

  const container = document.querySelector('#container');
  if (!container) throw new Error('container not found');

  const message = container.querySelector('.message');
  expect(message).toBeNull();
});
> npm run test                                                                                                                   1 ✘  system   07:30:11 

 RUN 

 ✓ dom.test.js (2)

 Test Files  1 passed (1)
      Tests  2 passed (2)