Testing like a user: writing accessible and user centered tests
In the previous post, we explored why integration tests deliver better value than unit tests. Now let's dive into the practical side: how to write tests that mirror how users actually interact with your app.
Naming your tests
We should always test from the user's perspective.
Tests should describe user actions (submits a form, displays employee list) rather than implementation details (renders FormComponent).
It's quite simple. When a user reports a bug in your app, do they mention which component is broken? No. They mention what they were trying to do and what went wrong.
So should your tests.
Take a look at the following example:
tsxtest('should render null if is_subscribed is false')
rendernullis_subscribedfalse
Those are all implementation details. Things that only devs know. Even for maintainers without context, it's not clear what we are testing here.
Now let's consider the following test instead:
tsxtest('does not display premium perks if user is not subscribed')
That's a much more human-readable test. It describes what the user is trying to do and what they expect to see. If something goes wrong, you will probably receive a message saying "Hey, I have a subscription but I don't see my perks."
Even though this might sound silly, words matter. Words yield power. It's highly probable that someone will want to read your work if it's well written.
Moreover, in this agentic/vibe coding era we are living right now, it's more important than ever. Every agent will take existing tests in your codebase and follow the same pattern.
If you want to deep dive into test naming, I'd suggest this amazing talk/article from Artem Zakharchenko (MSW creator and maintainer).
Writing accessible tests
We have already explained that our test names should resemble our user's mental model. But what about the tests themselves?
It might surprise you but we can actually follow the same principle for our tests. Let's take a look at the following example:
tsxtest('displays premium perks for subscribed users', async () => {
render(<HomePage />)
const viewPerks = screen.getByText('View all perks')
await UserEvent.click(viewPerks)
const perk1 = screen.getByTestId('perk-1')
expect(perk1).toBeVisible()
})
Do you spot anything wrong with this test? The test will pass and it has a human readable name. However, there are two problems here.
The first one comes with the screen.getByText query. It represents a cognitive load for the reader/maintainer of the test. What's View all perks? A button? A link? A heading?
There is no way to know just by reading the test. Yes, you could argue that I'm intentionally not naming the variable viewPerksButton. But that doesn't guarantee that it's a button. It could still be a link, heading, etc.
The second problem, which is worse than the first one, comes from using the getByTestId query. Literally, you have absolutely no clue what perk-1 is. It could be literally anything.
And don't even get me started on using class or id selectors. Those are even worse. Again, users have no clue about classes, test ids, ids, etc. They just see buttons, links, list items, headings, textboxes, etc.
That's why we prefer accessible queries. Let's write the same test again but using accessible queries.
tsxtest('displays premium perks for subscribed users', async () => {
render(<HomePage />)
const viewPerks = screen.getByRole('button', { name: 'View all perks' })
await UserEvent.click(viewPerks)
const perk1 = screen.getByRole('heading', { name: '50% off city pass' })
expect(perk1).toBeVisible()
})
By just reading the test, in your head you can picture that there will be a button with the text "View all perks" and when you click that button, a heading with the text "50% off city pass" will show up.
Isn't that way easier to read? You don't even need to look up the code of the HomePage to understand what's going on.
Accessibility is important not just for making tests easier to read but also for making your app more accessible to users with disabilities. Those users will be using a screen reader to navigate your app.
And as Marcy Sutton once said, accessibility is like building a ramp on the street. Not only people in wheelchairs will benefit from it but also regular people. People riding a bike or carrying some luggage can take advantage of it too.
If you keep your HTML code as semantic as possible, you will spend less time and cognitive load on reading existing code.
tsx// Compare this
<div onClick={showAllPerks}>
View all perks
</div>
{arePerksVisible && <span>50% off city pass</span>}
// To this:
<button onClick={showAllPerks}>
View all perks
</button>
{arePerksVisible && <h1>50% off city pass</h1>}
If you are using Tailwind as a styling library where you end up with huge classes, you probably have drowned yourself in a sea of divs. And you know how much that sucks.
There are a bunch of tips and tricks around accessibility. You can ask: How about icon buttons?
Well, you can use the aria-label attribute to describe the button.
tsx<Button
variant="secondary"
className="w-6 h-6 flex items-center justify-center"
aria-label="Subscribe"
>
<Stars02Icon className="size-3" />
</Button>
Then in your test you can safely query by the aria-label (in this case, "Subscribe") even though the button doesn't have a visible label.
tsxtest('subscribes to premium perks', async () => {
render(<HomePage />)
const subscribeButton = screen.getByRole('button', { name: 'Subscribe' })
await UserEvent.click(subscribeButton)
// rest of test...
})
Ultimately, we should strive to keep elements as semantic as possible:
tsx// ❌ Non-semantic
<div onClick={handleSubmit} className="btn">Submit</div>
// ✅ Semantic
<button onClick={handleSubmit}>Submit</button>
// ❌ Misusing button
<button onClick={() => navigate("/about")}>About</button>
// ✅ Semantic link
<a href="/about">About</a>
const todos = ["Buy groceries", "Walk the dog", "Read a book"];
// ❌ Using divs
<div className="todo-list">
{todos.map((todo, i) => (
<div key={i}>{todo}</div>
))}
</div>
// ✅ Using ul/li
<ul className="todo-list">
{todos.map((todo, i) => (
<li key={i}>{todo}</li>
))}
</ul>
Wrapping Up
Writing tests from the user's perspective not only makes them more maintainable, it also improves your app's accessibility. When you write tests that use accessible queries, you're forced to think about how users actually interact with your app, which leads to better UX overall.
At the end of the day, it's a win-win for everybody. You will write better tests, your app will be more accessible, your code will be more readable, and your users will have a better experience.
As a bonus, you can check the guiding principles from Testing Library.