Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
フロントエンドテストの育て方
Search
Yosuke Kurami
March 26, 2025
Programming
3.8k
12
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
フロントエンドテストの育て方
Yosuke Kurami
March 26, 2025
More Decks by Yosuke Kurami
See All by Yosuke Kurami
TypeScript LSP の今までとこれから
quramy
1
2.1k
App Router 悲喜交々
quramy
8
740
上手に付き合うコンポーネントテスト
quramy
6
2.4k
Patched fetch did not work
quramy
6
790
GraphQL あるいは React における自律的なデータ取得について
quramy
18
5.9k
Next.js App Router
quramy
15
3.9k
Fragment Composition of GraphQL
quramy
17
4.8k
reg-viz VRT tools
quramy
4
1.7k
NoInfer
quramy
0
390
Other Decks in Programming
See All in Programming
JAWS-UG横浜 #102 AWSサ終供養LT会 成仏できない AWS サービスたち 〜本日、三体供養します〜
maroon1st
0
210
SREの積み重ねがAI駆動開発のガードレールになった ― 7つの実践/SRE Guardrails The 7
tomoyakitaura
8
4.4k
symfony/aiとlaravel/boost
77web
0
130
PHPだって関数型したい 〜できること、できないこと〜 / fp-in-php
jsoizo
0
210
関数型プログラミングのメリットって何だろう?
wanko_it
0
180
壊れたパーサから始める関数型設計と構成的なパーサ #fp_matsuri
raiga0310
2
230
PHP Application における Kubernetes 内 gRPC 通信
ganchiku
0
480
なぜ関数型プログラミングで「型」と「証明」が語られるのか #fp_matsuri
kajitack
3
950
ITヒヤリハットを整理してみた ~ライフサイクルと原因から考える再発防止策~
koukimiura
1
110
音楽のための関数型プログラミング言語mimiumにおける多段階計算の活用
tomoyanonymous
1
330
エンジニア向け会社紹介/Findy Company Profile
findyinc
6
360k
Welcome to the "Parametricity" 🏙️ − Generic だけど Specific な世界 −
guvalif
PRO
1
170
Featured
See All Featured
Put a Button on it: Removing Barriers to Going Fast.
kastner
60
4.4k
Exploring anti-patterns in Rails
aemeredith
3
440
Leading Effective Engineering Teams in the AI Era
addyosmani
9
2.2k
Paper Plane
katiecoart
PRO
2
52k
Claude Code のすすめ
schroneko
67
230k
Building Better People: How to give real-time feedback that sticks.
wjessup
370
20k
Measuring Dark Social's Impact On Conversion and Attribution
stephenakadiri
2
240
Optimising Largest Contentful Paint
csswizardry
37
3.8k
The Impact of AI in SEO - AI Overviews June 2024 Edition
aleyda
5
1.1k
DevOps and Value Stream Thinking: Enabling flow, efficiency and business value
helenjbeal
1
260
Introduction to Domain-Driven Design and Collaborative software design
baasie
1
900
Agile Leadership in an Agile Organization
kimpetersen
PRO
0
190
Transcript
ϑϩϯτΤϯυςετͷҭͯํ 2025.3.26 @Quramy
About me - id: @Quramy (GitHub, X) - Web ϑϩϯτΤϯυϝΠϯͷΤϯδχΞ
- ࣗಈςετ։ൃੜ࢈ੑܥͷٕज़͕͖ - e.g. reg-suit, Storycap, prisma-fabbrica, etc,,,
ࠓͷςʔϚ ࠓͷϝΠϯ - React Λར༻ͨ͠ Web ΞϓϦέʔγϣϯͷࣗಈςετ - ͍ΘΏΔ Component
ͷ୯ମςετΛ֦ॆ͢Δ্ͰؾΛ͚͍ͭͯΔ͜ͱ Jest testing-library Λ͏ࣗಈςετ෦ ࠓ͠ͳ͍ - Visual Testing, E2E
ීஈ͍ͬͯΔ͜ͱ ϦʔυΤϯδχΞͱ͍ͯͬͯ͠Δ͜ͱ - ϓϩδΣΫτൃ࣌ - બఆͨ͠ϑϨʔϜϫʔΫϥΠϒϥϦΛר͖ࠐΜͩঢ়ଶͰɺ Storybook / Jest ͕ύε͢Δ·Ͱ
Jest ͷηοτΞοϓ෦Λ࿔Γ·Θ͢ - Component / Storybook / Jest ͷ Scaffold Λ༻ҙ͢Δ - ϓϩδΣΫτ్தظ (PR ϨϏϡʔ࣌ͳͲ) - ΧόϨοδ Storybook Λ֬ೝͭͭ͠ɺςετ࡞Λଅ͢ - ςετίʔυͷಡΈʹ͘͞Λײͨ͡Βɺ ςετ༻ͷϢʔςΟϦςΟ࡞Λଅ͢
ීஈҙ͍ࣝͯ͠Δ͜ͱ ςετΛॻ্͍͍ͯ͘Ͱେࣄʹ͍ͯ͠Δ͜ͱ - ςετίʔυͷ͋Δ͖࢟Λࢥ͍ු͔͓ͯ͘ - ʮ͜͏͍͏ςετίʔυ͕͍͍ͳʯ͔Βٯࢉͯ͠ճΓΛ४උ͢Δ
ීஈҙ͍ࣝͯ͠Δ͜ͱ (Quramy ʹͱͬͯ) ςετίʔυͷ͋Δ͖࢟ͱʁ - ॻ͖͘͢ɺಡΈ͍͢ - 3ϲ݄ޙͷࣗͷଞਓͱࢥͬͨ΄͏͕͍͍ - έʔεʹରͯ͠ɺςετίʔυͷهड़͕ඞཁ͔ͭेͰ͋Δ͜ͱ
ͳΜ͔ͩநతͳ༰ʹͳ͖ͬͯͨͷͰɺ ͜͜ΒͰ έʔεελσΟίʔφʔ
έʔεελσΟ ࣗʹͱͬͯͷʮॻ͖͘͢ಡΈ͍͢ʯςετίʔυΛ۩ମతʹݟ͍ͯ ͨ͘ΊɺۙܞΘͬͨϓϩμΫτͷςετίʔυΛྫʹͯ͠Έ·͢ (Λϕʔεͱͨ͠αϯϓϧ. ྲྀੴʹ 100% ϗϯϞϊͰͳ͍) ରͷΞϓϦέʔγϣϯ: Chat GPT
ͷΑ͏ͳ LLM ͱର͢Δܥ౷ͷνϟοτΞϓϦ ߲࣍Ͱྫࣔ͢Δͷ Zustand ʹ֨ೲ͞ΕͨνϟοτϝοηʔδΛදࣔ͢Δ React Component ͷςετίʔυͰ͢
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Λඳը͠ɺϝοηʔδ͕ද ࣔ͞Ε͍ͯΔ͜ͱΛݕূ
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(); });
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 ͷ্ʹॻ͍ͯ͋ΔͨΊɺ Ͳ͜·Ͱ͕४උͰɺͲ͕࣮͜ࡍͷ֬ೝର͔Ұྎવ
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(); });
- 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> ); };
Story Decorator ׆༻ྫ - State Manager ܥϥΠϒϥϦͱͷ૬ੑ͕Α͍ - Zustand, Jotai,
etc... - ΩϟογϡΛ׆༻͢ΔྨͷϥΠϒϥϦͱΈ߹Θ͍ͤ͢ - Apollo Client, Relay ͳͲ e.g. Storybook Ͱ Apollo Client ͷ useFragment Λѻ͏
ಥͳ Q&A ίʔφʔ - Q. Storybook Λซ༻͢ΔͳΒ Play Function ʹશ෦دͤͳ͍ͷʁ
- A. Component ͷςετΛͯ͢ Story ʹدͤΔͱͬͺΓ CI ͕ ͍ɻjest-dom Ͱेͳػೳ Jest (·ͨ Vitest) Ͱࡁ·͍ͤͨ
ࠨͷίʔυΛʮཧʯͱͯ͠ɺͲ͏ͬͯͦ͜ʹ౸ୡ͢Δ ͷ͔ ͍͖ͳΓࠨͷίʔυ͕ੜ·ΕΔΘ͚Ͱͳ͍ 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(); });
ςετΛҭ͍ͯ͢ঢ়گΛ࡞Δ - νʔϜϝϯόʔʹʮςετॻ͘ͷ͕μϧ͍ʯͱࢥΘΕͨ͘ͳ͍ - ཧͱࢥ͏ Component ςετίʔυͷΪϟοϓΛগ͠ͰຒΊͯ ͓͖͍ͨ - ςετΛॻ͖͍͢ঢ়گΛ༻ҙ͢Δ
- Component ίʔυͷࣗಈੜ - ݟ͍ͤͨՕॴͱӅ͍ͨ͠Օॴ
㙽ʹ֯ʹςετ͕͋Δঢ়ଶΛ༻ҙ͢Δ - 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
ʮ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ͷ୯ମςετίʔυͷܗ
- 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(); });
ݟͤΔՕॴͱӅ͢Օॴ - ςετέʔεͱຊ࣭తʹؔ࿈ੑ͕͍༰ɺηοτΞοϓίʔυʹهड़ (= ςετϑΝΠϧςετέʔεΛಡΉͱ͖ͷϊΠζΛݮΒ͢) - ྫ - Next.js ͷ
Image Link Component Λಈ࡞ͤ͞ΔͨΊͷϞοΫ - IntersectionObserver ResizeObserver ͷΑ͏ͳɺjsdom ʹੜ͑ͯ ͍ͳ͍ global API Λར༻͢ΔͨΊͷϞοΫ - MSW ͷγϟοτμϯ
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Ͱॻ͍͍ͯΔ༰
͓ΘΓʹ
͓ΘΓʹ - ίϝϯτͷॻ͖ํࣗಈੜͳͲɺز͔ͭͷΛհ - 1ͭ1ͭͷςΫχοΫٕज़తʹ͍͠ͷͰͳ͍ - ؆୯ͳςΫχοΫͷΈ߹ΘͤΛ͏͜ͱͰɺ୯ମςετ͕ΑΓۙͳ ͷʹͳΔͱྑ͍ͱࢥ͏
͓·͚
(͓·͚) Story Loaders ׆༻ - Decorator ΄Ͳར༻͠ͳ͍͕ɺซ͓ͤͯͬͯ͘ͱศརͳ Storybook ͷ֦ுϙΠϯτʹ Loaders
͕͋Δ - https://storybook.js.org/docs/writing-stories/loaders - Loader React ͷੈքʹདྷΔલʹ Story ΛηοτΞοϓͰ͖Δ֦ுϙΠ ϯτ
(͓·͚) 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 ͷྫ (͜ΕΑ͘׆༂͢Δ)