In software development, automated tests are essential to ensure that applications remain reliable and error-free. They provide both end-users and development teams with confidence and trust in software quality. Over the past few years, Kent C. Dodds’ Testing Library has established itself as a powerful tool that goes far beyond traditional unit tests. It focuses on testing applications built with web technologies such as React, Angular, Vue, and even React Native.
In this post, our Xperts explore why testing is so important, the principles behind Testing Library, and how it helps maintain software quality. By integrating automated tests into build processes, we ensure that applications meet our high-quality standards before they are released to end-users.
Why Testing matters
Testing is an integral part of successful software development and serves several important purposes:
- Ensuring Functionality: Tests verify that an application works as intended. When an application behaves unexpectedly, it is crucial that tests fail to highlight the issue.
- Documentation: Tests act as living documentation, describing how the application behaves in specific scenarios.
- Automation: Automated tests ensure that developers do not overlook critical steps in the testing process.
In essence, tests fulfil two primary roles:
- They ensure that the software functions reliably for end-users.
- They assist development teams in avoiding critical oversights while saving valuable time.
Additionally, testing enhances developers' confidence in their work. It enables them to better assess whether the required quality standards have been achieved.
Building Confidence Through Testing
Tests can only build confidence if they are meaningful and realistic. One of the biggest challenges in creating tests is ensuring they do more than evaluate isolated units; they must also reflect the actual usage of the application.
Insufficient tests often exhibit certain traits, such as simulating (mocking) all imports, working with data that does not exist in the real application, or employing shallow rendering, where child components are also simulated. The more integrated and realistic the tests, the more meaningful they become.
When tests are more meaningful, the likelihood of them failing in case of an error increases. This, in turn, boosts the development team's confidence in both the tests and the application.
In summary, to strengthen confidence in tests, it is crucial that they not only validate isolated functionalities but also closely mimic real-world usage scenarios. This is where Testing Library comes into play. Specifically designed to address this gap, it helps development teams create tests that are more practical and user-focused, thereby increasing the test reliability and fostering greater confidence in the application as a whole.
What Is Testing Library and where Does it Come from?
Testing Library is a collection of testing utilities created by Kent C. Dodds, a JavaScript developer and educator. It was designed to improve the way developers test user interfaces (UIs). Testing Library is built on the principle that the closer your tests are to how your software is used in reality, the more confidence they can give you. This emphasises the importance of designing tests to realistically replicate actual user behaviour, thereby enhancing the reliability of the tests and trust in the software.
Kent C. Dodds developed Testing Library in response to traditional testing methods, which often focus too heavily on internal implementation details and less on real user experiences. His goal was to create a tool that aligns testing more closely with the actual interactions end-users have with the software.
The Principles of Testing Library
- Realistic Tests: The primary goal of Testing Library is to create tests that mirror real-world usage as closely as possible. This enhances the reliability of the tests and builds greater trust in the results.
- Framework Independence: Testing Library is not tied to a specific JavaScript framework. It offers implementations for various frameworks, such as React (React Testing Library), Angular (Angular Testing Library), Vue (Vue Testing Library), and even generic DOM testing, like Cypress (Cypress Testing Library). This makes it versatile, widely applicable and framework agnostic.
- Focus on Accessibility): A core concept of Testing Library is prioritising accessibility queries. These queries mimic interactions that users with assistive technologies, such as screen readers, would perform. This ensures that the application's core structure is accessible to users with disabilities.
- Minimal Knowledge of internal Details: Testing Library encourages writing tests that require little to no knowledge of a component’s internal details. Instead, the focus should be on the visible and accessible parts of the UI. This approach promotes better maintainability and prevents tests from failing due to implementation changes that are irrelevant from the user’s perspective.
- User-centred Interactions: Instead of directly triggering specific DOM events (e.g.,
fireEvent.click
), Testing Library advocates usinguserEvent
, which simulates more realistic user interactions. This results in more robust tests that better reflect actual user experiences.
These principles are also embodied in the Testing Trophy, a modern alternative to the traditional Testing Pyramid developed by Kent C. Dodds. The Testing Trophy focuses on the return on investment (ROI) of tests and highlights the importance of integration testing.
Testing Trophy
- Static Tests: Tools like ESLint, typed languages, or Sonar analyse the code without executing it. These tests catch errors early in the development process, ensuring code quality and consistency.
- Unit and Component Tests: These tests validate individual functions or components in isolation, ensuring that smaller units of the codebase work as expected.
- Service and Integration Tests: These tests verify the interaction between multiple components or systems, ensuring they work together as intended.
- End-to-End (E2E) Tests: Simulating the complete user journey, these tests mimic real user interactions to validate the application from the perspective of the end-user.
Key Tools and Concepts of Testing Library
- Queries: Testing Library provides various query methods, such as
getByRole
,getByLabelText
, andgetByText
. These methods are prioritised based on how closely they align with real user behaviour, with queries likegetByRole
being preferred due to their relevance to accessibility and realistic usage scenarios. - userEvent vs. fireEvent: While
fireEvent
directly triggers DOM events,userEvent
simulates more complex interactions that better reflect actual user behaviour. As a result,userEvent
is the preferred choice for writing tests that are closer to real-world usage. - Jest-DOM: This is an extension of Testing Library that provides additional matchers for Jest, a JavaScript testing framework. It simplifies the validation of DOM states with assertions like
toBeVisible
,toHaveTextContent
and more.
Example
To make the use of Testing Library more tangible, our Xperts have developed a compact example that incorporates all the tools and concepts discussed earlier. The focus is on testing a component with interactive elements. The example is implemented in Angular but can be easily adapted to other frameworks.
The Component
We begin with a template for our example component. It contains several headings as well as a "Tip of the Day" section, which can be hidden via a button click.
<div>
<h1>Hello Blog</h1>
@if (isSectionVisible()) {
<section>
<h2>Tip of the Day</h2>
<p>Testing Library makes refactoring safer.</p>
</section>
} @else {
<section>
<h2>You have already seen the tip of the day.</h2>
</section>
}
<button type="button" [disabled]="!isSectionVisible()" (click)="hideTipOfTheDay()">Hide</button>
</div>
The logic resides in the component’s TypeScript file. Here, we implement an action triggered by a button click and a signal that makes the template reactive. Signals were introduced in one of our previous blog posts.
import { Component, signal } from "@angular/core";
@Component({
selector: "hello-blog",
standalone: true,
imports: [],
templateUrl: "./hello-blog.component.html",
})
export class HelloBlogComponent {
public isSectionVisible = signal<boolean>(true);
public hideTipOfTheDay(): void {
this.isSectionVisible.set(false);
}
}
The Tests
In our test, we aim to ensure that the initial state matches expectations. Additionally, we verify that the "Tip of the Day" section is correctly hidden when a user clicks the hide button.
Setup
We begin by setting up the environment so we can focus on the actual tests. The setup includes initialising userEvent
to simulate user interactions and rendering the component using Testing Library’s render method. Inputs or the test environment itself can also be configured here. Using a setup method is consistent with the recommended approach for writing tests with userEvent
(Link).
import { HelloBlogComponent } from "src/hello-blog/hello-blog.component";
import { render, screen } from "@testing-library/angular";
import { userEvent } from "@testing-library/user-event";
const setup = async () => {
const user = userEvent.setup();
const view = await render(HelloBlogComponent);
return { user, view };
};
describe("MyComponentComponent", () => {
…
});
Testing the Initial State
Using our setup method, we can now write our tests. We start with a test that ensures that the initial state of the component is as expected. The setup triggers a render and waits until it is completed. We then query elements on the screen – a global object provided by Testing Library – using its underlying queries. Since the template defines many roles through corresponding HTML tags, we can utilise them as follows:
describe("MyComponentComponent", () => {
it("should render initially with heading and tip of the day", async () => {
// GIVEN
await setup();
// THEN
expect(screen.getByRole("heading", { level: 1, name: "Hello Blog" })).toBeVisible();
expect(screen.getByRole("heading", { level: 2, name: "Tipp des Tages" })).toBeVisible();
…
});
});
For instance, the <h1>
element can be retrieved using the query getByRole("heading", { level: 1, name: "Hello Blog" })
. Here, we see that the text content of the heading can be queried via the name option, making the query even more precise.
Additionally, elements can be queried directly by their text content. In the following test snippet, we demonstrate how to verify an element's visibility using the getBy
query combined with toBeVisible
. Conversely, if we want to check that an element is not visible, we use the queryBy
query in combination with not.toBeInTheDocument
.
describe("MyComponentComponent", () => {
it("should render initially with heading and tip of the day", async () => {
…
expect(screen.getByText("Testing Library makes refactoring safer")).toBeVisible();
expect(
screen.queryByRole("heading", { level: 2, name: "You have already seen the tip of the day." })
).not.toBeInTheDocument();
…
});
});
In the final section of our first test, we use the Jest-DOM matcher toBeDisabled
, to ensure that the button for hiding the content is active and clickable initially. One of the advantages of these matchers is that they also allow us to verify the correct ARIA attributes, this way we ensure that at least the basic accessibility requirements are met and that users who rely on screen readers can navigate.
describe("MyComponentComponent", () => {
it("should render initially with heading and tip of the day", async () => {
…
const hideButton = screen.getByRole("button", { name: "Hide" });
expect(hideButton).toBeVisible();
expect(hideButton).not.toBeDisabled();
});
});
Interaction Test
In the second test, we use userEvents
to simulate a user clicking the button and verify that the "Tip of the Day" section disappears as expected. As before, the setup method is invoked first. We then check the state prior to the interaction using toBeVisible
and not.toBeInTheDocument
.
describe("MyComponentComponent", () => {
it("should render with heading and personal section initially", async () => {
…
});
it("should hide tip of the day on button click", async () => {
// GIVEN
const { user } = await setup();
// THEN
expect(screen.getByRole("heading", { level: 2, name: "Tip of the Day" })).toBeVisible();
expect(screen.getByText("Testing Library makes refactoring safer.")).toBeVisible();
expect(
screen.queryByRole("heading", { level: 2, name: "You have already seen the tip of the day." })
).not.toBeInTheDocument();
…
});
});
Next, the button click is triggered. The await
keyword ensures we capture the UI’s updated state on the screen immediately, without having to worry about reactivity. Finally, we validate that the "Tip of the Day" section is no longer visible, the alternate message appears, and the button is disabled to prevent further interactions.
describe("MyComponentComponent", () => {
it("should render with heading and personal section initially", async () => {
…
});
it("should hide tip of the day on button click", async () => {
…
// WHEN
const hideButton = screen.getByRole("button", { name: "Hide" });
await user.click(hideButton);
// THEN
expect(screen.queryByRole("heading", { level: 2, name: "Tip of the Day" })).not.toBeInTheDocument();
expect(screen.queryByRole("Testing Library makes refactoring safer.")).toBeVisible();
expect(screen.getByRole("heading", { level: 2, name: "You have already seen the tip of the day." })).toBeVisible();
expect(hideButton).toBeDisabled();
});
});
Conclusion
In summary, Testing Library is becoming increasingly central to modern software development projects. It allows developers to write tests that are both realistic and meaningful, building trust in the quality of the software. Testing Library has played a significant role in shifting the focus toward user-centred testing and accessibility, helping development teams create high-quality and user-friendly applications. While some tests might be slower compared to unit testing alone, they provide greater relevance and reliability.
It is important to note that there is no absolute "right" or "wrong" approach to testing. Tests should always be tailored to the specific needs of the product and the development team. Ultimately, the goal is to create tests that instil confidence in the functionality and ensure the reliability of the software.