Scott Nath

Sharing Interaction Tests Between Vitest and Storybook


How to use the same testing-library UI tests for Storybook Interaction testing and Vitest, Vite’s native test runner.

tl;dr

Tests can be shared between Vitest tests (*.test.js) and Storybook stories’ play tests by using shared test files (*.shared-spec.js).

Replace all assertions:

describe('Meow component', () => {
  it('should meow', () => {
    const { thing1, thing2, thing3, thing4 } = render(<Meow />);
    expect(thing1).toBe('some assertion 1')
    expect(thing2).toBe('some assertion 2')
    expect(thing3).toBe('some assertion 3')
    expect(thing4).toBe('some assertion 4')
  })
})

With shared tests:

describe('Meow component', () => {
  it('should meow', () => {
    sharedTests(render(<Meow />), Meow.props)
  })
})
Series: Sharing tests across UI components
Prerequisite knowledge

Why share tests between Storybook and Unit tests?

Time and money!

…and coverage reporting requirements…and trust issues…

Shared tests and how to use them

Staying D.R.Y. so… see part 1 and part 2 of the shared tests series for details.

Basic example of shared-spec tests being used in Vitest

Take a basic Vitest for a button:

// button.test.js
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
describe('MeowButton', () => {
  const props = {
    label: 'meow'
    onClick: vi.fn(),
  };
  it('should have properly configured attributes', async () => {
    const { queryByRole } = render(<Button {...props} />)
    const button = await queryByRole('button')
    await expect(button).toBeTruthy();
    await expect(button).toHaveTextContent(props.label);
  });
  it('should respond to mouse interaction', async () => {
    const { queryByRole } = render(<Button {...props} />)
    const button = await queryByRole('button')
    await userEvent.click(button);
    await expect(args.onClick).toHaveBeenCalled();
    await expect(args.onClick).toHaveBeenCalledTimes(1);
    await args.onClick.mockClear();
  });
});

If you have shared-test methods like this:

// button.shared-spec.js
import { within, userEvent } from '@storybook/testing-library';
export const getElements = async (canvasElement) => {
  const button = await within(canvasElement).findByRole('button');
  return { button };
};
export const ensureElements = async (elements, args) => {
  const { button } = elements;
  // same assertions as in the Vitest
  await expect(button).toBeTruthy();
  await expect(button).toHaveTextContent(args.label);
};
export const mouseInteraction = async (elements, args) => {
  const { button } = elements;
  // same user event as in the Vitest
  await userEvent.click(button);
  // same assertions as in the Vitest
  await expect(args.onClick).toHaveBeenCalled();
  await expect(args.onClick).toHaveBeenCalledTimes(1);
  await args.onClick.mockClear();
}

Then integrating the shared-test methods in your Vitest would allow you to centralize all assertions for use in other testing systems. You’d only need to write the Vitest-specific containers with Vitest doing the component rendering.

// button.test.js (updated)
import { render } from '@testing-library/react';
import { getElements, ensureElements } from './button.shared-spec';
import { vi } from 'vitest';
describe('MeowButton', () => {
  const props = {
    label: 'meow'
    onClick: vi.fn(),
  };
  it('should have properly configured attributes', async () => {
    const rendered = render(<Button {...props} />)
    const elements = await getElements(rendered.container);
    await ensureElements(elements, props);
  });
  it('should respond to mouse interaction', async () => {
    const rendered = render(<Button {...props} />)
    const elements = await getElements(rendered.container);
    await mouseInteraction(elements, props);
  });
});

As a refresher on shared tests in Storybook - here are the shared-test methods in a Storybook story.

// button.stories.js
import { getElements, ensureElements } from './button.shared-spec';
export const MeowButton = {
  args: {
    label: 'meow',
  },
  play: async ({ args, canvasElement }) => {
    const elements = await getElements(canvasElement);
    await ensureElements(elements, args);
    await mouseInteraction(elements, args);
  },
};

Important Points

Expand the concept with a shared test suite!

While the previous example may seem overbuilt, this concept is crazy-helpful when you have a lot of tests to write for a lot of different scenarios. A way to make this easier is to create a shared test suite method within your test file.

// button.test.js (updated)

/**
 * Uses the shared spec methods inside vitest test functions
 * @param Component - OG component or a composed-via-storybook version
 * @param args - props to pass to the component
 */
const buttonTestSuite = (Component, args) => {
  it('should have properly configured attributes', async () => {
    const rendered = render(<Component {...args} />)
    const elements = await getElements(rendered.container);
    await ensureElements(elements, args);
  });
  it('should respond to mouse interaction', async () => {
    const rendered = render(<Component {...args} />)
    const elements = await getElements(rendered.container);
    await mouseInteraction(elements, args);
  });
  it('should respond to keyboard interaction', async () => {
    const rendered = render(<Component {...args} />)
    const elements = await getElements(rendered.container);
    await keyboardInteraction(elements, args);
  });
}

describe('Button', () => {
  describe('Primary', () => {
    buttonTestSuite(Primary, Primary.args);
  });
  describe('Secondary', () => {
    buttonTestSuite(Secondary, Secondary.args);
  });
  describe('Large', () => {
    buttonTestSuite(Large, Large.args);
  });
  describe('Small', () => {
    buttonTestSuite(Small, Small.args);
  });
});

The above will test each permutation of Button in the same way, and you can add as many test examples as you want without having to repeat yourself or excessively increasing the size of the file.

Verbose-output coverage would look like this:

 src/stories/Button.test.jsx (12)
   Button (12)
     Primary (3)
       should have properly configured attributes
       should respond to mouse interaction
       should respond to keyboard interaction
     Secondary (3)
       should have properly configured attributes
       should respond to mouse interaction
       should respond to keyboard interaction
     Large (3)
       should have properly configured attributes
       should respond to mouse interaction
       should respond to keyboard interaction
     Small (3)
       should have properly configured attributes
       should respond to mouse interaction
       should respond to keyboard interaction

Test Files  1 passed (1)
    Tests  12 passed (12)
  Start at  16:07:51
  Duration  1.38s (transform 188ms, setup 159ms, collect 293ms, tests 118ms, environment 345ms, prepare 85ms)

Conclusion

Sharing tests between Storybook and your application’s test suite could be seen as a niche-need … however, looking at your testing in a more programatic way will: