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

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

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

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 ͷྫ (͜Ε΋Α͘׆༂͢Δ)