DOM Testing Library: Is it Worth a Try?

Written by drakulavich | Published 2021/10/20
Tech Story Tags: testing-library | jsdom | puppeteer | web-development | e2e-testing | software-testing | dom-testing-library | software-development

TLDRShort answer: Yes. The testing library approach to query elements with simulated user events looks really promising. This way of testing the app naturally improves accessibility (ARIA attributes are the main source for selectors). The quick launch of the tests rocks. Local development with jest --watch is a real productivity booster. If you already have the testing library on your project and developers are actively using it, I recommend you reading the code and make a couple of tests to understand how it works. If you as a QA engineer want to bring testingclibrary to the project, it might be a challenge. You will have to understand internal parts, components and which APIs should be mocked. In that case, I would rather stick to e2e tests. via the TL;DR App

Selenium celebrates a new release. Version 4 is out, with a new communication protocol between the library and the WebDriver, a rewritten Selenium Grid, and a ton of other changes. This is all great, but you still need to run a browser. It takes time and resources on the machine where the tests are running.

It took three years to build the release for Selenium. During this time, a huge number of libraries for testing web applications have matured and blossomed in the NodeJS camp. Libraries for e2e tests like cypress, playwright, Puppeteer are popular among testers. Developers, in addition to e2e tests, prefer to write lightweight tests for their applications. For this purpose, there is a popular DOM Testing Library.

What approaches does this library recommend, and does it make sense for a tester to use it?

How do we usually write e2e tests?

Let's take Puppeteer as an example.

There is a simple TODO application. How would a typical test with adding a task to the list look like?

1. Puppeteer implementation

First, we need to describe Page Objects:

export default class SimpleTodoPage {
  constructor(page) {
    this.page = page;
  }

  async navigate() {
    await this.page.goto('http://localhost:8080');
  }

  async addTask(text) {
    const selector = '[data-testid="task-name-input"]';
    await this.page.waitForSelector(selector);
    
    const elementHandle = await this.page.$(selector);
    await elementHandle.type(text, { delay: 25 });
    await this.page.keyboard.press('Enter');
  }
}

And the test itself:

it('should add new task', async () => {
    const page = await browser.newPage();
    const simpleTodoPage = new SimpleTodoPage(page);

    await simpleTodoPage.navigate();
    await simpleTodoPage.addTask('my task');
    await expect(page).toMatch('my task');
});

Create a Puppeteer page. Use page objects methods from SimpleTodoPage class to open the desired URL and add the task to the list. And the last step is to use puppeteer jest matcher to check if the text is displayed on the page. PageObjects makes the test listing simple and straightforward, even for a person who hasn't seen it before.

We usually use testId for the selectors in e2e. That’s what we did inside our test. Jest report may look like:

PASS  __tests__/test.js
  Simple Todo
    ✓ should add new task (2309 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        2.998 s, estimated 3 s

Headless Chrome in test ran for more than two seconds. It's important to have at least 2GB RAM to run it. As a result of the experiment, we see that e2e puts high requirements on resources and the test does not run instantly.

Is it possible to write tests for web applications without a need to spin a browser? Let's see what the JS ecosystem offers for test automation without heavy dependencies.

The testing-library has its own philosophy. This tool is not as universal as libraries for e2e tests. But you get fast and pretty "honest" tests, which, if well organized, won't break after every refactoring. And thanks to the fast feedback, you can keep them running with jest --watch. The watch flag tells jest to watch for file changes and run the tests affected by those changes.

DOM Testing Library Motto

The library has insightful documentation. I really enjoyed reading it.

The core library, DOM Testing Library, is a light-weight solution for testing web pages by querying and interacting with DOM nodes (whether simulated with JSDOM/Jest or in the browser). The main utilities it provides involve querying the DOM for nodes in a way that's similar to how the user finds elements on the page. In this way, the library helps ensure your tests give you confidence that your application will work when a real user uses it.

I would emphasize two main points. Firstly, why is it light-weight solution? The library leverages JSDOM to run tests quickly. What is that?

jsdom is a pure-JavaScript implementation of many web standards, notably the WHATWG DOM and HTML Standards, for use with Node.js. In general, the goal of the project is to emulate enough of a subset of a web browser to be useful for testing and scraping real-world web applications.

Secondly, the search for elements on the page and interaction with them is designed to replicate the user's actions as much as possible. What does this mean? Contrary to usual e2e libraries like Puppeter, element search on the page is mostly done with ARIA attributes in the HTML.

To get a better idea of what this means, check out the priorities for element search in the documentation.

Architecture

Testing-library has wrappers for popular frameworks (React, Vue, Angular) that expose APIs to deal with framework components. Wrappers use the DOM Testing Library with a universal API to interact with DOM. And the DOM Testing Library internally calls jsdom methods:

FRAMEWORK SPECIFIC WRAPPER → DOM Testing Library → JSDOM
(Eg. React Testing Library)

2. JSDOM implementation

What would a test for the same Todo app look like using pure jsdom:

beforeEach(() => {
  // ...

  app = {
    tasksContainer: document.querySelector('[data-container="tasks"]'),
    newTaskForm: document.querySelector('[data-container="new-task-form"]'),
  };
});

it('should add new task', () => {
  const taskName = 'new task';
  app.newTaskForm.querySelector('[data-testid="add-task-input"]').value = taskName;

  const addTaskBtn = app.newTaskForm.querySelector('[data-testid="add-task-button"]');
  addTaskBtn.click();

  expect(app.newTaskForm).toHaveFormValues({
    name: '',
  });
  expect(app.tasksContainer).toHaveTextContent(taskName);
});

NB. I omitted the code to prepare the HTML and run the JS with the application code for simplicity.

Jest report after running the test:

PASS  __tests__/application.test.js
  ✓ should add new task (33 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.361 s, estimated 2 s

Note the test execution time and compare it with Puppeter's result. The test was completed in ~30ms instead of 2300ms!

The test uses CSS selectors with classes. To enter the task text, cut the corner and assign the desired result to the value attribute, i.e. modify the DOM directly. Then call the click() event for the button. The test is low-level, verbose and a little bit synthetic cause the nodes are modified directly.

Let's see what convenience the DOM Testing Library adds.

3. DOM Testing Library Implementation

it('should add new task', () => {
  const taskName = 'new task';
  const newTaskInput = screen.getByLabelText('New Task Name');
  
  userEvent.type(newTaskInput, taskName);
  userEvent.click(screen.getByRole('button', { name: /add task/i }));

  expect(newTaskInput).toHaveDisplayValue('');
  expect(screen.getByText(taskName)).toBeInTheDocument();
});

And jest report:

PASS  __tests__/application.test.js
  ✓ should add new task (150 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.642 s, estimated 2 s

Differences

  • Instead of the low-level document.querySelector, the testing-library API is used to get the elements (screen.getByRole, screen.getByLabelText).
  • No direct manipulation with the DOM. To enter the task text and click the button we use companion library user-event. It simulates the user's actions in the browser.
  • The total test execution time increased to 150ms, but it's still acceptable.

Now the test really follows the user's actions on the page.

What was behind the scenes in this post?

  1. The testing library requires that the page or component tested is rendered in jsdom. High-level wrappers have render function for that.
  2. For interactions with back-end you will have to mock server responses using nock or msw.
  3. Debugging with the testing library is more complicated in comparison to e2e tools. There’s no browser to see what actually fails. Instead, the DOM is printed out in the terminal. You will need some time to adopt “blind” debugging.

Key takeaways

  • The testing library approach to query elements with simulated user events looks really promising. This way of testing the app naturally improves accessibility (ARIA attributes are the main source for selectors).
  • The quick launch of the tests rocks. Local development with jest --watch is a real productivity booster.
  • If you already have the testing library on your project and developers are actively using it, I recommend you reading the code and make a couple of tests to understand how it works.
  • If you as a QA engineer want to bring testing-library to the project, it might be a challenge. You will have to understand internal parts, components and which APIs should be mocked. In that case, I would rather stick to e2e tests.
  • Even if you don't plan to use testing-library, I would recommend looking at examples of tests from the documentation just to get an idea, how web apps can be tested.


Written by drakulavich | SDET, Lead QA Engineer. I believe in engineering culture and the importance of fast feedback to the changes.
Published by HackerNoon on 2021/10/20