Upgrade to Pro — share decks privately, control downloads, hide ads and more …

フロントエンドテストの育て方

Sponsored · Ship Features Fearlessly Turn features on and off without deploys. Used by thousands of Ruby developers.

 フロントエンドテストの育て方

Avatar for Yosuke Kurami

Yosuke Kurami

March 26, 2025
Tweet

More Decks by Yosuke Kurami

Other Decks in Programming

Transcript

  1. About me - id: @Quramy (GitHub, X) - Web ϑϩϯτΤϯυϝΠϯͷΤϯδχΞ

    - ࣗಈςετ΍։ൃੜ࢈ੑܥͷٕज़͕޷͖ - e.g. reg-suit, Storycap, prisma-fabbrica, etc,,,
  2. ࠓ೔ͷςʔϚ ࠓ೔ͷϝΠϯ - React Λར༻ͨ͠ Web ΞϓϦέʔγϣϯͷࣗಈςετ - ͍ΘΏΔ Component

    ͷ୯ମςετΛ֦ॆ͢Δ্ͰؾΛ͚͍ͭͯΔ͜ͱ Jest ΍ testing-library Λ࢖͏ࣗಈςετ෦෼ ࠓ೔͠ͳ͍࿩ - Visual Testing, E2E
  3. ීஈ΍͍ͬͯΔ͜ͱ ϦʔυΤϯδχΞͱͯ͠΍͍ͬͯΔ͜ͱ - ϓϩδΣΫτൃ଍࣌ - બఆͨ͠ϑϨʔϜϫʔΫ΍ϥΠϒϥϦΛר͖ࠐΜͩঢ়ଶͰɺ Storybook / Jest ͕ύε͢Δ·Ͱ

    Jest ͷηοτΞοϓ෦෼Λ࿔Γ·Θ͢ - Component / Storybook / Jest ͷ Scaffold Λ༻ҙ͢Δ - ϓϩδΣΫτ్தظ (PR ϨϏϡʔ࣌ͳͲ) - ΧόϨοδ΍ Storybook Λ֬ೝͭͭ͠ɺςετ࡞੒Λଅ͢ - ςετίʔυͷಡΈʹ͘͞Λײͨ͡Βɺ ςετ༻ͷϢʔςΟϦςΟ࡞੒Λଅ͢
  4. 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(<Component />); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); }); ϝοηʔδݸΛ֨ೲͨ͠4UPSFΛ༻ҙ $PNQPOFOUΛඳը͠ɺϝοηʔδ͕ද ࣔ͞Ε͍ͯΔ͜ͱΛݕূ
  5. 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(<Component />); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });
  6. 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(<Component />); // Act await userEvent.click( screen.getByRole('button', { name: 'ཤྺΛݟΔ' }), ); // Assert expect(fetchMessageHistory).toHaveBeenCalledTimes(1); }); ࠨ͸ಉ͡ Component ͷผέʔε Act ͕ userEvent.click ͷ্ʹॻ͍ͯ͋ΔͨΊɺ Ͳ͜·Ͱ͕४උͰɺͲ͕࣮͜ࡍͷ֬ೝର৅͔Ұ໨ྎવ
  7. 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(<Component />); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });
  8. - 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<State>; readonly actionOverride?: Partial<Actions>; }; } } export const ChatPageStoreDecorator: Decorator = (Story, { parameters }) => { const { chatPageStore } = parameters; const initialState = { ...chatPageStore?.initialState, }; return ( <ChatPageStoreProvider initialState={initialState} actionOverride={chatPageStore?.actionOverride ?? {}} > <Story /> </ChatPageStoreProvider> ); };
  9. Story Decorator ׆༻ྫ - State Manager ܥϥΠϒϥϦͱͷ૬ੑ͕Α͍ - Zustand, Jotai,

    etc... - ΩϟογϡΛ׆༻͢ΔྨͷϥΠϒϥϦͱ΋૊Έ߹Θͤ΍͍͢ - Apollo Client, Relay ͳͲ e.g. Storybook Ͱ Apollo Client ͷ useFragment Λѻ͏
  10. ౜ಥͳ Q&A ίʔφʔ - Q. Storybook Λซ༻͢ΔͳΒ Play Function ʹશ෦دͤͳ͍ͷʁ

    - A. Component ͷςετΛ͢΂ͯ Story ʹدͤΔͱ΍ͬͺΓ CI ͕஗ ͍ɻjest-dom Ͱे෼ͳػೳ͸ Jest (·ͨ͸ Vitest) Ͱࡁ·͍ͤͨ
  11. ࠨͷίʔυΛʮཧ૝ʯͱͯ͠ɺͲ͏΍ͬͯͦ͜ʹ౸ୡ͢Δ ͷ͔ ͍͖ͳΓࠨͷίʔυ͕ੜ·ΕΔΘ͚Ͱ͸ͳ͍ 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(<Component />); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });
  12. 㙽ʹ΋֯ʹ΋ςετ͕͋Δঢ়ଶΛ༻ҙ͢Δ - Hygen Ͱ Component ຊମ / .stories.tsx / test.tsx

    Λੜ੒ - $ npm run new:component Ͱ࣮ߦ͢ΔΠ ϝʔδ type Props = { readonly className?: string; }; export function <%= component_name %> ({ className }: Props) { return ( <div className={clsx(styles.module, className)}> Hi <br /> <%= component_name %> . </div> ); } $PNQPOFOUຊମͷ਽ܗ ࣗಈੜ੒ ࣗಈੜ੒͞ΕΔϑΝΠϧୡ src/ components/ <ComponentName>/ index.tsx index.stories.tsx index.test.tsx
  13. ʮComponent ͷରͱͳΔςετϑΝΠϧ͕ ͋Δঢ়ଶʯΛఏڙ - ʮStory ͕ඳըͰ͖͍ͯΕ͹௨Δʯέʔε ·Ͱ͸ࣗಈੜ੒ - ʮ·ͣ͸ Story

    Λॻ͘ʯͱ͍͏ߦҝʹ஫ྗ ͤ͞Δ const storyRenderers = composeStories(stories); describe(<%= component_name %>, () => { // Add test case // // test("[Write test title here]", () => { // const { container } = render(<Default />); // }); test("Rendering without error logs", () => { // Arrange const errorLogSpy = jest.spyOn(console, 'error'); const { Default } = storyRenderers; // Act render(<Default />); // Assert expect(errorLogSpy).not.toHaveBeenCalled(); }); }); const meta = { component: <%= component_name %>, args: {} } satisfies Meta<typeof <%= component_name %>>; export default meta; type Story = StoryObj<typeof meta>; export const Default = {} satisfies Story; $PNQPOFOUͷ4UPSZCPPLͷ਽ܗ $PNQPOFOUͷ୯ମςετίʔυͷ਽ܗ
  14. - 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(<Component />); // Assert expect(screen.getByText('Կ͔஻Ε')).toBeInTheDocument(); expect(screen.getByText('͜Μʹͪ͸')).toBeInTheDocument(); });
  15. ݟͤΔՕॴͱӅ͢Օॴ - ςετέʔεͱຊ࣭తʹؔ࿈ੑ͕௿͍಺༰͸ɺηοτΞοϓίʔυʹهड़ (= ςετϑΝΠϧ΍ςετέʔεΛಡΉͱ͖ͷϊΠζΛݮΒ͢) - ྫ - Next.js ͷ

    Image ΍ Link Component Λಈ࡞ͤ͞ΔͨΊͷϞοΫ - IntersectionObserver ΍ ResizeObserver ͷΑ͏ͳɺjsdom ʹੜ͑ͯ ͍ͳ͍ global API Λར༻͢ΔͨΊͷϞοΫ - MSW ͷγϟοτμ΢ϯ
  16. 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) => <img {...props} />, })); // 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Ͱॻ͍͍ͯΔ಺༰
  17. (͓·͚) Story Loaders ׆༻ - Decorator ΄Ͳ͸ར༻͠ͳ͍͕ɺซͤͯ஌͓ͬͯ͘ͱศརͳ Storybook ͷ֦ுϙΠϯτʹ Loaders

    ͕͋Δ - https://storybook.js.org/docs/writing-stories/loaders - Loader ͸ React ͷੈքʹདྷΔલʹ Story ΛηοτΞοϓͰ͖Δ֦ுϙΠ ϯτ
  18. (͓·͚) 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<typeof setupWorker>; 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 ͷྫ (͜Ε΋Α͘׆༂͢Δ)