Kicking Ass With Playwright

By the end of this workshop, you'll be able to:

  • Write stories in Component Story Format
  • Test components with Storybook interaction tests
  • Use Storybook in development workflows

Writing stories in Component Story Format (CSF)

Storybook Cascade

.storybook/preview.js
src/some-component.stories.tsx - Meta
src/some-component.stories.tsx - Story

Every story

Groups of stories

One story

Component Story Format

import { Meta, StoryObj } from "@storybook/react"
import { MyComponent } from "./MyComponent"

// Create Meta
const meta: Meta<typeof MyComponent> = {
	title: 'Path/To/MyComponent',
	component: MyComponent,
};
				 
export default meta;

// Create a story type
type Story = StoryObj<typeof MyComponent>;

// Write stories
export const SomeName: Story = {
	name: "Some Name",
	args: {
		someProp: "Some value",
	},
};

1. Create Meta

2. Create Story type

3. Write stories

// Don't import Story
import { Meta, Story } from "@storybook/react"
import { MyComponent } from "./MyComponent"

// `as` loses the type check
export default {
	title: 'Path/To/MyComponent',
	component: MyComponent,
} as Meta;
				 

// Use types and render methods instead of templates
const Template: Story<MyComponentProps> = (...args) => (
  <MyComponent {...args} />
)

// No need to bind anymore
const SomeName = Template.bind({})
SomeName.name = "Some Name"
SomeName.args = {
	someProp: "Some value",
}

// Story exports should go inline
export { Default }
import { Meta, StoryObj } from "@storybook/react"
import { MyComponent } from "./MyComponent"

// Create Meta
const meta: Meta<typeof MyComponent> = {
	title: 'Path/To/MyComponent',
	component: MyComponent,
};
				 
export default meta;

// Create a story type
type Story = StoryObj<typeof MyComponent>;

// Write stories
export const SomeName: Story = {
	name: "Some Name",
	args: {
		someProp: "Some value",
	},
};

Old Way

New Way

Before and After

Title Hierarchy

const meta: Meta<typeof MyComponent> = {
	title: 'Components/ContactCardCheckout/States',
	component: MyComponent,
};
  • States
  • Actions
  • Other
  • Components
  • Organisms
  • Pages

Component Name

Sharing Attributes

export const Default: Story = {
	args: {
		heading: "Some Heading",
	},
}

export const Valid: Story = {
	args: {
		...Default.args,
		isValid: true,
	},
}

export const Invalid: Story = {
	args: {
		...Default.args,
		isValid: false,
	},
}

Just spread!

Template

`render()`

export const SomeName: Story = {
	name: "Some Name",
    render: (args) => {
		const [someState, setSomeState] = useState("")
    	return (
        	<MyComponent
        		{...args}
				someState={someState}
				setSomeState={setSomeState}
      		/>
		)
    },
	args: {
		someProp: "Some value",
	},
};

Any JavaScripty stuff has to happen here!

Testing components in Storybook

Interaction Tests

  • Go in `.play()` functions in stories
  • Uses Jest (Vitest) and RTL helpers/assertions
  • Run in your browser locally, run in Playwright in CI
  • Basic rendering tests are free

States vs. Interactions

  • One per interesting state
  • Static
  • Different props combinations
  • Starting point for tests & experiments
  • Play functions usually not needed
  • One per interesting failure
  • Reuse states
  • Things that change over time
  • Assert at boundary

Testing Syntax

export const SomeInteraction = {
 	...SomeState
 	play: async ({ canvasElement, args }) => {
		const canvas = within(canvasElement);

		await click(canvas.getByRole("button", { name: /Add/i }));

		expect(args.someCallback).toHaveBeenCalledWith("Hello, world!")
	},
}

Arrange

Assert

Act

  • `canvas` is your screen
  • Don't always need an `expect`

Test Helpers

// Use these instead of the Jest/RTL/User-Event versions
import { expect, within, fn, userEvent, waitFor } from "@storybook/test";

// These are interaction helpers, use these instead of DOM events
const { hover, click, dblClick, type, keyboard } = userEvent;

Steps

import { clickAdd } from "../steps";

export const SomeStory = {
	play: async ({ canvasElement, step }) => {
		const canvas = within(canvasElement);

		// Inline
		await step("Click add", () => {
			await click(await canvas.findByRole("button", { name: /Add/i }));
		});

		// Reuse a step
 		await step("Click add", clickAdd(canvas));
	},
}

Steps are a way to make interaction tests more semantic and composeable

Fixtures

const fixtures: Record<string, Contact> = {
  primaryHomeOwner: {
    contactId: "1",
    customerFirstName: "Primary",
    customerLastName: "Homeowner",
    customerPrimaryPhone: "(123) 456-7890",
    customerEmail: "primary.homeowner@email.com",
    primary: true,
    roles: ["Homeowner"],
    derivedRoles: ["Homeowner"],
  },
  // Others
};

export const createFixture = (key: keyof typeof fixtures) =>
  structuredClone(fixtures[key]);

Fixtures make data more semantic and reusable

Using Storybook

Addons Panel

  • Test Runner
  • a11y Checks
  • Controls

Typical Storybook Workflow

  • Build the non-interactive UI
    • Stories for each interesting state
  • Build the interactive elements
    • Test is: What would you do to verify?
  • Refactor code and tests
    • States, steps, fixtures

playwright-testing

By Kyle Coberly

playwright-testing

  • 73