Testing with Storybook
Storybook provides testing capabilities for React Native components through portable stories and integration with testing frameworks like Jest and custom setups with Maestro.
Portable Stories
Portable stories allow you to reuse your Storybook stories in external testing environments like Jest. This enables better shareability and maintenance between writing tests and writing stories.
Setup
Install the required testing dependencies:
npm install --save-dev @testing-library/react-native @testing-library/jest-native jest
Jest Configuration
Configure Jest for React Native in your 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;
Setup File
Create a setup-jest.ts
file for test configuration:
// setup-jest.ts
import 'react-native-gesture-handler/jestSetup';
Using composeStories
The composeStories
utility processes all stories from a CSF file and returns them as testable components:
// 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 returned as a composed component
const { Primary, Secondary, Disabled } = composeStories(stories);
test('renders primary button with default args', () => {
render(<Primary />);
const buttonElement = screen.getByText('Click me');
expect(buttonElement).toBeTruthy();
});
test('renders primary button with overridden props', () => {
// Props override story args
render(<Primary title="Custom Title" />);
const buttonElement = screen.getByText('Custom Title');
expect(buttonElement).toBeTruthy();
});
test('renders disabled button correctly', () => {
render(<Disabled />);
const buttonElement = screen.getByRole('button');
expect(buttonElement).toBeDisabled();
});
Using composeStory
For testing individual stories, use composeStory
:
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native';
import { composeStory } from '@storybook/react';
import meta, { Primary } from './Button.stories';
test('button click handler is called', () => {
const PrimaryStory = composeStory(Primary, meta);
const onPressSpy = jest.fn();
render(<PrimaryStory onPress={onPressSpy} />);
const button = screen.getByText('Click me');
fireEvent.press(button);
expect(onPressSpy).toHaveBeenCalled();
});
Project Annotations Setup
For stories that use global decorators or parameters, set up project annotations:
// setup-portable-stories.ts
import { setProjectAnnotations } from '@storybook/react';
import * as previewAnnotations from '../.rnstorybook/preview';
setProjectAnnotations(previewAnnotations);
Add this to your Jest setup:
// jest.config.js
const config = {
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts', '<rootDir>/setup-portable-stories.ts'],
};
Real-World Examples
Testing Controls
// TextInput.test.tsx
import { render, screen } from '@testing-library/react-native';
import { composeStories } from '@storybook/react';
import * as TextInputStories from './TextInput.stories';
const { Basic } = composeStories(TextInputStories);
test('text input renders with default placeholder', () => {
render(<Basic />);
const input = screen.getByPlaceholderText('Enter text...');
expect(input).toBeTruthy();
});
test('text input can be customized via args', () => {
render(<Basic placeholder="Custom placeholder" value="Test value" />);
const input = screen.getByDisplayValue('Test value');
expect(input).toBeTruthy();
});
Visual Testing with Maestro
Right now there isn't a built-in way to do visual testing in Storybook for React Native, but you can use Maestro, Detox or other testing tools to automate testing of your Storybook stories.
Maestro is a mobile UI testing framework that can automate screenshot testing of your Storybook stories.
I also recommend setting onDeviceUI to false in your Storybook config to avoid issues with the Storybook UI interfering with screenshots.
Setup
Install Maestro CLI:
# macOS
brew tap mobile-dev-inc/tap
brew install maestro
# Other platforms - see https://maestro.mobile.dev/getting-started/installing-maestro
Maestro Configuration
Create a Maestro flow file for Storybook screenshot testing:
.maestro/storybook-screenshots.yaml
appId: host.exp.Exponent # Replace with your app bundle ID
name: Take screenshots of all Storybook stories
---
- stopApp: host.exp.Exponent
# Story screenshots
# put your app uri scheme here, e.g. myapp://storybook/?STORYBOOK_STORY_ID=button--primary
- openLink: 'exp://127.0.0.1:8081/--/?STORYBOOK_STORY_ID=button--primary'
- waitForAnimationToEnd
- assertVisible:
id: 'button--primary'
- takeScreenshot: '.maestro/screenshots/Button---Primary'
- openLink: 'exp://127.0.0.1:8081/--/?STORYBOOK_STORY_ID=button--secondary'
- waitForAnimationToEnd
- assertVisible:
id: 'button--secondary'
- takeScreenshot: '.maestro/screenshots/Button---Secondary'
# Add more stories...
Running Maestro Tests
Add scripts to your package.json
:
Note I highly recommend using Bun for running scripts, since you will experience less pain with esm/commonjs interop issues.
{
"scripts": {
"generate-maestro-tests": "bun ./scripts/generate-maestro-tests.mts",
"test:maestro": "maestro test .maestro/storybook-screenshots.yaml",
"test:compare": "bun scripts/compare-screenshots.ts"
}
}
Run the tests:
# Start your Expo/React Native app with Storybook
npm run start
# In another terminal, run Maestro tests
npm run test:maestro
Automated Screenshot Generation
You can automatically generate Maestro test files from your stories:
// scripts/generate-maestro-tests.ts
import { writeFileSync, mkdirSync } from 'fs';
import path from 'path';
const run = async () => {
const { buildIndex } = await import('storybook/internal/core-server');
const index = await buildIndex({
configDir: path.join(__dirname, '../.rnstorybook'),
});
// Ensure .maestro directory exists
const maestroDir = path.join(__dirname, '../.maestro');
mkdirSync(maestroDir, { recursive: true });
// Generate Maestro test file content
const stories = Object.values(index.entries)
.filter((entry: any) => entry.type === 'story')
.map((story: any) => ({
id: story.id,
name: story.title.replace(/\//g, '-') + ' - ' + story.name,
}));
const appId = 'host.exp.Exponent'; // Replace with your actual app ID if different
const baseUri = 'exp://127.0.0.1:8081/--/'; // Replace with your actual base URI if different
const maestroContent = `appId: ${appId}
name: Take screenshots of all Storybook stories
---
- stopApp: ${appId}
${stories
.map(
(story) => `# Story ${story.name}
- openLink: '${baseUri}?STORYBOOK_STORY_ID=${story.id}'
- waitForAnimationToEnd
- assertVisible:
id: '${story.id}'
- takeScreenshot: '.maestro/screenshots/${story.name.replace(/ /g, '-')}'
`
)
.join('\n')}`;
// Write the Maestro test file
const maestroTestPath = path.join(maestroDir, 'storybook-screenshots.yaml');
writeFileSync(maestroTestPath, maestroContent);
console.log('Generated Maestro test file at:', maestroTestPath);
};
run()
.then(() => {
console.log('Done');
process.exit(0);
})
.catch((err) => {
console.error(err);
process.exit(1);
});
Screenshot Comparison
Heres an example of how you can set up screenshot comparison to detect visual regressions:
// scripts/compare-screenshots.ts
import * as fs from 'fs';
import * as path from 'path';
import looksSame from 'looks-same';
import { fileURLToPath } from 'url';
// @ts-ignore
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const screenshotsDir = path.resolve(__dirname, '..', '.maestro', 'screenshots');
const baselineDir = path.resolve(__dirname, '..', '.maestro', 'baseline');
const diffsDir = path.resolve(__dirname, '..', '.maestro', 'diffs');
// Add debug logging
console.log('Paths:', {
screenshotsDir,
baselineDir,
diffsDir,
});
async function compareScreenshots() {
const screenshots = fs.readdirSync(screenshotsDir);
console.log('screenshots', screenshots);
for (const screenshot of screenshots) {
const currentPath = path.join(screenshotsDir, screenshot);
const baselinePath = path.join(baselineDir, screenshot);
const diffPath = path.join(diffsDir, `diff_${screenshot}`);
if (!fs.existsSync(baselinePath)) {
console.log(`Baseline not found for ${screenshot}`);
continue;
}
try {
const result = await looksSame(baselinePath, currentPath, {
strict: false,
tolerance: 2.5,
createDiffImage: true,
});
if (!result.equal) {
await result.diffImage?.save(diffPath);
}
console.log(`${screenshot}: ${result.equal ? 'Match' : 'Differs'}`);
} catch (error) {
console.error(`Error comparing ${screenshot}:`, error);
}
}
}
compareScreenshots().catch(console.error);
Best Practices
Portable Stories
- Keep stories focused - Each story should test a specific state or behavior
- Use meaningful story names - Names become test descriptions
- Leverage args - Use args for different test scenarios
- Test edge cases - Create stories for error states, loading, etc.
- Mock dependencies - Use decorators to mock external dependencies
Visual Testing
- Stable screenshots - Ensure animations complete before taking screenshots
- Consistent environment - Use the same device/simulator for baseline images
- Organize screenshots - Use clear naming conventions for screenshot files
- Version control baselines - Commit baseline screenshots to track changes
- Review changes - Always review visual differences before updating baselines
Test Organization
components/
├── Button/
│ ├── Button.tsx
│ ├── Button.stories.tsx
│ ├── Button.test.tsx # Unit tests with portable stories
│ └── Button.visual.test.ts # Visual regression tests
├── TextInput/
│ ├── TextInput.tsx
│ ├── TextInput.stories.tsx
│ └── TextInput.test.tsx
└── shared/
├── setup-jest.ts
└── setup-portable-stories.ts
Troubleshooting
Common Issues
Stories not found in tests:
- Ensure story file paths are correct
- Check that stories are properly exported
- Verify
composeStories
import matches story file structure
Missing decorators in tests:
- Set up
setProjectAnnotations
with preview decorators - Import addon decorators if stories depend on them
Maestro test failures:
- Check app ID matches your bundle identifier
- Ensure Storybook is running and accessible
- Verify story IDs match exactly (case-sensitive)
- Wait for animations to complete before assertions
Performance issues:
- Try using
jest --maxWorkers=1
jest tests if you encounter issues with parallel test execution - Mock heavy dependencies in test setup
- Consider using
test.concurrent
for independent tests