Test Component Interactions in Storybook
🌱 This post is in the growth phase. It may still be useful as it grows up.
Testing is a critical component to providing reliable UI. But testing the wrong thing can actually create bugs. And the type of bugs that might not even need fixing.
Let’s look at why interaction testing (in the browser) provides the most value for effort, when testing UI.
[illustration] showing tests playing and stepping back over user events.
Let’s dive in.
Contents
- Prerequisite: react.dev tic-tac-toe tutorial
- The wrong way to test UI
- Interaction tests reveals a11y issues
- Test user events with Storybook interactions and play functions
- Make assertions with Jest
- Compose play functions for re-use
- What we learned
- Prefer video?
Prerequisite: react.dev tic-tac-toe tutorial
Before we start, check out the tic-tac-toe tutorial over on the React docs.
[illustration] show react.dev/learn page. scanning over tutorial.
We won’t go thru that tutorial. But we will use its final component as a starting point.
The wrong way to test UI
Let’s start by testing this component the wrong way — with unit tests.
When unit testing, a normal inclination is to write tests that feel like writing code. We’ll see in a minute that this intuition is wrong.
Starting the, wrong way, I’d probably export the Board
component (previously private in App.tsx
).
I’d then build cases by appyling unique game scenarios to that component directly. (Represented below as CSF stories).
This isn’t great. It’s not great because it violates two pretty important testing principles.
- Private functions should be tested via public interfaces.
- Test data should match real usage.
And look, we’re out here exporting private components to test them with impossible states.
Who smells a cheater?
[Visual] Use effect to show as list items.
Let’s fast-farward to a bit to see where where this practice becomes even more problematic. Look at this eventual case for a tie game:
It looks reasonable.
But it results in an another impossible UI state where O
is the next player — even though there are more O
s on the board than X
s. (Check my math.)
We may be inclined to fix this but we would be fixing something a user would never experience. This is a waste of time, effort, and complexity.
OK. Let’s stop doing things the wrong way and start testing the right way.
Interaction tests reveals a11y issues
Let’s interact with the whole app using interactions (user events).
Start with a new story file, using testing-library.
We immediately have a problem: we have no way to query the DOM for the squares.
Testing-library applies virtuous friction to our tests by forcing us to query the DOM only by content that is available to assistive technologies. There is no way to directly query for button.square
.
Because we’ve chosen to test like our user, we reveal a stark accessibility issue: all the board squares just read “Button.”
Our testing pattern requires that we fix this issue before proceeding.
- Add a label to each square.
- Ensure that each square communicates both position and value.
Now, we can navigate and query the DOM using content available to assistive technologies.
With a more accessible board, we can finally write tests.
Test user events with Storybook interactions and play functions
In Storybook, we use interactions to create stories from user events.
Let’s add the XWins
story to Game.stories.tsx
using a play function.
- Get all of the squares by their label (now containing the string
"space"
). - Then simulate a game by awaiting
click
user events on each specific squares.
Unlike our unit tests (above), this test simulates an actual game of tic-tac-toe.
Make assertions with Jest
Up to this point, we’ve created visual tests (stories) but without codifying our expectations. Let’s add tests using jest
’s expect
function.
- Import
expect
from@storybook/jest
. - Test that the game declares
X
a winner — after our interactions.
Open the Storybook interactions panel to see the expectations pass.
Compose play functions for re-use
It would be a real hassle to re-create the XWins
play function for every test. Instead, let’s compose it into a new test as a prerequisite.
- Add a new
XWinsThenReturnsToMove3
story. - Use
testing-library
to setup the canvas and elements. - Call and await the
XWins.play
function (passing context). - Await additional interactions and expectations.
Our new XWinsThenReturnsToMove3
runs the XWins
play function and then plays the additional interactions and expectations.
Note: In a JavaScript codebase, I would return an object with { canvas, squares }
from the play function. This reduces selector repetition when composing stories. But, in TypeScript, the StoryObj<typeof { component: MyComponent }>
type dissalows return values. But I’m told this isn’t strictly necessary and could change.
What we learned
This was a long one but we covered a lot of ground.
We learned that unit tests are often the wrong way to test UI. Not because unit tests are bad. But because they promote thinking like a developer, not a user. And you can’t spell UI without U.
When practice interaction testing with testing-library, we can immediately see when UI is inaccessible. And we limit outcomes to user-producable states.
We learned how to capture tests as play functions. And how to compose play functions for re-use.
If you’d like to learn more about the mechanics of play functions in Storybook, check out my video on the topic.
Prefer video?
Watch on YouTube!