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?
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.
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.
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)
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.
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
document.querySelector
, the testing-library API is used to get the elements (screen.getByRole
, screen.getByLabelText
).Now the test really follows the user's actions on the page.
render
function for that.jest --watch
is a real productivity booster.