Slide 1

Slide 1 text

ϑϩϯτΤϯυςετͷҭͯํ 2025.3.26 @Quramy

Slide 2

Slide 2 text

About me - id: @Quramy (GitHub, X) - Web ϑϩϯτΤϯυϝΠϯͷΤϯδχΞ - ࣗಈςετ΍։ൃੜ࢈ੑܥͷٕज़͕޷͖ - e.g. reg-suit, Storycap, prisma-fabbrica, etc,,,

Slide 3

Slide 3 text

ࠓ೔ͷςʔϚ ࠓ೔ͷϝΠϯ - React Λར༻ͨ͠ Web ΞϓϦέʔγϣϯͷࣗಈςετ - ͍ΘΏΔ Component ͷ୯ମςετΛ֦ॆ͢Δ্ͰؾΛ͚͍ͭͯΔ͜ͱ Jest ΍ testing-library Λ࢖͏ࣗಈςετ෦෼ ࠓ೔͠ͳ͍࿩ - Visual Testing, E2E

Slide 4

Slide 4 text

ීஈ΍͍ͬͯΔ͜ͱ ϦʔυΤϯδχΞͱͯ͠΍͍ͬͯΔ͜ͱ - ϓϩδΣΫτൃ଍࣌ - બఆͨ͠ϑϨʔϜϫʔΫ΍ϥΠϒϥϦΛר͖ࠐΜͩঢ়ଶͰɺ Storybook / Jest ͕ύε͢Δ·Ͱ Jest ͷηοτΞοϓ෦෼Λ࿔Γ·Θ͢ - Component / Storybook / Jest ͷ Scaffold Λ༻ҙ͢Δ - ϓϩδΣΫτ్தظ (PR ϨϏϡʔ࣌ͳͲ) - ΧόϨοδ΍ Storybook Λ֬ೝͭͭ͠ɺςετ࡞੒Λଅ͢ - ςετίʔυͷಡΈʹ͘͞Λײͨ͡Βɺ ςετ༻ͷϢʔςΟϦςΟ࡞੒Λଅ͢

Slide 5

Slide 5 text

ීஈҙ͍ࣝͯ͠Δ͜ͱ ςετΛॻ্͍͍ͯ͘Ͱେࣄʹ͍ͯ͠Δ͜ͱ - ςετίʔυͷ͋Δ΂͖࢟Λࢥ͍ු͔΂͓ͯ͘ - ʮ͜͏͍͏ςετίʔυ͕͍͍ͳʯ͔Βٯࢉͯ͠଍ճΓΛ४උ͢Δ

Slide 6

Slide 6 text

ීஈҙ͍ࣝͯ͠Δ͜ͱ (Quramy ʹͱͬͯ) ςετίʔυͷ͋Δ΂͖࢟ͱ͸ʁ - ॻ͖΍͘͢ɺಡΈ΍͍͢ - 3ϲ݄ޙͷࣗ෼͸੺ͷଞਓͱࢥͬͨ΄͏͕͍͍ - έʔεʹରͯ͠ɺςετίʔυͷهड़͕ඞཁ͔ͭे෼Ͱ͋Δ͜ͱ

Slide 7

Slide 7 text

ͳΜ͔ͩந৅తͳ಺༰ʹͳ͖ͬͯͨͷͰɺ ͜͜ΒͰ έʔεελσΟίʔφʔ

Slide 8

Slide 8 text

έʔεελσΟ ࣗ෼ʹͱͬͯͷʮॻ͖΍͘͢ಡΈ΍͍͢ʯςετίʔυΛ۩ମతʹݟ͍ͯ ͨ͘Ίɺ௚ۙܞΘͬͨϓϩμΫτͷςετίʔυΛྫʹͯ͠Έ·͢ (Λϕʔεͱͨ͠αϯϓϧ. ྲྀੴʹ 100% ϗϯϞϊͰ͸ͳ͍) ର৅ͷΞϓϦέʔγϣϯ: Chat GPT ͷΑ͏ͳ LLM ͱର࿩͢Δܥ౷ͷνϟοτΞϓϦ ߲࣍Ͱྫࣔ͢Δͷ͸ Zustand ʹ֨ೲ͞ΕͨνϟοτϝοηʔδΛදࣔ͢Δ React Component ͷςετίʔυͰ͢

Slide 9

Slide 9 text

test('νϟοτͷ΍ΓͱΓ͕ඳը͞ΕΔ', async () => { // Arrange const Component = composeStory( { parameters: { chatPageStore: { initialState: { messages: [ { id: 'msg01', role: 'user', content: { body: 'Կ͔஻Ε', }, }, { id: 'msg02', role: 'application', content: { body: '͜Μͪʹ͸', }, }, ], }, actionOverride: { bootstrap: jest.fn(), }, }, }, }, stories.default, ); // Act await render(); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); }); ϝοηʔδݸΛ֨ೲͨ͠4UPSFΛ༻ҙ $PNQPOFOUΛඳը͠ɺϝοηʔδ͕ද ࣔ͞Ε͍ͯΔ͜ͱΛݕূ

Slide 10

Slide 10 text

AAA (Arrange, Act, Assert) ίϝϯτͰಡΈ΍͘͢ - https://xp123.com/3a-arrange-act-assert/ - Arrange: ४උ / Act: ࣮ߦ / Assert: ݕূ test('νϟοτͷ΍ΓͱΓ͕ඳը͞ΕΔ', async () => { // Arrange const Component = composeStory( { parameters: { chatPageStore: { initialState: { messages: [ { id: 'msg01', role: 'user', content: { body: 'Կ͔஻Ε', }, }, { id: 'msg02', role: 'application', content: { body: '͜Μͪʹ͸', }, }, ], }, actionOverride: { bootstrap: jest.fn(), }, }, }, }, stories.default, ); // Act await render(); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });

Slide 11

Slide 11 text

test('ཤྺϘλϯλοϓͰ fetchMessageHistory action ͕ݺ͹ΕΔ', async () => { // Arrange const fetchMessageHistory = jest.fn(); const Component = composeStory( { parameters: { chatPageStore: { initialState: { oldestMessageFetched: false, chatProcedureState: 'idle', }, actionOverride: { fetchMessageHistory, bootstrap: jest.fn(), }, }, }, }, stories.default, ); render(); // Act await userEvent.click( screen.getByRole('button', { name: 'ཤྺΛݟΔ' }), ); // Assert expect(fetchMessageHistory).toHaveBeenCalledTimes(1); }); ࠨ͸ಉ͡ Component ͷผέʔε Act ͕ userEvent.click ͷ্ʹॻ͍ͯ͋ΔͨΊɺ Ͳ͜·Ͱ͕४උͰɺͲ͕࣮͜ࡍͷ֬ೝର৅͔Ұ໨ྎવ

Slide 12

Slide 12 text

Storybook ( composeStory ؔ਺ ) - https://storybook.js.org/docs/api/portable-stories/portable-stories-jest - ʮStorybook Λ༻ҙͰ͖Ε͹୯ମςετ΋༻ҙͰ͖Δʯͱ͢Δ ͜ͱͰ Arrange ͷखؒΛݮΒ͢ - Story Λ༻ҙ͢Δํ๏ / Jest Λ༻ҙ͢Δํ๏ ͕ڞ௨Խ͞ΕΔ͜ ͱͰɺςετίʔυ͕ॻ͖΍͘͢ͳΔ - (IMO) "Portable Stories" ͱݺ͹ΕΔػೳͰ͸͋Δ΋ͷͷɺಛఆ ͷ Story ͦͷ΋ͷΛ୯ମςετଆ͔Β࠶ར༻͠ͳ͍Α͏ʹͯ͠ ͍·͢ test('νϟοτͷ΍ΓͱΓ͕ඳը͞ΕΔ', async () => { // Arrange const Component = composeStory( { parameters: { chatPageStore: { initialState: { messages: [ { id: 'msg01', role: 'user', content: { body: 'Կ͔஻Ε', }, }, { id: 'msg02', role: 'application', content: { body: '͜Μͪʹ͸', }, }, ], }, actionOverride: { bootstrap: jest.fn(), }, }, }, }, stories.default, ); // Act await render(); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });

Slide 13

Slide 13 text

- Story ΛૢΕΔΑ͏ʹϢʔςΟϦςΟΛ੔͑Δ - Storybook Decorator ͸ఆ൪֦ுϙΠϯτ - Context Provider Ͱ Story Λϥοϓ - Decorator ͷதͰ Story parameter ͔Β Context ஋Λ ࡞ͬͯΠϯδΣΫτ - ϙΠϯτ: ಛఆͷ state Λૂ্ͬͯॻ͖Ͱ͖ΔΑ͏ςε τ࣌͸ Partial ܕʹ͓ͯ͘͠ import { type Decorator } from '@storybook/react'; import { ChatPageStoreProvider } from '../provider'; import type { State, Actions } from '../store'; declare module '@storybook/csf' { interface Parameters { readonly chatPageStore?: { readonly initialState?: Partial; readonly actionOverride?: Partial; }; } } export const ChatPageStoreDecorator: Decorator = (Story, { parameters }) => { const { chatPageStore } = parameters; const initialState = { ...chatPageStore?.initialState, }; return ( ); };

Slide 14

Slide 14 text

Story Decorator ׆༻ྫ - State Manager ܥϥΠϒϥϦͱͷ૬ੑ͕Α͍ - Zustand, Jotai, etc... - ΩϟογϡΛ׆༻͢ΔྨͷϥΠϒϥϦͱ΋૊Έ߹Θͤ΍͍͢ - Apollo Client, Relay ͳͲ e.g. Storybook Ͱ Apollo Client ͷ useFragment Λѻ͏

Slide 15

Slide 15 text

౜ಥͳ Q&A ίʔφʔ - Q. Storybook Λซ༻͢ΔͳΒ Play Function ʹશ෦دͤͳ͍ͷʁ - A. Component ͷςετΛ͢΂ͯ Story ʹدͤΔͱ΍ͬͺΓ CI ͕஗ ͍ɻjest-dom Ͱे෼ͳػೳ͸ Jest (·ͨ͸ Vitest) Ͱࡁ·͍ͤͨ

Slide 16

Slide 16 text

ࠨͷίʔυΛʮཧ૝ʯͱͯ͠ɺͲ͏΍ͬͯͦ͜ʹ౸ୡ͢Δ ͷ͔ ͍͖ͳΓࠨͷίʔυ͕ੜ·ΕΔΘ͚Ͱ͸ͳ͍ test('νϟοτͷ΍ΓͱΓ͕ඳը͞ΕΔ', async () => { // Arrange const Component = composeStory( { parameters: { chatPageStore: { initialState: { messages: [ { id: 'msg01', role: 'user', content: { body: 'Կ͔஻Ε', }, }, { id: 'msg02', role: 'application', content: { body: '͜Μͪʹ͸', }, }, ], }, actionOverride: { bootstrap: jest.fn(), }, }, }, }, stories.default, ); // Act await render(); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });

Slide 17

Slide 17 text

ςετΛҭͯ΍͍͢ঢ়گΛ࡞Δ - νʔϜϝϯόʔʹʮςετॻ͘ͷ͕μϧ͍ʯͱࢥΘΕͨ͘ͳ͍ - ཧ૝ͱࢥ͏ Component ςετίʔυ΁ͷΪϟοϓΛগ͠Ͱ΋ຒΊͯ ͓͖͍ͨ - ςετΛॻ͖΍͍͢ঢ়گΛ༻ҙ͢Δ - Component ίʔυͷࣗಈੜ੒ - ݟ͍ͤͨՕॴͱӅ͍ͨ͠Օॴ

Slide 18

Slide 18 text

㙽ʹ΋֯ʹ΋ςετ͕͋Δঢ়ଶΛ༻ҙ͢Δ - Hygen Ͱ Component ຊମ / .stories.tsx / test.tsx Λੜ੒ - $ npm run new:component Ͱ࣮ߦ͢ΔΠ ϝʔδ type Props = { readonly className?: string; }; export function <%= component_name %> ({ className }: Props) { return (
Hi
<%= component_name %> .
); } $PNQPOFOUຊମͷ਽ܗ ࣗಈੜ੒ ࣗಈੜ੒͞ΕΔϑΝΠϧୡ src/ components/ / index.tsx index.stories.tsx index.test.tsx

Slide 19

Slide 19 text

ʮComponent ͷରͱͳΔςετϑΝΠϧ͕ ͋Δঢ়ଶʯΛఏڙ - ʮStory ͕ඳըͰ͖͍ͯΕ͹௨Δʯέʔε ·Ͱ͸ࣗಈੜ੒ - ʮ·ͣ͸ Story Λॻ͘ʯͱ͍͏ߦҝʹ஫ྗ ͤ͞Δ const storyRenderers = composeStories(stories); describe(<%= component_name %>, () => { // Add test case // // test("[Write test title here]", () => { // const { container } = render(); // }); test("Rendering without error logs", () => { // Arrange const errorLogSpy = jest.spyOn(console, 'error'); const { Default } = storyRenderers; // Act render(); // Assert expect(errorLogSpy).not.toHaveBeenCalled(); }); }); const meta = { component: <%= component_name %>, args: {} } satisfies Meta>; export default meta; type Story = StoryObj; export const Default = {} satisfies Story; $PNQPOFOUͷ4UPSZCPPLͷ਽ܗ $PNQPOFOUͷ୯ମςετίʔυͷ਽ܗ

Slide 20

Slide 20 text

- Story ͕͏·͘ॻ͚ͳ͍ͷͰ͋Ε͹ɺ Decorator ͳͲͷ Utils Λ௥Ճ͍ͯ͘͠ - ٯʹɺStory Λ༻ҙͰ͖Δঢ়ଶʹͳΕ͹ɺ Jest ଆͰ΋ composeStory Ͱ Arrange ෦ ෼Λॻ͚ΔΑ͏ʹͳ͍ͬͯΔ͸ͣ test('νϟοτͷ΍ΓͱΓ͕ඳը͞ΕΔ', async () => { // Arrange const Component = composeStory( { parameters: { chatPageStore: { initialState: { messages: [ { id: 'msg01', role: 'user', content: { body: 'Կ͔஻Ε', }, }, { id: 'msg02', role: 'application', content: { body: '͜Μͪʹ͸', }, }, ], }, actionOverride: { bootstrap: jest.fn(), }, }, }, }, stories.default, ); // Act await render(); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });

Slide 21

Slide 21 text

ݟͤΔՕॴͱӅ͢Օॴ - ςετέʔεͱຊ࣭తʹؔ࿈ੑ͕௿͍಺༰͸ɺηοτΞοϓίʔυʹهड़ (= ςετϑΝΠϧ΍ςετέʔεΛಡΉͱ͖ͷϊΠζΛݮΒ͢) - ྫ - Next.js ͷ Image ΍ Link Component Λಈ࡞ͤ͞ΔͨΊͷϞοΫ - IntersectionObserver ΍ ResizeObserver ͷΑ͏ͳɺjsdom ʹੜ͑ͯ ͍ͳ͍ global API Λར༻͢ΔͨΊͷϞοΫ - MSW ͷγϟοτμ΢ϯ

Slide 22

Slide 22 text

import '@testing-library/jest-dom'; import { setProjectAnnotations } from '@storybook/nextjs'; import { createRouter } from '@storybook/nextjs/router.mock'; import mockRouter from 'next-router-mock'; import previewAnnotations from '../../../.storybook/preview'; import { getServer } from '../msw/node'; // https://github.com/scottrippey/next-router-mock?tab=readme-ov- file#jest-configuration jest.mock('next/router', () => require('next-router-mock')); // https://zenn.dev/mitate_gengaku/articles/jest-with-react-markdown-and- remark-gfm jest.mock('react-markdown', () => { return { __esModule: true, default: (props: { readonly children: unknown }) => { return props.children; }, }; }); jest.mock('remark-gfm', () => { return { __esModule: true, default: () => void 0, }; }); // https://github.com/vercel/next.js/discussions/ 32325#discussioncomment-3164774 jest.mock('next/image', () => ({ __esModule: true, // eslint-disable-next-line default: (props: any) => , })); // NOTE: // jest-js-dom env does not have some functions. Here're mock implmentation of them. global.ResizeObserver = jest.fn().mockImplementation( () => ({ observe: jest.fn(), disconnect: jest.fn(), unobserve: jest.fn(), }) satisfies ResizeObserver, ); global.scrollTo = jest.fn(() => null); // Note: // ref: https://storybook.js.org/docs/api/portable-stories/portable- stories-jest#setprojectannotations const annotations = setProjectAnnotations([previewAnnotations]); beforeAll(() => { getServer().listen(); annotations.beforeAll(); }); beforeEach(() => { getServer().resetHandlers(); // Init on each case because Next.js Router mock is implemented as singleton mockRouter.push('/'); createRouter({}); }); afterEach(() => { jest.clearAllMocks(); }); afterAll(() => { getServer().close(); }); +FTUͷTFUVQ'JMFT"GUFS&OWͰॻ͍͍ͯΔ಺༰

Slide 23

Slide 23 text

͓ΘΓʹ

Slide 24

Slide 24 text

͓ΘΓʹ - ίϝϯτͷॻ͖ํ΍ࣗಈੜ੒ͳͲɺز͔ͭͷ޻෉఺Λ঺հ - 1ͭ1ͭͷςΫχοΫ͸ٕज़తʹ೉͍͠΋ͷͰ͸ͳ͍ - ؆୯ͳςΫχοΫͷ૊Έ߹ΘͤΛ࢖͏͜ͱͰɺ୯ମςετ͕ΑΓ਎ۙͳ ΋ͷʹͳΔͱྑ͍ͱࢥ͏

Slide 25

Slide 25 text

͓·͚

Slide 26

Slide 26 text

(͓·͚) Story Loaders ׆༻ - Decorator ΄Ͳ͸ར༻͠ͳ͍͕ɺซͤͯ஌͓ͬͯ͘ͱศརͳ Storybook ͷ֦ுϙΠϯτʹ Loaders ͕͋Δ - https://storybook.js.org/docs/writing-stories/loaders - Loader ͸ React ͷੈքʹདྷΔલʹ Story ΛηοτΞοϓͰ͖Δ֦ுϙΠ ϯτ

Slide 27

Slide 27 text

(͓·͚) Story Loaders ׆༻ import type { Loader } from '@storybook/react'; import type { RequestHandler } from 'msw'; import type { setupWorker } from 'msw/browser'; declare module '@storybook/csf' { interface Parameters { readonly mswHandlers?: readonly RequestHandler[]; } } type Worker = ReturnType; export function createMswHandlerLoader( worker: Worker | undefined | null, globalHandlers: readonly RequestHandler[] = [], ) { const loader: Loader = ({ parameters }) => { if (!worker) return; const storyHandlers = parameters.mswHandlers ?? []; worker.resetHandlers(...globalHandlers); worker.use(...storyHandlers); }; return loader; } const worker = !isInJest() ? createWorker() : null; const activateStatus = worker ? worker.start() : Promise.resolve(); const preview: Preview = { loaders: [ createMswHandlerLoader(worker), () => activateStatus, ], parameters: { /* தུ */ }, }; export default preview; - MSW ͷϋϯυϥΛ parameters ͔ΒࢦఆͰ͖ΔΑ͏ʹ͢Δ Loader ͷྫ (͜Ε΋Α͘׆༂͢Δ)