DEV Community

loading...
Cover image for Build Reusable Testing Queries

Build Reusable Testing Queries

jonmajorc profile image Jon Major Condon ・5 min read

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...

  1. Data attributes may not appear in a third-party component.
  2. 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=""
/>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 the Select 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
Enter fullscreen mode Exit fullscreen mode

If we were to inspect the third-party component, there's a lot of divs 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)
})
Enter fullscreen mode Exit fullscreen mode

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')   
})
Enter fullscreen mode Exit fullscreen mode

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']`
    ),
  }
}
Enter fullscreen mode Exit fullscreen mode
//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')
})
Enter fullscreen mode Exit fullscreen mode

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! 👋"

Discussion (2)

pic
Editor guide
Collapse
frenkix profile image
Goran Jakovljevic

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.

Collapse
jonmajorc profile image
Jon Major Condon Author

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")