Skip to content

Instantly share code, notes, and snippets.

@aurbano
Last active October 14, 2024 15:10
Show Gist options
  • Save aurbano/abf0e65ee6ff008fe75b88914c2aed48 to your computer and use it in GitHub Desktop.
Save aurbano/abf0e65ee6ff008fe75b88914c2aed48 to your computer and use it in GitHub Desktop.
Storybook Snapshot Testing (HTML & visual)
import { configureToMatchImageSnapshot } from 'jest-image-snapshot';
import { close, start } from 'jsdom-screenshot-playwright';
import { afterAll, beforeAll, expect } from 'vitest';
import { render } from '@testing-library/react';
// Update this to match the path to your preview file
import * as previewAnnotations from '.storybook/preview';
// Customise jest-image-snapshot options
const toMatchImageSnapshot = configureToMatchImageSnapshot({
diffDirection: 'vertical',
// useful on CI (no need to retrieve the diff image, copy/paste image content from logs)
dumpDiffToConsole: false,
// use SSIM to limit false positive
// https://github.com/americanexpress/jest-image-snapshot#recommendations-when-using-ssim-comparison
comparisonMethod: 'ssim',
customDiffConfig: {
ssim: 'fast',
},
failureThreshold: 0.01,
failureThresholdType: 'percent',
});
expect.extend({
toMatchImageSnapshot,
});
if (!process.env.CI) {
// We only do the Playwright snapshots outside of CI
beforeAll(async () => {
// start jsdom-screenshot-playwright before all tests (to avoid starting it for each test and improve performance)
await start(
{
defaultSelector: 'body', // first div element in rendered html
device: 'Desktop Firefox HiDPI',
},
{
// playwright context options
viewport: {
width: 800,
height: 600,
},
},
);
});
afterAll(async () => {
// close jsdom-screenshot-playwright after all tests (close playwright instance)
await close();
});
}
function setupStorybookTestEnvironment() {
// Storybook configuration
setProjectAnnotations([previewAnnotations, { testingLibraryRender: render }]);
// Mock the ResizeObserver
const ResizeObserverMock = vi.fn(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Stub the global ResizeObserver
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
};
setupStorybookTestEnvironment();
export * from '@testing-library/react';
import { composeStories, type Meta, type StoryFn } from '@storybook/react';
import { generateImage } from 'jsdom-screenshot-playwright';
import path from 'path';
import { describe, expect, test } from 'vitest';
type StoryFile = {
default: Meta;
[name: string]: StoryFn | Meta;
};
describe('Storybook Tests', async () => {
const storyFiles = getAllStoryFiles();
for (const { storyFile: storyFileFn, componentName } of storyFiles) {
patchMatchMedia();
const storyFile = await storyFileFn();
const meta = storyFile.default;
const title = meta.title || componentName;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (meta.parameters?.storyshots?.disable) {
// Skip component tests if they are disabled
continue;
}
describe(title, async () => {
const stories = Object.entries(compose(storyFile)).map(([name, story]) => ({ name, story }));
if (stories.length <= 0) {
throw new Error(`
No stories found for this module: ${title}.
Make sure there is at least one valid story for this module,
without a disable parameter, or add parameters.storyshots.disable in the default export of this file.
`);
}
stories.forEach(({ name, story }) =>
test(name, async () => {
await story.run();
// await story.play?.(story);
// Ensures a consistent snapshot by waiting for the component to render by adding a delay
// before taking the snapshot.
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 1));
expect(document.body.firstChild).toMatchSnapshot();
if (!process.env.CI) {
// Only run image snapshot tests locally
expect(await generateImage()).toMatchImageSnapshot();
}
}),
);
});
}
});
const compose = (entry: StoryFile): ReturnType<typeof composeStories<StoryFile>> => {
try {
return composeStories(entry);
} catch (e) {
throw new Error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`There was an issue composing stories for the module: ${JSON.stringify(entry)}, ${e}`,
);
}
};
function getAllStoryFiles() {
const storyFiles = [
...Object.entries(
import.meta.glob<StoryFile>(`../components/app/**/*.(stories|story).@(js|jsx|mjs|ts|tsx)`),
),
...Object.entries(
import.meta.glob<StoryFile>(`../pages/**/*.(stories|story).@(js|jsx|mjs|ts|tsx)`),
),
];
return storyFiles.map(([filePath, storyFile]) => {
const storyDir = path.dirname(filePath);
const componentName = path.basename(filePath).replace(/\.(?:stories|story)\.[^/.]+$/, '');
return { filePath, storyFile, componentName, storyDir };
});
}
function patchMatchMedia() {
// eslint-disable-next-line functional/immutable-data
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn((query) => ({
matches: false,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
media: query,
onchange: null,
addListener: vi.fn(), // Deprecated
removeListener: vi.fn(), // Deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// eslint-disable-next-line functional/immutable-data
Element.prototype.scrollIntoView = vi.fn();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment