generate-snapshot-tests
Generate Snapshot Tests
Generate a *.snapshots.tsx file colocated with a Sentry React component, following the established pattern used by core design system components.
Step 1: Locate the Component
If $ARGUMENTS is provided, treat it as a path or component name. Otherwise ask the user which component to snapshot.
Search strategies:
static/app/components/core/<name>/<name>.tsx
static/app/components/core/<name>/index.tsx
static/app/components/<name>.tsx
static/app/components/<name>/index.tsx
Use Glob or Grep to find the file if the exact path is unknown.
Read the component source file to understand:
- The component's name and its exported
Props/<ComponentName>Propstype - Union types and enum-like string literals on props (e.g.,
variant,priority,size) - Boolean toggle props (e.g.,
disabled,checked,busy) - Whether the component is interactive (needs
onChange={() => {}}or similar no-op handlers)
Step 2: Determine the Import Path
| Condition | Import style |
|---|---|
Component lives under static/app/components/core/ AND is published as @sentry/scraps/<name> |
import {ComponentName, type ComponentNameProps} from '@sentry/scraps/<name>'; |
Component lives under static/app/components/core/ but is NOT in @sentry/scraps |
// eslint-disable-next-line @sentry/scraps/no-core-import -- SSR snapshot needs direct import to avoid barrel re-exports with heavy depsimport {ComponentName, type ComponentNameProps} from 'sentry/components/core/<path>'; |
| All other components | import {ComponentName, type ComponentNameProps} from 'sentry/components/<path>'; |
To check if a component is in @sentry/scraps, look for an existing import using @sentry/scraps/<name> in neighboring files, or check if other snapshot files in the same directory use @sentry/scraps.
Step 3: Identify Props to Snapshot
Read the TypeScript props and classify them:
| Prop type | Action |
|---|---|
Union of string literals ('sm' | 'md' | 'lg') |
Snapshot each value with it.snapshot.each |
Boolean toggle with visual impact (disabled, checked) |
Snapshot true and false states |
| Boolean flag with no visual test value | Skip or add a single named snapshot |
children / className / style / event handlers |
Skip — not visually interesting on their own |
Prioritize props that change the component's visual appearance substantially. For interactive components (inputs, toggles), always include disabled/checked states.
Step 4: Write the Snapshot File
Name the output file <component-name>.snapshots.tsx, colocated with the component source file.
Required imports (always include)
import {ThemeProvider} from '@emotion/react';
import {ComponentName, type ComponentNameProps} from '@sentry/scraps/<name>'; // or appropriate path
// eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access
import {darkTheme, lightTheme} from 'sentry/utils/theme/theme';
const themes = {light: lightTheme, dark: darkTheme};
Core structure
Always wrap in light/dark theme loop:
describe('ComponentName', () => {
describe.each(['light', 'dark'] as const)('%s', themeName => {
// ... snapshot cases here
});
});
it.snapshot.each — for union prop variants
Use when iterating over multiple values of a single prop:
it.snapshot.each<ComponentProps['variant']>(['info', 'warning', 'success', 'danger'])(
'%s',
variant => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Component variant={variant}>Label</Component>
</div>
</ThemeProvider>
),
variant => ({theme: themeName, variant: String(variant)})
);
The third argument to it.snapshot.each is the metadata function — include all props that vary in the snapshot. This metadata is used for snapshot naming and diffing.
it.snapshot — for single named snapshots
Use for one-off states (disabled, checked combinations, etc.):
it.snapshot('disabled-unchecked', () => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Component disabled onChange={() => {}} />
</div>
</ThemeProvider>
));
Pass metadata as a third argument when it adds useful snapshot context:
it.snapshot(
'bold',
() => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Component bold>Bold text</Component>
</div>
</ThemeProvider>
),
{theme: themeName}
);
Sizing the container
Match container sizing to what makes the component readable:
| Situation | Wrapper |
|---|---|
| Default | <div style={{padding: 8}}> |
| Width-sensitive (alerts, text) | <div style={{padding: 8, width: 400}}> |
| Narrow (icons, small controls) | <div style={{padding: 8}}> |
Interactive components
For components that require event handlers (inputs, checkboxes, radios, switches), pass no-op handlers to satisfy required props:
<Component onChange={() => {}} />
<Component checked onChange={() => {}} />
Step 5: Ordering snapshots within the theme loop
Order cases from most impactful to least:
- Primary variant/priority prop (the most visible visual differentiator)
- Secondary variant props
- Size variants
- State combinations (disabled+unchecked, disabled+checked)
- Boolean modifiers (bold, italic, etc.)
- Edge cases and combined props
Examples
Simple variant component (Button-style)
import {ThemeProvider} from '@emotion/react';
import {Button, type ButtonProps} from '@sentry/scraps/button';
// eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access
import {darkTheme, lightTheme} from 'sentry/utils/theme/theme';
const themes = {light: lightTheme, dark: darkTheme};
describe('Button', () => {
describe.each(['light', 'dark'] as const)('%s', themeName => {
it.snapshot.each<ButtonProps['priority']>([
'default',
'primary',
'danger',
'warning',
'link',
'transparent',
])(
'%s',
priority => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Button priority={priority}>{priority}</Button>
</div>
</ThemeProvider>
),
priority => ({theme: themeName, priority: String(priority)})
);
});
});
Interactive component with state combinations (Switch-style)
import {ThemeProvider} from '@emotion/react';
import {Switch, type SwitchProps} from '@sentry/scraps/switch';
// eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access
import {darkTheme, lightTheme} from 'sentry/utils/theme/theme';
const themes = {light: lightTheme, dark: darkTheme};
describe('Switch', () => {
describe.each(['light', 'dark'] as const)('theme-%s', themeName => {
it.snapshot.each<SwitchProps['size']>(['sm', 'lg'])('size-%s-unchecked', size => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Switch size={size} onChange={() => {}} />
</div>
</ThemeProvider>
));
it.snapshot.each<SwitchProps['size']>(['sm', 'lg'])('size-%s-checked', size => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Switch checked size={size} onChange={() => {}} />
</div>
</ThemeProvider>
));
it.snapshot('disabled-unchecked', () => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Switch disabled onChange={() => {}} />
</div>
</ThemeProvider>
));
it.snapshot('disabled-checked', () => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8}}>
<Switch checked disabled onChange={() => {}} />
</div>
</ThemeProvider>
));
});
});
Component with multiple independent variant props (Alert-style)
When a component has multiple meaningful boolean or variant props that combine independently, add separate it.snapshot.each blocks per combination:
describe('Alert', () => {
describe.each(['light', 'dark'] as const)('%s', themeName => {
// Primary variants
it.snapshot.each<AlertProps['variant']>([
'info',
'warning',
'success',
'danger',
'muted',
])(
'%s',
variant => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8, width: 400}}>
<Alert variant={variant}>This is a {variant} alert</Alert>
</div>
</ThemeProvider>
),
variant => ({theme: themeName, variant: String(variant)})
);
// Modifier combination: same variants but with showIcon={false}
it.snapshot.each<AlertProps['variant']>([
'info',
'warning',
'success',
'danger',
'muted',
])(
'%s-no-icon',
variant => (
<ThemeProvider theme={themes[themeName]}>
<div style={{padding: 8, width: 400}}>
<Alert variant={variant} showIcon={false}>
This is a {variant} alert without icon
</Alert>
</div>
</ThemeProvider>
),
variant => ({theme: themeName, variant: String(variant), showIcon: 'false'})
);
});
});
Anti-Patterns
// ❌ Don't import theme from the barrel re-export
import {theme} from 'sentry/utils/theme';
// ✅ Import directly and suppress the lint warning
// eslint-disable-next-line no-restricted-imports -- SSR snapshot rendering needs direct theme access
import {darkTheme, lightTheme} from 'sentry/utils/theme/theme';
// ❌ Don't omit the metadata argument — snapshot names become ambiguous
it.snapshot.each<Props['variant']>(['a', 'b'])('%s', variant => (
<Component variant={variant} />
));
// ✅ Include metadata that reflects all varying props
it.snapshot.each<Props['variant']>(['a', 'b'])(
'%s',
variant => <Component variant={variant} />,
variant => ({theme: themeName, variant: String(variant)})
);
// ❌ Don't snapshot implementation-detail props like className or style
it.snapshot('custom-class', () => <Component className="foo" />);
// ❌ Don't use @sentry/scraps barrel import for components not in the scraps package
import {Badge} from '@sentry/scraps/badge'; // if Badge isn't published there
// ✅ Use the direct path with the no-core-import suppression comment
// eslint-disable-next-line @sentry/scraps/no-core-import -- SSR snapshot needs direct import to avoid barrel re-exports with heavy deps
import {Badge} from 'sentry/components/core/badge/badge';
Checklist
Before finishing:
- File is named
<component-name>.snapshots.tsxand colocated with the component - Both
lightanddarkthemes are covered viadescribe.each - All primary variant/priority props are snapshotted
- Interactive components include disabled and checked/unchecked states
-
no-restricted-importsESLint suppression comment is present on the theme import - Metadata argument is provided to
it.snapshot.eachcalls - No-op handlers (
onChange={() => {}}) provided for required event props - Import path uses
@sentry/scraps/<name>if available, otherwise the directsentry/components/...path with theno-core-importsuppression