How to make effective frontend tests?
Writing automated tests for frontend applications often brings some problems. It’s easy to have shortcomings, wrong assumptions, quick solutions that are bad practice, and to top it all off, it’s quite time-consuming. However, the work put in to developing skills and using the right tools has created a pretty good culture of effective testing early in the development process at HL Tech.
Why do we need tests?
The shift-left approach is a concept that has existed in the testing world for more than twenty years. It assumes the verification of functionality at an early stage of software development. In this way, various inaccuracies can be detected relatively early. By this I mean mistakes made by the developers, but also shortcomings in the design or incomplete analysis without so-called edge scenarios.
This verification primarily involves automating tests that check how the solution being implemented works and whether it will damage other components in the ecosystem. This helps create a team culture of good software quality and completeness. Developers become aware of more of the challenges of keeping functionality within the constraints of the planned scenario, which culminates in better quality final work submitted to the business for final approval.
In order to properly implement shift-left in day-to-day development, a well-designed dev-ops layer is essential. By this I mean the creation of a sequence of tasks that check the quality of the code and software, the tools used and all other formalities related to the development phase. When all this is fulfilled, the new solution can be automatically delivered to customers, in principle even without manual verification on the tester’s side.
However, these issues go far beyond the subject of this article. I would like to present how we approach the testing of front-end applications in our everyday development.
Code quality verification
For the past few years, I have enjoyed watching junior colleagues start to realise that the quality of the functionality they write is also the result of some kind of testing. By this I mean the static analysis and automatic improvement of source code, implemented by tools like Prettier, Eslint or Typescript. This is because they are the informal, lowest layer of tests that take care of the general readability of the code, checking whether we make any typos in the commands we call and whether we go beyond the established business framework enclosed in the data types we operate on.
Low-level functionality testing
True and intentional testing starts at the layer of unit and integration checks, which are actually the most important in our day-to-day development. This is due to the simple fact that they verify functionality based on raw source code, requiring no compilation or prior modification. We perform them using Jest or Vitest tools, although due to the greater modernity and overall efficiency, we recommend the latter for new projects.
It is worth taking a pause at this point to clarify what we at HL Tech mean by unit and integration tests. This is because internet knowledge is very unclear in this area and causes a lot of misunderstandings even for experienced developers.
Well, unit tests are about checking the results of some closed logic. We are talking about simple functions that, with the same input arguments, will always produce the same final results. A good example would be a mapper, validator, or any helper operating on a limited range of input. In such scenarios, we check as many combinations of input as possible, sometimes manually and sometimes trying to exploit some reusability.
describe('isInRange', () => {
it.each<{value: number; result: boolean}>([
{value: -2, result: false},
{value: -0, result: false},
{value: 0, result: false},
{value: 0.1, result: true},
{value: 5, result: true},
{value: 9.9, result: true},
{value: 10, result: false},
{value: 15, result: false},
])('returns $result if value is $value', ({value, result}) => {
expect(isInRange(value)).toBe(result);
});
});
Unit test code example.
Integration tests in our ecosystem are all tests based on rendering a component in a JSDOM environment. In this layer, it is critically important to understand what this title integration is all about. This is because there is still a misconception in the frontend world that it concerns connections between parts of the code – the components. However, this approach makes integration tests dependent on implementation details, making them unreliable and hard to maintain in the future. In our projects, we consider each page in the application as a self-determining whole, which is the best candidate for testing. During these, we verify how they integrate with their so-called external world – the user reading data on the screen and using the application with the mouse and keys, the url in the browser or the api through which the view communicates with the backend.
Such tests are based on accurately simulating the way a user uses our application. The user-event library with functions such as click() or type() and the Testing Library with selectors, the most important of which is getByRole(), work perfectly here. Integration with the url is achieved by including a library that supports routing inside the tested component.
In this way, we are able to check the current url ‘in the browser’ and confirm the occurrence of expected redirects. Communication with the backend, on the the other hand, is mocked using MSW. In this case, we are very scrupulous – we handle only those endpoints that should be called during the test and only with the data we expect to send/receive with accuracy from the last character. Nothing more, nothing less, as any deviation from the planned integration with the backend results in improper processing of financial operations. For us, this can quickly escalate into significant penalties imposed by the market regulator.
We divide integration tests into two stages – the Happy path and the Negative path. The first is used to confirm whether all the expected elements are displayed and to go through all the actions aimed at – broadly speaking – achieving success. A good example of this would be filling out a form and submitting the data, ending with a correct response from the server. The second stage is humorously called the ‘health path’, during which we check the works of the component in the event of incorrect responses from endpoints or incorrect data input by users.
At this point, I have to confess that the above description is only a preview of the topic, because integration testing in itself is the material for quite a large publication. The ability to write authoritative, efficient and readable scenarios is therefore the result of painstakingly honing one’s craft, learning good practices and understanding the entire sequence of events triggered by each action on the component under test. Unfortunately, the natural tendency to use shortcuts very often ends up creating ineffective scenarios. This causes problems even for intermediate developers. My advice in such a situation is to be patient and concentrate on simulating as accurately as possible the way the user uses the component under test. The rest will come with time.
it('redirects client to /profile after successful sign up', async () => {
// Given data used during test scenario
const login = 'test-login';
const password = 'test-password';
const csrfToken = 'test-csrf-token';
// And handler for POST /login endpoint successful call
// Note: it handles api endpoint requested only with given requestBody definition
server.use(loginHandler({login, password, csrfToken}, {isAuthenticated: true}));
// And url to redirect to after successful sign up
const redirectionUrl = '/profile';
// When component renders
// Note: render helper wraps View with open source libraries like React Router or React Query
// It also starts the page within a /login url context and provides csrfToken to be mocked internally
const {history} = renderPageWithinContexts(<LoginView />, {path: '/login', csrfToken});
// Then main header is displayed
expect(await screen.findByRole('heading', {name: /log in to see your profile/i}));
// When client fill a sign-up form
await userEvent.type(screen.getByRole('textbox', {name: /login/i}), login);
await userEvent.type(screen.getByRole('textbox', {name: /password/i}), password);
await userEvent.click(screen.getByRole('button', {name: /sign up/i}));
// Then they are redirected to /profile page
await waitFor(() => expect(history.location.pathname).toEqual(redirectionUrl));
});
Example of integration test code for a hypothetical login page.
Application functional testing
While the previous layer of testing is based on checking sections of the application – logical units, views or pages – we now focus on verifying so-called user journeys – real-world scenarios focusing on the customer achieving the expected result. For such verification, Playwright is most often useful to us. It tests the production application bundle generated locally (functional tests), or deployed on test environments (end-to-end / e2e tests).
Functional testing works best in so-called step-by-step applications, where customers reach their goal by passing through several views that together form their journey. An example of this is the creation of a financial transfer, which in most systems involves entering data, confirming it on the summary, entering a security code and finally sending the request to the financial institution. In the case of single-step applications, e.g. panels for operational staff, which resemble a view layer for CRUD (create, read, update, delete) operations, such tests are almost uniform with our integration tests. At this stage, communication with the endpoints is also mocked, and the application itself is tested using a real browser, which is usually implemented by Playwright.
e2e tests, on the other hand, focus on verifying the regression of critical paths in the application. QA engineers or so-called testers are responsible for creating them. However, thanks to the use of the common Typescript language and the Playwright tool, their work can also be performed or verified by frontend developers. This layer communicates with the real backend services, so that we regress the entire solution. Such tests are performed each time changes are implemented into the main branch of any of the ecosystem components (backend, frontend, etc.).
More than just code testing
If I had to describe the world of frontend development over the past few years in a single sentence, I would not hesitate to use the words – very dynamic progress. The creation of so-called ‘pretty views’ has long since been a thing of the past. Nowadays, on the front-end we also focus on the semantics of the code (in order to comply with SEO standards), we care about the performance of applications, which should display the expected data to customers as quickly as possible, or we keep a close eye on metrics indicating – generally speaking – comfort and safety related to the use of applications. And this is just a small part of our daily responsibilities.
Checking all this can also be automated. ‘Nice views’ are confirmed by visual tests using Playwright. These involve creating so-called ‘snapshots’ of pages, which are then saved to a graphical format. In this way, we can confirm how the page will look in the browser and whether we will inadvertently make unexpected visual changes. On the other hand, we verify metrics related to application performance, good practice or SEO compliance through automated snapshot audits performed in Lighthouse.
Accessibilty testing
Accessibility is an extremely important topic for us. In addition to the EAA Directive coming into force in mid-2025, requiring us to meet accessibility standards for digital services for people with disabilities, it is simply required by a huge number of our most valuable customers.
Of course, the most authoritative accessibility tests are performed by real users, as are tests of the overall User Experience. However, we are able to automate some of the tests at an early stage of development. This is especially true if we are talking about standards for the HTML code generated – its semantics allowing better integration with assistive technologies, or a properly laid out appearance in terms of contrasts used, size of elements, etc.
We have included the analysis of static code and design in a group of so-called snapshot tests. We are very strict here and in new projects we do not allow any change to be made if any error is found that does not comply with the most comprehensive WCAG guidelines. We control the tested applications using Playwright, after which we analyse their effect in Lighthouse and Axe scanners.
public async assertSnapshots(snapshotTitle: string) {
await Promise.all([
this.assertAccessibilityChecks(snapshotTitle),
this.performVisualSnapshotComparison(snapshotTitle),
this.performAriaSnapshotComparison(snapshotTitle),
this.performLighthouseFlowSnapshot(snapshotTitle),
]);
}
private async assertAccessibilityChecks(snapshotTitle: string) {
// Perform static a11y analysis of given page (snapshot)
const accessibilityChecks = await new AxeBuilder({page: this.page})
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'])
.analyze();
// Attach checks into test report
await this.testInfo.attach(this.composeA11yReportName(snapshotTitle), {
body: JSON.stringify(accessibilityChecks, null, 2),
contentType: 'application/json',
});
// Ensure no a11y errors
expect(accessibilityChecks.violations).toEqual([]);
}
Types of snapshot tests and an example of how to call a static Axe analysis.
In addition to this, we duplicate scenarios that test critical client journeys by navigating using only the keyboard. While we also use Playwright for this purpose, the test cases focus more on verifying the currently selected elements and triggering actions on them using the keyboard buttons. In this way, we confirm that our services are also available for users using so-called assistive technologies.
Everything above is just a teaser
Without a doubt, frontend testing is a tough piece of development. Many times, the simplest solutions turn out to be a bad pattern, such as identifying elements by their HTML structure or through a special data-test-id attribute. On the other hand, the getByRole() function from the Testing Library is not particularly efficient for reasonable technological reasons. Thus, the knowledge of any tips&tricks to increase it becomes a kind of maturity check for test writing. And with each passing day, it becomes apparent that there is room for improvement – for scenarios to not only be robust to the implementation details of the application, but also to elements of randomness in the data, the order in which the actions occur or the configuration of the environments on which they are executed.
We have been discussing these topics at our in-house meetups for years, so that we are able to create tests of higher and higher quality year after year. We are aware that this increases the development time of the functionality. I am not exaggerating when I say that writing all the tests for a new functionality takes a bit longer than writing the functionality itself. However, what I personally appreciate in my day-to-day work at HL Tech is that the business is aware of the value of automated tests and never asks questions about the possibility of half-measures. In the financial market, verifiability is fortunately one of the most important regulations.