Skip to main content

Portable Stories

Portable stories allow you to reuse Storybook stories in external testing environments like Jest. This creates better shareability and maintenance between tests and stories.

Why Portable Stories?

Traditional unit tests require duplicating component setup:
// ❌ Without portable stories - duplicated setup
test('renders button', () => {
  render(
    <ThemeProvider>
      <Button text="Click me" color="purple" onPress={mockFn} />
    </ThemeProvider>
  );
});
With portable stories, setup is defined once in your story:
// ✅ With portable stories - reuse story setup
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

const { Primary } = composeStories(stories);

test('renders button', () => {
  render(<Primary />);
});
All args, decorators, and parameters from the story are automatically applied.

Setup

1

Install dependencies

npm install --save-dev @testing-library/react-native @testing-library/jest-native jest
2

Configure Jest

Create or update jest.config.js:
/** @type {import('jest').Config} */
const config = {
  preset: 'jest-expo', // or 'react-native'
  setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
  transformIgnorePatterns: [
    'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@storybook/.*)',
  ],
};

module.exports = config;
3

Create setup file

// setup-jest.ts
import 'react-native-gesture-handler/jestSetup';
4

Set up project annotations (optional)

If your stories use global decorators or parameters:
// setup-portable-stories.ts
import { setProjectAnnotations } from '@storybook/react';
import * as previewAnnotations from './.rnstorybook/preview';

setProjectAnnotations(previewAnnotations);
Update jest.config.js:
const config = {
  setupFilesAfterEnv: [
    '<rootDir>/setup-jest.ts',
    '<rootDir>/setup-portable-stories.ts'
  ],
};

composeStories

composeStories processes all stories from a CSF file and returns composed stories with all annotations applied.

Basic Usage

// Button.test.tsx
import { render, screen } from '@testing-library/react-native';
import { composeStories } from '@storybook/react';
import * as stories from './Button.stories';

// Every story is available as a composed component
const { Primary, Secondary } = composeStories(stories);

test('renders primary button with default args', () => {
  render(<Primary />);
  const buttonElement = screen.getByText('Text coming from args in stories file!');
  expect(buttonElement).not.toBeNull();
});

test('renders primary button with overridden props', () => {
  // Props override story args
  render(<Primary>Hello world</Primary>);
  const buttonElement = screen.getByText(/Hello world/i);
  expect(buttonElement).not.toBeNull();
});

Real Example: Testing Actions

From the Storybook React Native repository:
// Actions.test.tsx
import { render, screen, userEvent } from '@testing-library/react-native';
import { composeStories } from '@storybook/react';
import * as Actions from './Actions.stories';

const { Basic } = composeStories(Actions);

test('action story renders and onpress works', async () => {
  jest.useFakeTimers();

  const onPress = jest.fn();

  await render(<Basic onPress={onPress} />);

  const user = userEvent.setup({});
  const actionButton = screen.getByText('Press me!');

  await user.press(actionButton);

  expect(onPress).toHaveBeenCalled();
});

Real Example: Testing Controls

// Boolean.test.tsx
import { render, screen } from '@testing-library/react-native';
import { composeStories } from '@storybook/react';
import * as BooleanStories from './Boolean.stories';

const { Basic, On } = composeStories(BooleanStories);

test('boolean story renders', async () => {
  await render(<Basic />);
  screen.getByText('off');
});

test('boolean story renders on', async () => {
  await render(<On />);
  screen.getByText('on');
});

API Reference

composeStories(
  csfExports: CSF file exports,
  projectAnnotations?: ProjectAnnotations
): Record<string, ComposedStoryFn>
Parameters:
  • csfExports (required): Full set of exports from CSF file (e.g., import * as stories from './Button.stories')
  • projectAnnotations (optional): Override project annotations set via setProjectAnnotations
Returns: Object mapping story names to composed stories. Each story includes:
PropertyTypeDescription
storyNamestringThe story’s name
argsRecord<string, any>The story’s args
argTypesArgTypeThe story’s argTypes
idstringThe story’s id
parametersRecord<string, any>The story’s parameters

composeStory

Compose a single story instead of all stories from a file.

Basic Usage

// Button.test.tsx
import { jest, test, expect } from '@jest/globals';
import { render, screen, userEvent } from '@testing-library/react-native';
import { composeStory } from '@storybook/react';

import meta, { Primary } from './Button.stories';

test('onclick handler is called', async () => {
  // Returns a story with all annotations applied
  const PrimaryStory = composeStory(Primary, meta);

  const onPressSpy = jest.fn();

  render(<PrimaryStory onPress={onPressSpy} />);

  const user = userEvent.setup({});
  const actionButton = screen.getByText('Press me!');

  await user.press(actionButton);

  expect(onPressSpy).toHaveBeenCalled();
});

API Reference

composeStory(
  story: Story export,
  componentAnnotations: Meta,
  projectAnnotations?: ProjectAnnotations,
  exportsName?: string
): ComposedStoryFn
Parameters:
  • story (required): The story to compose
  • componentAnnotations (required): The default export from the stories file
  • projectAnnotations (optional): Override project annotations
  • exportsName (optional): Story export name for unique identification

setProjectAnnotations

Apply global decorators and parameters to all composed stories. Call once before tests run.

Setup File Example

// setup-portable-stories.ts
import { setProjectAnnotations } from '@storybook/react';
import * as addonAnnotations from 'my-addon/preview';
import * as previewAnnotations from './.rnstorybook/preview';

setProjectAnnotations([previewAnnotations, addonAnnotations]);
If a story requires an addon’s decorator or loader to render (e.g., router context), include that addon’s preview export in project annotations.
If you manually apply addon decorators in .rnstorybook/preview.tsx (e.g., withThemeFromJSXProvider), they’re already included in previewAnnotations.

API Reference

setProjectAnnotations(
  projectAnnotations: ProjectAnnotation | ProjectAnnotation[]
): void
Parameters:
  • projectAnnotations (required): Annotations from .rnstorybook/preview.tsx or addon preview exports

Annotations

Annotations are metadata applied to stories:
  • Args: Component props/inputs
  • Decorators: Wrappers for stories (context providers, layout)
  • Loaders: Async data fetchers
  • Parameters: Configuration for addons
Annotations can be defined at:
  1. Story level: Specific to one story
  2. Component level: All stories in a file
  3. Project level: All stories in .rnstorybook/preview.tsx

Testing Patterns

Testing Multiple States

import { composeStories } from '@storybook/react';
import * as ButtonStories from './Button.stories';

const { Default, Loading, Disabled, Error } = composeStories(ButtonStories);

describe('Button', () => {
  test('renders in default state', () => {
    render(<Default />);
    expect(screen.getByText('Click me')).toBeTruthy();
  });

  test('shows loading spinner', () => {
    render(<Loading />);
    expect(screen.getByTestId('spinner')).toBeTruthy();
  });

  test('is disabled when prop is set', () => {
    render(<Disabled />);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  test('shows error state', () => {
    render(<Error />);
    expect(screen.getByText('Something went wrong')).toBeTruthy();
  });
});

Testing with Custom Props

const { Primary } = composeStories(stories);

test('button can be customized', () => {
  const onPress = jest.fn();
  
  render(
    <Primary 
      title="Custom Text"
      color="blue"
      onPress={onPress}
    />
  );
  
  const button = screen.getByText('Custom Text');
  fireEvent.press(button);
  
  expect(onPress).toHaveBeenCalled();
});

Accessing Story Metadata

const { Primary } = composeStories(stories);

test('story has correct metadata', () => {
  expect(Primary.storyName).toBe('Primary');
  expect(Primary.args).toEqual({
    text: 'Hello World',
    color: 'purple',
  });
  expect(Primary.parameters.layout).toBe('centered');
});

Best Practices

Keep Stories Focused: Each story should represent a specific state or behavior. This makes tests more granular.
Use Meaningful Names: Story names become test descriptions. Use descriptive names like WithLongText, InErrorState, DisabledState.
Leverage Args: Define variations through args rather than creating multiple similar stories.
Mock Dependencies: Use decorators to mock context providers, navigation, or external dependencies.
Test Edge Cases: Create stories for edge cases (empty states, errors, loading) and test them.

Troubleshooting

Stories Not Found

Problem: composeStories returns an empty object Solution: Ensure you’re importing the full module:
// ✅ Correct
import * as stories from './Button.stories';

// ❌ Wrong
import { Primary } from './Button.stories';

Missing Decorators

Problem: Tests fail because global decorators aren’t applied Solution: Set up setProjectAnnotations:
import { setProjectAnnotations } from '@storybook/react';
import * as previewAnnotations from './.rnstorybook/preview';

setProjectAnnotations(previewAnnotations);

Transform Errors

Problem: Jest can’t transform @storybook/* packages Solution: Update transformIgnorePatterns in jest.config.js:
transformIgnorePatterns: [
  'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@storybook/.*)',
],

Addon Dependencies

Problem: Tests fail because addon decorators are missing Solution: Include addon preview exports in setProjectAnnotations:
import * as addonAnnotations from '@storybook/addon-themes/preview';
import * as previewAnnotations from './.rnstorybook/preview';

setProjectAnnotations([previewAnnotations, addonAnnotations]);

Additional Resources

Build docs developers (and LLMs) love