Sharing Interaction Tests Between Components

scottnath

Scott Nath

Posted on April 26, 2023

Sharing Interaction Tests Between Components

tl;dr

Descriptions and code examples for sharing tests between components. Includes info on writing tests for Storybook's boilerplate components.

Goal: reduce your testing footprint while increasing coverage

Prerequisite knowledge

Why share tests between components?

Time and money!

Shared tests keep your overall codebase DRY, as in Don't Repeat Yourself.

Sharing tests between different parts of your codebase can have several benefits. If you have two or more modules that depend on the same functionality, it can be useful to write tests that cover that functionality once and then share those tests between the modules. For UI components, that means sharing unit tests, user-interaction tests, and you can stack them together to make in-depth E2E test suites.

Testing the Storybook Boilerplate Stories

When using Storybook's installer to add Storybook to your project, the installer includes three components with stories files to get you started. The three boilerplate stories are:

  • Button
  • Header, which imports Button
  • Page, which imports Header

These are generally the same components and stories across all frameworks. Since they build on each other, they're a great use-case for shared tests.

Testing the Button

The Button is the most complex with three props for styling, one for content, and one for user actions.

The Button tests were detailed in Reusable Storybook Interaction Tests. The Button.shared-spec.js which contains the Button's tests exports the following:

method description
getElements returns the elements to be tested
ensureElements conditionally tests the elements
mouseInteraction conditionally tests the mouse interactions
keyboardInteraction conditionally tests the keyboard interactions

Testing the Header

The Header internally configures the Button component and has it's own props. The following table shows the props the Header accepts:

prop description
user a simple object with a name property
onLogout function called when a user triggers the Log Out button
onLogin function called when a user triggers the Log In button
onCreateAccount function called when a user triggers the Sign Up button

Elements to gather from the Header

Things that will conditionally change based on the props:

element description
buttons an array of all buttons in Header
title the heading (h1) element
header the header element

header and title will always be the same since they are not changed by props, but we are testing them anyway to ensure they are present.

buttons will change based on the user prop.

Tests that Header's tests will run

  • Does the header element exist?
  • Does the title element exist?
  • Does the title element contain the text Acme?
  • If the user is logged in:
    • Is there one Button?
  • If the user is not logged in:
    • Are there two Buttons?
/**
 * Ensure elements are present and have the correct attributes/content
 */
export const ensureElements = async (elements, args) => {
  await expect(elements.header).toBeTruthy();
  await expect(elements.title).toBeTruthy();
  await expect(elements.title).toHaveTextContent('Acme');
  if (args.user) {
    await expect(buttons).toHaveLength(1);
  } else {
    await expect(buttons).toHaveLength(2);
  }
  ...
}
Enter fullscreen mode Exit fullscreen mode

Header lets Button test itself

Because the Button component has it's own tests, the Header component doesn't need to know how the Button works. It only needs to know that the Button works.

The Header tests each Button by using the Button's ensureElements method.

// Import the Button's shared tests
import { ensureElements as buttonEnsureElements } from "./Button.shared-spec";

...

export const ensureElements = async (elements, args) => {
  ... // Header's tests, see above

  if (args.user) {
    // Recreates button configuration from Header
    await buttonEnsureElements({ button: buttons[0] }, {
      label: 'Log out',
      size: 'small',
    });
  } else {
    await buttonEnsureElements({ button: buttons[0] }, {
      label: 'Log in',
      size: 'small',
    });
    await buttonEnsureElements({ button: buttons[1] }, {
      label: 'Sign up',
      size: 'small',
      primary: true,
    });
  }
Enter fullscreen mode Exit fullscreen mode

User interactions are owned by Button

The only interactive elements in the Header are the Buttons, so the only thing the Header needs to test for is how a keyboard user gets to the Button(s). Everything else, including whether the onLogout, onLogin, and onCreateAccount functions are called, are owned by the Button.

The Header tests it can get to the Button(s) by triggering a tab UserEvent within itself.

/**
 * Test keyboard interaction
 */
export const keyboardInteraction = async (elements, args) => {
  const { buttons, header } = elements;
  if (args.user) {
    // `focusTrap` keeps the user's keyboard within the `header` HTMLElement
    await userEvent.tab({ focusTrap: header });

    // we expect the `Log Out` button to have focus
    await expect(buttons[0]).toHaveFocus();

    // Uses Button's keyboard interaction tests
    await buttonKeyboardInteraction({ button: buttons[0] }, {
      label: 'Log out',
      size: 'small',
      onClick: args.onLogout,
    });
  } else {
    // again, starts within Header
    await userEvent.tab({ focusTrap: header });

    // we expect the `Log In` button to have focus
    await expect(buttons[0]).toHaveFocus();

    // User hits `tab` once more
    await userEvent.tab({ focusTrap: header });

    // we expect the `Sign Up` button to have focus
    await expect(buttons[1]).toHaveFocus();

    // Button tests for first button
    await buttonKeyboardInteraction({ button: buttons[0] }, {
      ...
    });

    // Button tests for second button
    await buttonKeyboardInteraction({ button: buttons[1] }, {
      ...
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing the Page

The Page component has no props and all of the page content is inside the code (tsk tsk), so we'll use the Header's tests to test the Page.

FYI - in some frameworks the Page boilerplate component relies on the JS Framework to control it's state. We'll test for JS Framework-specific when we share these tests between frameworks.

Elements to gather from the Page

In this instance, we'll use the Header's getElements method to get the elements we need.

// Import the Button's shared tests
import { getElements as headerGetElements } from './Header.shared-spec';

/**
 * Extract elements from an HTMLElement
 */
export const getElements = async (canvasElement) => {
  const screen = within(canvasElement);

  // Header knows how to get it's own elements
  const headerElements = await headerGetElements(canvasElement);


  return {
    // spreads the headerElements into this object
    ...headerElements,
    screen,
  };
}
Enter fullscreen mode Exit fullscreen mode

Tests that Page's tests will run

It'll just use Header's tests:

import { 
    ensureElements as headerEnsureElements,
    mouseInteraction as headerMouseInteraction,
    keyboardInteraction as headerKeyboardInteraction
} from './Header.shared-spec';

/**
 * Ensure elements are present and have the correct attributes/content
 */
export const ensureElements = async (elements, args, step) => {
  await headerEnsureElements(elements, args, step, true);
}

/**
 * Test mouse interaction
 */
export const mouseInteraction = async (elements, args, step) => {
  await headerMouseInteraction(elements, args, step, true);
}

/**
 * Test keyboard interaction
 */
export const keyboardInteraction = async (elements, args, step) => {
  await headerKeyboardInteraction(elements, args, step, true);
}
Enter fullscreen mode Exit fullscreen mode

Wrapping up

Self-knowledge for components is the lynchpin with sharing tests. As deeply as possible, a component should know every possible change that can happen to it, and how to test for each possibility.

Using this across a design-system, this should vastly reduce the testing footprint. As long as you're writing testing-library tests, this concept should allow the shared-spec tests to be used within your unit tests in addition to being used by Storybook stories.

Efficiency in all things - and productivity will follow!

💖 💪 🙅 🚩
scottnath
Scott Nath

Posted on April 26, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related