Artikel
16 september 2025 · 9 min lästidSometimes a user interface is left untested because of ineffective processes – or simply because it's difficult. Whatever the reason, not testing the UI is always a bad idea. But fear not: Esko Luontola, Senior Software Architect at Nitor, shares his tips on user interface testing.
In every brownfield project I’ve joined, user interface (UI) tests have been lacking. In contrast, backend testing has clearly improved over the past decade compared to how it was some 20 years ago. Writing tests for database access code has its own challenges (more on that in a follow-up article), but most developers at least write some backend tests. On the UI side, though, projects typically have tests only for simple helper functions.
Testing a UI is an order of magnitude harder than testing backend code, which in turn is an order of magnitude harder than testing pure functions. As a result, most developers give up on testing the UI or rely on slow and fragile end-to-end tests instead of fast and precise unit tests.
In this article, I’ll go through four techniques that make unit testing web application UIs easier:
using whole assertions instead of piecemeal assertions
extracting visible text from the UI
making visual information accessible to tests
separating side effects from rendering
These techniques cover both test design and making code more testable.
Prefer whole assertions over piecemeal assertions
In the olden times, you might have seen tests that assert things piecemeal, like this:
assert stuff.length == 3
assert stuff[0] == "foo"
assert stuff[1] == "bar"
assert stuff[2] == "gazonk"
But it's much better to write the same test using a single equality comparison:
assert stuff == ["foo", "bar", "gazonk"]
That way, when the test fails, your test runner will show a diff between the whole expected and actual values. Otherwise, for example, if stuff.length is unexpectedly 4 instead of 3, the test failure message won’t tell you what the extra item was, and understanding why the test failed would require some debugging.
Whole equality assertions have another benefit: in addition to verifying that the expected things did actually happen, they also verify that nothing else happened. This is especially true for functional code, which doesn't have any side effects beyond the function's return value.
This practice is common knowledge for backend tests, but in frontend code you often see piecemeal assertions like this:
assert page containsText "1.5.2025"
assert page containsText "31.5.2025"
This makes it harder to read the test, because it's not obvious what is being tested. The above code is checking that some dates are shown on the page, but it doesn't convey their meaning. Even worse, the test might pass accidentally. Maybe the start and end date are in the wrong order. Maybe the page also contains "2.5.2025" when it shouldn't. Or perhaps "1.5.2025" is also a substring of "31.5.2025" and the test passes even when the page doesn't contain the start date.
To apply the same principle of whole assertions to the UI, you might wish you could write:
Unfortunately, most programming languages don't support embedding images in the code. And even if they did, we rarely care about every detail of how the component looks – we care about what information it displays. There's a foundational principle at play here:
Programmer tests should be sensitive to behaviour changes and insensitive to structure changes.
–Kent Beck
Applied to UI, that means your tests should care about:
✅ What information is shown to the user
And not about:
❌ Whether a <div> or a <span> was used
❌ Whether a margin is 8 or 10 pixels
Unless you're implementing an image filter in Photoshop, you shouldn't be testing individual pixels. In business applications, the visual aspects are secondary to information content. Tests should fail only when the information changes.
Whole-asserting the UI using visible text
There is a neat trick for asserting the whole text of a page: use the HTMLElement.innerText property. With the help of that (and ignoring whitespace differences) you can write assertions like:
assert page.innerText ~== `Start date: 1.5.2025
End date: 31.5.2025`
Notice how the test's textual representation resembles the graphical UI. It also ensures that nothing unexpected appears on the page. This makes the test more understandable and reliable.
This technique is very powerful, but it's also worth considering the coupling and cohesion of your tests. If you have many tests coupled to a large component, there’s a high likelihood that you will need to update the tests whenever you change the component. Well-designed tests should make it faster to change code, not slow you down.
Typically, I have a couple of high-level tests and many low-level ones. Let's say we have a form with ten fields. I would write two tests that assert the whole form: one with some data in every field (to ensure the values are wired in correctly) and another with everything set to null (to ensure the form handles missing data gracefully). For very basic fields, those two tests are enough.
But for fields with custom logic, I would test every edge case using low-level tests that focus on just that one field. This way, when you add new fields to the form, you only need to update the high-level tests. The low-level tests are decoupled from the rest of the form, so they won't break needlessly.
Visualize non-textual information using text
Checking the innerText of a component is fine and dandy, but not all UI information is textual. In particular, innerText won't show form field values. And sometimes icons, color, or font styling convey information. But there is a workaround: you can create textual representations of non-textual elements, allowing you to use normal unit testing tools to test the UI.
For instance, we could add a custom attribute to represent information such as a checkbox being checked:
<label><input type="checkbox" checked data-test-icon="☑️"> Enabled</label>
In production the data-test-icon attribute is ignored, but tests can use a little regular expression to make it visible:
html.replace(/<[^<>]+\bdata-test-icon="(.*?)".*?>/sg, " $1 ")
This enables whole assertions like:
assert visualizeHtml(`<label><input type="checkbox" checked data-test-icon="☑️"> Enabled</label>`) == "☑️ Enabled"
I've published a few implementations of visualizeHtml at https://github.com/luontola/html-utils for you to copy and adapt. The above mentioned regular expression approach is the most portable, though limited in its expressiveness. Ideally your programming language has an HTML parser, which enables more flexible visualizations. With a parsed document tree you can, for example, replace an element's children with a test visualization, or more easily create default visualizations for common elements.
This style, also known as stringly asserted tests, has served me well in multiple projects. Originally I created the data-test-icon attribute to replace SVG icons with emoji. Consider this UI component:
It uses SVG icons with various colors to signal e.g. how many items are in stock at a warehouse and their estimated delivery time (green), and the estimated delivery time of any backordered items (yellow). When testing, we can replace the SVG with a matching emoji by writing <svg data-test-icon="🟢">. The test then closely resembles the actual UI:
assert visualizeHtml(discounts) ~== `
Warehouse
🟢 20.5.2025 73
🟡 4.6.2025 327`
This technique turned out to be useful also in many other situations, in particular with form fields:
assert visualizeHtml(discounts) ~== `
List price 37,50 €
Default discount -10 % (not applied)
Manual discount [-13] %
Manual price [(32,63)] €
Total discount -13,00 % / -4,87 €`
Here, I use square brackets to represent form fields (inspired by Markdown). In some applications it's important to make a distinction between a user-entered value and a placeholder value. In the example above, the manual discount has been entered by the user, and the manual price is an automatically calculated placeholder value. We visualize them with data-test-icon="[-13]" and data-test-icon="[(32,63)]" respectively.
In this example, the manual discount overrides the default discount. When a default value is overridden, it appears crossed out and faded in the UI. Such visualization doesn't have a natural textual representation, but we can use the data-test-icon attribute also like this:
<td>-10 %<span data-test-icon="(not applied)"></span></td>
In a way, the data-test-icon attribute is like an aria-label attribute. It's accessibility for automated tests.
This technique makes it easier to write UI tests in terms of what the user sees. Instead of querying the DOM with CSS selectors or data-testid attributes, make the information easily accessible for tests. This makes tests more readable and reliable.
Render using pure functions
One reason why single-page apps (SPA) are particularly hard to test is that they typically are riddled with mutable state, data fetching and asynchronous behavior. Using pure functions and separating data fetching from rendering makes testing easier.
(Sidenote: There’s nothing truly functional about React’s function components. When a React function component uses a hook, it becomes a stateful object whose state is managed internally by React, tightly coupled to the framework. Legacy React applications often lack clear seams for testability, so the network layer is usually your best option. You can use tools like nock to mock network requests, React Testing Library to test the component as a stateful black box, and be prepared to deal with a lot of asynchrony.)
Consider a todo list implemented in two parts:
todoListModel is an impure function that reads from the database and returns a map of data (the model).
todoListView is a pure function that takes in a model and returns HTML.
In tests we would have multiple shared constants of example models. One model per edge case to be tested. For example, signedInModel for when the user is authenticated:
signedInModel = {
todoItems: [
{id: 1, label: "Item 1", done: true},
{id: 2, label: "Item 2"},
{id: 3, label: "Item 3"},
],
allowEditing: true,
}
Tests for the model function would take the database and other parameters as input, and use signedInModel as the assertion's expected value:
assert todoListModel(database, userId) == signedInModel
Tests for the view function, on the other hand, would use signedInModel as the input:
assert visualizeHtml(todoListView(signedInModel)) ~== `
☑ Item 1 Delete
☐ Item 2 Edit Delete
☐ Item 3 Edit Delete`
This way the two functions will always be in sync. You won't need any static types in dynamic languages, because the model parameter to the view function has been tested to be a realistic value that the model function can return. You avoid the common issue of mocks that behave differently from the real implementation.
Model functions depend on the database, so their tests can be slower and require test data setup and cleanup. View functions don't have any difficult dependencies, so they are easy to test and you can run up to thousands of UI tests per second.
Summary
In this article, we explored four techniques to make UI testing easier:
Prefer whole assertions that check the whole result using one equality comparison. Avoid piecemeal assertions which poke parts of the result individually.
Extract all visible text from the UI and assert that with a whole assertion.
Make non-textual information accessible for tests by defining custom visualizations using HTML attributes.
Separate data fetching and other side effects from rendering. Use pure functions for rendering the UI components. Keep the two sides of the UI component in sync by using shared constants.
In the next article, I go through the challenges of testing database access code and how various design decisions can improve the situation or make it worse.