QUI React

Testing Guidelines

Tests are authored using Cypress Component Testing

Advantages of Cypress:

  • Very fast.
  • Component tests offer in-browser testing with real DOM elements - no mocks required.
  • Cypress renders the components exactly as they're rendered in the built application, yielding significantly higher testing confidence.
  • Watch mode offers an interactive runner with a live preview of the rendered output.

Don't worry if you're unfamiliar with Cypress. There are helpful links at the bottom of this page.

There are two main challenges with testing:

  1. Knowing what to test.
  2. Knowing how to test.

For the sake of this component library, #1 is actually pretty easy. We need to test any exported component, directive, service, etc... that our consumers will use. In the future, this guide will also cover testing applications, which is a bit more nuanced. For now, let's instead focus on how to test.

How to Test

TIP

Think less about the code you're testing and more about the use cases that code supports.

Focus on Integration Tests

Integration tests strike a great balance on the trade-offs between confidence and speed/expense. This is why it's advisable to spend most of your effort here.

The line between integration tests and unit tests is a bit fuzzy, but the main distinction is that unit tests typically rely more on mocking. When you mock something, you're removing all confidence in the integration between what you're testing and what's being mocked. This isn't to say that mocking isn't useful, but it should be done sparingly.

Don't Test Implementation Details

TIP

Implementation details are things that users of your code won't typically use, see, or even know about.

When you focus on the code itself, it's too easy and natural to start testing implementation details. There are two primary reasons to avoid this. Tests which test implementation details:

False negatives

False negatives happen when tests fail on working code.

Let's view an example. Consider the following component:

import {ReactNode, useState} from "react"
interface Item {
contents: string
id: number
title: string
}
interface Props {
items: Item[]
}
export function SimpleAccordion({items}: Props): ReactNode {
const [openIndex, setOpenIndex] = useState(-1)
const toggle = (index: number) => {
setOpenIndex((prevState) => (prevState === index ? -1 : index))
}
return (
<>
{items.map((item, index) => {
return (
<div key={item.id}>
<button onClick={() => toggle(index)}>{item.title}</button>
{openIndex === index ? (
<div className="item-content">{item.contents}</div>
) : null}
</div>
)
})}
</>
)
}

The developer writes the following test to verify that the panel opens after the user clicks the button:

describe("Accordion Test", () => {
it("Shows the panel contents when the button is clicked", () => {
const items: Item[] = [
{contents: "Content 1", id: 21, title: "Title 1"},
{contents: "Content 2", id: 32, title: "Title 2"},
]
cy.mount(<SimpleAccordion items={items} />)
cy.get("button").first().click()
cy.get(".item-content").should("be.visible")
})
})

The developer runs the test, and it works, at least for now. Let's look at how things break down with this test.

False negatives when refactoring

Let's say we refactor the accordion's className. This refactor doesn't change the existing behavior of the component, it just changes the implementation details (the style of the accordion's content).

// ...
<>
{items.map((item, index) => {
return (
<div key={item.id}>
<button onClick={() => setOpenIndex(index)}>{item.title}</button>
{openIndex === index ? (
<div className="accordion-content">{item.contents}</div>
) : null}
</div>
)
})}
</>

If we run the tests after this change, we'll see that they've failed!

CAUTION

Timed out retrying after 4000ms: Expected to find element: .item-content, but never found it.

Is this test actually warning us of a real problem? No, the component still works fine. This is an example of a false negative. It means that we've received a test failure, but only because of a broken test.

Notes
  • Testing implementation details can produce false negatives when you refactor your code. This leads to brittle and frustrating tests that break easily.
  • Avoid brittle selectors like CSS classes which can change often.

False positives

For this next exercise, we'll modify the scenario from before. Say the developer accidentally flips the condition on the index check.

export function SimpleAccordion({items}: Props): ReactNode {
const [openIndex, setOpenIndex] = useState(-1)
const toggle = (index: number) => {
setOpenIndex((prevState) => (prevState === index ? -1 : index))
}
return (
<>
{items.map((item, index) => {
return (
<div key={item.id}>
<button onClick={() => toggle(index)}>{item.title}</button>
{openIndex !== index ? (
<div className="item-content">{item.contents}</div>
) : null}
</div>
)
})}
</>
)
}
describe("Accordion Test", () => {
it("Shows the panel contents when the button is clicked", () => {
const items: Item[] = [
{contents: "Content 1", id: 21, title: "Title 1"},
{contents: "Content 2", id: 32, title: "Title 2"},
]
cy.mount(<SimpleAccordion items={items} />)
cy.get("button").first().click()
cy.get(".item-content").should("be.visible")
})
})

If we run the same test, we'll see that everything passes. Yet the accordion no longer functions as it's intended: every item apart from the intended item is visible. This is an example of a false positive: the component code is effectively broken, but the test still passes.

Testing the Right Way

We write tests to be confident that our components will work correctly. To write tests with high confidence, we want to test for the right behavior.

Consider the accordion example from earlier. We should write tests to ensure that our accordion behaves as intended. Before we can do that, we have to define the component's behavior:

An accordion is a menu composed of vertically stacked headers that reveal more details when triggered. For the sake of this example, our accordion only allows one section to be open at a time.

Let's refactor the previous example to verify this behavior:

describe("Accordion Test", () => {
it("Shows the panel contents when the button is clicked", () => {
const items: Item[] = [
{contents: "Content 1", id: 21, title: "Title 1"},
{contents: "Content 2", id: 32, title: "Title 2"},
]
cy.mount(<SimpleAccordion items={items} />)
// Select the first button by its text content.
cy.contains(items[0].title).click()
// Verify the visible section by its text content.
cy.contains(items[0].contents).should("be.visible")
// Extra validation is usually a good idea. Let's ensure that only one panel
// is visible at a time.
cy.contains(items[1].contents).should("not.exist")
// Toggle the second item.
cy.contains(items[1].title).click()
// revalidate
cy.contains(items[1].contents).should("be.visible")
cy.contains(items[0].contents).should("not.exist")
})
})

Implementing Tests

Tests live alongside each component and service. The Cypress runner is configured to pick up every file ending in .cy.ts.

Say you're developing tests for the QButton component. The directory structure will look like this:

react
src
components
button
index.ts
q-button.tsx
q-button.cy.tsx(cypress tests go here)

To run tests in interactive development mode, run the following from the root of the repository:

pnpm test:react:watch

For more information on Cypress Component Tests, check out the official documentation.

Additional Resources

Key Takeaways

  • Write lots of tests, mostly integration.
  • Test behavior, not implementation.
  • Your tests should resemble how your software is used.