Improving a testing-library test

By: Jeremy W. Sherman. Published: . Categories: testing. Tags: testing-library jest react webdev.

Test clarity helps in understanding the claims being made and the various ways the test might fail. With JavaScript/TypeScript, asynchrony can make this syntactically more confusing.

So I saw this code recently:

// Test for official email address
await waitFor(() =>
  expect(screen.getByTestId("officialEmail").getAttribute("href")).toBe(
    "mailto:OFFICIAL_any_string@email.com"
  )
);

This is using testing-library‘s explicit polling waitFor to repeatedly test the predicate. If it keeps failing till a timeout, then it concludes the test failed.

Two problems:

Matching how users would see the content

For this, I’ll point to Testing Library’s advice on which query to use, “About Queries: Priority”. The short version is “accessibility APIs, then visible stuff, then invisible stuff”. A test ID is firmly in the “invisible” category; this could be improved by instead searching by link, or by searching just for the text in question.

That’s not actually what I want to focus on here, though, and Testing Library covers that well enough.

Hard to read & slow

Yes, I’m counting this as one issue, because I’m blaming waitFor, and rewriting the test not to use it naturally leads to fixing both readability and slowness.

Nesting & Overhead

It’s hard to read because of the nesting. That’s a lot of syntax to do an attribute check.

Waiting for test success, not just element presence

It slow because it mixes up synchronizing with rendering (“is this thing here yet that we need to exist before the test makes sense?") and passing the test (“ok but is that thing right?"). It should only be waiting for the element to appear. But by including the test expectation within the waitFor'd predicate, when the element has rendered but the test concerning the element fails, waitFor will keep polling long past the time the test outcome could have changed: It’ll run out the clock on its timeout.

Switch to findBy

So, we separate syncing on the element’s presence from checking its content. The cleanest way to do this is to use one of the async findBy… queries, which handles the waiting on our behalf.

Syntactic gotcha WRT what to await

But there’s a syntactic gotcha; if you write:

/* DOES NOT COMPILE */
await waitFor(() =>
  expect(await screen.findByTestId('officialEmail').getAttribute('href')).toBe(
  'mailto:OFFICIAL_any_string@email.com'
  )
)
/* DOES NOT COMPILE */

then it won’t work. With TypeScript, it won’t even compile:

error TS2339: Property 'getAttribute' does not exist on type 'Promise<HTMLElement>'.

This error is informative, though:

findByTestId returns a Promise of some type. Promise does not have a getAttribute function. But the promised type does. So narrow the scope of what you’re awaiting to that particular expression using parentheses:

expect(
  (await screen.findByText("OFFICIAL_any_string@email.com")).getAttribute(
    "href"
  )
).toBe("mailto:OFFICIAL_any_string@email.com");

The more readable way to write this would be to extract the expression to a named variable:

const emailElement = await screen.findByText("OFFICIAL_any_string@email.com");
expect(emailElement.getAttribute("href")).toBe(
  "mailto:OFFICIAL_any_string@email.com"
);

Now it compiles, and we’ve fixed what we’re syncing on.

But we’re not done yet.

Getting the most out of test failure

The next step is to realize that, if this fails, it’s going to provide very poor context, because it’s just a string comparison.

TDD’s “watch it fail” step serves two purposes:

Those concerns apply whenever you’re writing tests; TDD just frontloads addressing them. In test-after coding, you need to ensure your automated test in fact catches what you were manually testing for before automating. With test-after, you watch it fail by breaking it on purpose:

I often find the test-failure tuning easier when corrupting the expectation in the test code rather than the actual implementation. It’s just easier to break all the links in the test chain that way, rather than doing it at a distance by breaking the implementation. If you started by breaking the implementation, you know the core claim of the test will be checked, so I feel it’s OK to just go straight to breaking the test itself when tuning output.

Use higher-level matchers to inject more context into the failure message

So back to the example. Currently, a failed expectation isn’t all that helpful:

expect(received).toBe(expected); // Object.is equality

Expected: "mailto:OFFICIAL_any_string!@email.com";
Received: "mailto:OFFICIAL_any_string@email.com";

This spawns some immediate questions:

It’s a mailto: scheme, so you can think a bit and work out it’s probably an href attribute, but that takes thinking. You don’t want to spend time and effort inferring that. Push that context into the test!

Fix that by using a more contextual matcher:

expect(
  await screen.findByText("OFFICIAL_any_string@email.com")
).toHaveAttribute("href", "mailto:OFFICIAL_any_string@email.com");

And then it fails with enough information to start debugging just from the error alone:

expect(element).toHaveAttribute("href", "mailto:OFFICIAL_any_string!@email.com") // element.getAttribute("href") === "mailto:OFFICIAL_any_string!@email.com"
Expected the element to have attribute:
    href="mailto:OFFICIAL_any_string!@email.com"
Received:
    href="mailto:OFFICIAL_any_string@email.com"

You’ll note that I was triggering the failure case by intentionally corrupted the expected value, by injecting a ! into it.

Narrow the scope of element search so failed searches dump the DOM you care about

What if the element isn’t even there? How helpful is the test failure?

I corrupted the matcher and confirmed it provides some context to help, but probably I’d want to scope down to the specific component, so the HTML output is less likely to truncate before hitting the relevant part I’d want to see, and so the person debugging has less logspew to wade through.

Let’s say this was checking links in a “Contact Info” section on a “Profile” page.

In the context of a whole page, the clean way to focus the failure info would be to introduce a <section> and then pull it out using a search for the region role:

diff --git a/src/components/Profile/ProfileCard.tsx b/src/components/Profile/ProfileCard.tsx
index 9931d8d..19ab3d9 100644
--- a/src/components/Profile/ProfileCard.tsx
+++ b/src/components/Profile/ProfileCard.tsx
@@ -242,11 +242,15 @@ const ContactInfo: React.VoidFunctionComponent<{
   const addressLabel = formatAddress(address)

   return (
-    <div className={classes.contactInfo}>
+    <section
+      className={classes.contactInfo}
+      aria-labelledby="contactinfo-header"
+    >
       <Typography
         className={classes.contactInfoHeader}
         variant="h6"
         component="h3"
+        id="contactinfo-header"
       >
         {t('ProfileCard.ContactInfo.Header', 'Contact Information')}
       </Typography>
@@ -302,7 +306,7 @@ const ContactInfo: React.VoidFunctionComponent<{
           className={classes.listItem}
         />
       </List>
-    </div>
+    </section>
   )
 }

(That would probably benefit from using some flavor of unique ID generator in case multiple contact info sections got rendered, but let’s ignore that for now.)

(Another way to improve the error output would be to narrow the scope of the test: instead of testing the component as part of a whole page, test the component directly. Then, when screen.findBy barfs, the whole screen is precisely the info we want to see. You might want to do that if this component gets reused elsewhere, but for now, assume it’s an implementation detail.)

Now we can use a matcher scoped to just that region:

const contactInfo = within(
  await screen.findByRole("region", { name: /contact info/i })
);

expect(
  await contactInfo.findByText("OFFICIAL_any_string?@email.com")
).toHaveAttribute("href", "mailto:OFFICIAL_any_string!@email.com");

This uses within to narrow the queried region, which also narrows the “nothing found, here’s what is there” output usefully. Now, a failed element search dumps the entire Contact Info region to the test log, rather than the entire blessed page, so you can plainly see what’s what.

Conclusion