This post is inspired by a problem I had about two weeks ago; I wrote brittle tests that interacted with the Select
component from React Material UI. After a bunch of time spent that day attempting many solutions, I landed on one that I am satisfied with... That solution is what I am sharing today!
TLDR; Keep a testbase maintainable and less brittle by sharing reusable DOM queries. The patterns for accessing "abstraction details" of a third-party component can change over time, but updates can be made in a single spot.
The Problem
I want to write tests that are maintainable and resemble the way my software is used. This means I need to simulate user interaction within components, including any third-party component. However...
- Data attributes may not appear in a third-party component.
- Data attributes may not appear on the intended element inside a third-party component.
I am a huge fan of data-testids, but I can't always rely upon them when working with a third-party component.
Quick Aside: The Material Select
component uses react-select
. This post will only use react-select
in a contrived example...
After some debugging, I discovered an id
on the input
tag inside react-select
.
<input
aria-autocomplete="list"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
id="react-select-2-input" {/* That's helpful! */}
spellcheck="false"
style="box-sizing: content-box; width: 2px; border: 0px; font-size: inherit; opacity: 1; outline: 0; padding: 0px;"
tabindex="0"
type="text"
value=""
/>
After testing by querying for the id
, I discovered that it increments based on the amount of rendered Select components on the page. I wouldn't trust this as a test id! This can potentially change at anytime causing cascading test failures. A good rule of thumb is to have a reserved id for testing. However, we don't have access to use data attributes or this id
on input anymore... I would rather have an id
on the root tag of the component anyways; then I can query anything scoped inside the component... Turns out, I can do this!
"Here is a hot take", if a component package does not allow data attributes, read the documentation and learn what can be passed as a substitute. There may be an id
or something that can be rebranded as a test id. In my case, I can do exactly that. In my contrived example, I can create my own internal Select
component that reintroduces react-select
with a required dataTestId
prop. Now I can use my internal component that has a trusted test id.
// Select.js
import ReactSelect from 'react-select'
import React from 'react'
import PropTypes from 'prop-types'
function Select({ dataTestId, ...props }) {
return <ReactSelect {...props} id={dataTestId} />
}
Select.propTypes = {
dataTestId: PropTypes.string.isRequired,
}
export default Select
The Solution
Let's carry on with some good old fashion “acceptance criteria.”
- I see my selected value in the input field of the
Select
component - I see my selected value in the
span
directly below theSelect
component
Here is the working contrived example that meets the acceptance criteria, but we need tests to ensure we avoid regression in production!
import React from 'react'
import Select from './Select'
const options = [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
]
function App() {
const [selectedOption, setSelectedOption] = React.useState({})
return (
<div>
<Select
dataTestId="select-ice-cream"
value={selectedOption}
onChange={valSelected => setSelectedOption(valSelected)}
options={options}
/>
<span data-testid="select-ice-cream-selected">You selected {selectedOption.value}</span>
</div>
)
}
export default App
If we were to inspect the third-party component, there's a lot of div
s and stuff within it. A lot of "abstraction details" that we don't care about. It can be rather difficult testing an unmocked third-party component, but doing so gives me better confidence that the application works correctly. Alright, since we are not using data-testid
, we cannot use the queryByTestId
selector from React Testing Library. I am going to use the DOM querySelector
instead...
it('renders without crashing', () => {
const { container, debug } = render(<App />)
const inputEl = container.querySelector('[id="select-ice-cream"] input')
debug(inputEl)
})
I don't know of a React Testing Library query available to us that queries for an attribute. That's why we're using the DOM querySelector
. We can do better though, we can turn the above into a custom query! And even better, I will return an object with elements that are needed for fulfilling the acceptance criteria!
it('shows selected value in input field and right below select', () => {
const { querySelectComponent } = render(<App />, {
queries: {
...queries,
querySelectComponent: (root, id) => {
return {
rootEl: root.querySelector(`[id=${id}]`),
inputEl: root.querySelector(`[id=${id}] input`),
spanEl: document.querySelector(
`div[id=${id}] + span[data-testid='${id}-selected']`
),
}
},
},
})
const { rootEl, inputEl, spanEl } = querySelectComponent('select-ice-cream')
fireEvent.change(inputEl, { target: { value: 'strawberry' } }) // change input value to strawberry
fireEvent.keyDown(inputEl, { key: 'Tab', code: 9 }) // select what the input value has as the selected value
//Assertions!
expect(spanEl).toHaveTextContent(/strawberry/)
expect(getByText(rootEl, 'Strawberry')).toHaveTextContent('Strawberry')
})
The test block now covers the acceptance criteria! And yes, we have a very specific selector containing abstraction details. div[id=${id}] + span[data-testid='${id}-selected']
. That selector is to make sure that the span appears directly below Select
as the Acceptance Criteria describes. The user should select a value and see the selected value in the input field of Select
and within the span
directly below Select
.
The current test block has queries to abstract the details of component selectors. It is ideal having the queries reusable inside any test block. Anyone who needs to interact with the Select
component, can use the same selector patterns within their tests. Every test can reuse the same pattern for accessing abstraction details of a third-party component, or possibly an internal component. But when react-select
updates, I can update my queries from a single spot!
//testUtils.js
export const selectComponentQueries = (root, id) => {
return {
rootEl: root.querySelector(`[id=${id}]`),
inputEl: root.querySelector(`[id=${id}] input`),
spanEl: document.querySelector(
`div[id=${id}] + span[data-testid='${id}-selected']`
),
}
}
//App.test.js
it('shows selected value in input field and right below select', () => {
const { container } = render(<App />)
const { rootEl, inputEl, spanEl } = selectComponentQueries(
container,
'select-ice-cream'
)
fireEvent.change(inputEl, { target: { value: 'strawberry' } })
fireEvent.keyDown(inputEl, { key: 'Tab', code: 9 })
expect(spanEl).toHaveTextContent(/strawberry/)
expect(getByText(rootEl, 'Strawberry')).toHaveTextContent('Strawberry')
})
Conclusion
Abstraction details of components can change. Keep a testbase maintainable and less brittle with shareable test utils for something like queries. That way, all tests use the same reusable code. Having queries in a single source will allow change to come much easier.
Hello! I'm Jon Major Condon. I am a Senior Software Farmer that tends to client codebases at Bendyworks. As a farmer of software, I focus on anything web, but my curiosity usually leads me down rabbit holes... "Jon Major just fell down another rabbit hole… Stay tuned for the next blog post! 👋"
Top comments (2)
I like this approach. Just a question, do you often write tests for external packages? I suppose that react-select has their own set of tests.
Thanks! Although react-select has its own tests, those tests do not integrate with our app. I write tests to test user behavior, so I will access third-party libraries to simulate user interaction within an app. I could mock but both ways come with a cost, and I think the tradeoffs for mocking doesn't outweigh testing with the actual component. (But I won't ever say "I won't ever use a mock")