Skip to main content

Scott Nath Frontend Architect

Scott Nath's picture
GitHubdev.toLinkedInMastodon
Header image for article 'Sharing UI Tests Between Javascript Frameworks'

Sharing UI Tests Between Javascript Frameworks

How to share testing-library UI tests between Javascript frameworks with the same or similar components and use them in Storybook and unit testing.


How to share testing-library UI tests between Javascript frameworks with the same or similar components and use them in Storybook and unit testing.

tl;dr

Tests can be shared between Javascript components which have a similar HTML structure and/or a similar UX. The same shared tests can be used in both Storybook’s play tests (Interaction testing) and Vitest unit tests.

Prerequisite knowledge

Why share tests between Javascript Frameworks?

  • You have one or more applications which share UX, but which are built with different JS Frameworks
  • When converting an application from one JS Framework to another
  • Your company maintains a design system with multiple component libraries built with different JS Frameworks, but the same UX

Shared tests and how to use them

Staying D.R.Y. so… dig through part 1 - “What are shared tests”, part 2 - “Sharing tests between components”, and part 3 - “Sharing tests between Vitest and Storybook” to get the low-down on shared tests.

Why does this work? Are you a wizard?

As I’ve been writing this series, I realized it’s an ode to the magic of Testing Library, which is built for testing UIs as a user would experience an app. This principle means Testing Library is not introduced to your UI until after it has already rendered…which means Testing Library is navigating a DOM just as a user would…and users do not know or care what framework you used to build your UI, just that it works as expected.

Testing Library is the real MVP here.

What are we testing this time?

This article shows a set of shared-tests for testing a Counter component rewritten across multiple Javascript frameworks. We’ll be using the components found in the Astro Web Framework’s example: Kitchen Sink (Multiple Frameworks). These are written in Preact, React, Svelte, SolidJS, and Vue. This Astro microfrontend example is fantastic in that it demonstrates how the Astro Web Framework can have multiple Javascript frameworks running on the same page at the same time.

Fun fact! This is the codebase I used to demonstrate Storybook presenting multiple frameworks in the DevOps for Multi-Framework Composition Storybooks series.

What is the Counter component?

The Counter component is a simple component which displays a number and has two buttons: one to increment the number and one to decrement the number. Each framework version has the exact same UX and HTML structure, with slight variations due to the syntax of the framework.

HTML for PreactCounter.tsx:

<div class="counter">
  <button onClick={subtract}>-</button>
  <pre>{count}</pre>
  <button onClick={add}>+</button>
</div>

HTML for ReactCounter.tsx:

<div className="counter">
  <button onClick={subtract}>-</button>
  <pre>{count}</pre>
  <button onClick={add}>+</button>
</div>

HTML for SvelteCounter.svelte:

<div class="counter">
  <button on:click={subtract}>-</button>
  <pre>{count}</pre>
  <button on:click={add}>+</button>
</div>

HTML for VueCounter.vue:

<div class="counter">
  <button @click="subtract()">-</button>
  <pre>{{ count }}</pre>
  <button @click="add()">+</button>
</div>

Rendered HTML for all Counter components is the same

All components output the same HTML, which is the voodoo which makes this possible. Same HTML? Same tests.

<div class="counter">
  <button>-</button>
  <pre>0</pre>
  <button>+</button>
</div>

The shared tests

1. Getting the elements

The first step? Query the DOM for the elements we want to test.

We need:

  • the + (plus) button
  • the - (minus) button
  • the pre element which contains the count
  • the container, which is a div with a class of counter

Using Testing Library’s queries, we’ll create an object by parsing the rendered HTML. In this method, canvasElement is the live DOM rendered by the framework.

import { within } from '@storybook/testing-library';
/**
 * Extract elements from an HTMLElement
 */
export const getElements = async (canvasElement) => {
  // `within` adds the testing-library query methods
  const screen = within(canvasElement);

  return { 
    screen,
    canvasElement,
    // `querySelector` used here to find the generic `div` container
    container: await canvasElement.querySelector('.counter'),
    // `queryByRole` finds each button, using `name` to search the text
    minus: await screen.queryByRole('button', { name: /-/i }),
    // using `queryBy` instead of `getBy` to avoid errors in `getElements`
    plus: await screen.queryByRole('button', { name: /\+/i }),
    // `querySelector` again as `pre` has no role and contains variable content
    count: await canvasElement.querySelector('pre'),
  };
}

This returns an object with our elements:

{
  screen: {object with testing-library query methods},
  canvasElement: <the-initial-html-element,unchanged>,
  container: <div class="counter">...</div>/{and query methods},
  minus: <button>-</button>/{and query methods},
  plus: <button>+</button>/{and query methods},
  count: <pre>0</pre>/{and query methods},
}

2. Testing the initial rendered elements

The Counter is a simple component that has no props, so this method just ensures the elements are present and the counter starts at 0. ensureElements should be called before interaction tests to ensure the initial state of the component is what is tested.

The elements arg is the object returned from getElements.

/**
 * Ensure elements are present and have the correct attributes/content
 */
export const ensureElements = async (elements) => {
  const { minus, plus, count } = elements;
  // `.toBeTruthy` ensures the element exists
  await expect(minus).toBeTruthy();
  await expect(plus).toBeTruthy();
  await expect(count).toBeTruthy();
  // ensures the count starts at zero
  await expect(count).toHaveTextContent('0');
}

3. Testing keyboard navigation

For keyboard-only users, we’ll need to ensure the buttons are focusable and in the expected order. We test navigation separate from interactions to keep the testing methods focused on one type of user experience at a time.

The component only has two focus-able elements - which are the buttons.

/**
 * Test keyboard interaction
 */
export const keyboardNavigation = async (elements) => {
  const { minus, plus, container } = elements;
  // tab within the container
  await userEvent.tab({ focusTrap: container });
  await expect(minus).toHaveFocus();
  // `pre` is the next element, but it's not focusable
  await userEvent.tab({ focusTrap: container });
  await expect(plus).toHaveFocus();
}

4. Testing keyboard interactions

For testing the interaction of the buttons, we’ll be adding or subtracting to the number in pre. We cannot ensure the order the testing methods will be used, which means elements.count might not be 0. This test method will start by getting whatever number is in elements.count and use that number to test the expected addition or subtraction result.

/**
 * Test keyboard interactions
 */
export const keyboardInteraction = async (elements) => {
  const { minus, plus, count, container } = elements;
  // could be any number
  const initCount = parseInt(count.textContent);
  // navigation unimportant here, so we'll just focus on the button
  await plus.focus();
  // with the `plus` button in focus, hitting `enter` should increment
  await userEvent.keyboard('{enter}');
  await expect(parseInt(count.textContent)).toBe(initCount + 1);
  await userEvent.keyboard('{enter}');
  await expect(parseInt(count.textContent)).toBe(initCount + 2);
  await minus.focus();
  await userEvent.keyboard('{enter}');
  await expect(parseInt(count.textContent)).toBe(initCount + 1);
  await userEvent.keyboard('{enter}');
  await expect(parseInt(count.textContent)).toBe(initCount);
  await userEvent.keyboard('{enter}');
  await expect(parseInt(count.textContent)).toBe(initCount - 1);
  // reset user focus to nothing
  await minus.blur();
}

5. Testing mouse interactions

Same as keyboard interactions, but with mouse clicks instead of keyboard events.

/**
 * Test mouse interaction
 */
export const mouseInteraction = async (elements) => {
  const { minus, plus, count } = elements;
  const initCount = parseInt(count.textContent);
  await userEvent.click(plus);
  await expect(parseInt(count.textContent)).toBe(initCount + 1);
  await userEvent.click(plus);
  await expect(parseInt(count.textContent)).toBe(initCount + 2);
  await userEvent.click(minus);
  await expect(parseInt(count.textContent)).toBe(initCount + 1);
  await userEvent.click(minus);
  await expect(parseInt(count.textContent)).toBe(initCount);
  await userEvent.click(minus);
  await expect(parseInt(count.textContent)).toBe(initCount - 1);
  // reset user focus
  await minus.blur();
}

Using the shared tests in Storybook

Now that we have our shared tests, we can use them in Storybook. (see part 1). Below is the React story file, see the full set of framework-specific stories here. The test methods are broken out into step functions for legibility in the Storybook UI, but they are not required.

// ReactCounter.stories.js
import { ReactCounter } from '../../src/components/ReactCounter';
import { getElements, ensureElements, mouseInteraction, keyboardNavigation, keyboardInteraction } from '../../src/components/tests/counter.shared-spec';

export default {
  title: 'React',
  component: ReactCounter,
};

export const React = {
  play: async ({ args, canvasElement, step }) => {
    const elements = await getElements(canvasElement);
    step('react tests', async () => {
      await step('ensure elements', async () => {
        await ensureElements(elements);
      });
      await step('mouse interaction', async () => {
        await mouseInteraction(elements);
      });
      await step('keyboard navigation', async () => {
        await keyboardNavigation(elements);
      });
      await step('keyboard interaction', async () => {
        await keyboardInteraction(elements);
      });
    });
  },
};

Using the shared tests in Vitest unit tests

They also drop-in to our unit tests. (see part 3). Below is the React unit test file, see the full set of framework-specific unit tests here. The magic here is in the render function. Each framework has a different one, but once Vitest renders it, the resulting output of all frameworks is understood by the shared tests.

note: The separate it functions are required for now for React, which was having some issues isolating the user interactions when they are all ran together. The it functions are not required for all frameworks, but since they give legibility anyway, we’ll keep them.

// Vue.spec.tsx
import { render } from '@testing-library/vue';
import { describe, it, assert, expect } from 'vitest';

import VueCounter from '@/components/VueCounter.vue';
import { getElements, ensureElements, mouseInteraction, keyboardNavigation, keyboardInteraction } from '@/components/tests/counter.shared-spec';

describe('Vue', () => {
  describe('Counter', () => {
    it('ensure elements', async () => {
      const rendered = render(VueCounter);
      const elements = await getElements(rendered.container);
      await ensureElements(elements);
    });
    it('mouse interaction', async () => {
      const rendered = render(VueCounter);
      const elements = await getElements(rendered.container);
      await mouseInteraction(elements);
    });
    it('keyboard navigation', async () => {
      const rendered = render(VueCounter);
      const elements = await getElements(rendered.container);
      await keyboardNavigation(elements);
    });
    it('keyboard interaction', async () => {
      const rendered = render(VueCounter);
      const elements = await getElements(rendered.container);
      await keyboardInteraction(elements);
    });
  });
})

Wrapping up

Writing sharable user-experience-based tests for your UI is really about planning for the future.

The NewHotness™ UI framework is being built right now and in two years that React component you put your soul into is going to be deleted in lieu of the shiny thing. That doesn’t mean your UX needs to change … or your Design … or your tests. Just do the facelift next time.

Efficiency in all things, and productivity will follow.