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.9k
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
760
上手に付き合うコンポーネントテスト
quramy
6
2.4k
Patched fetch did not work
quramy
6
800
GraphQL あるいは React における自律的なデータ取得について
quramy
18
5.9k
Next.js App Router
quramy
15
3.9k
Fragment Composition of GraphQL
quramy
17
4.9k
reg-viz VRT tools
quramy
4
1.7k
NoInfer
quramy
0
400
Other Decks in Programming
See All in Programming
人間の目はかわらない、だからJPEGは30年もつ
yuzneri
12
19k
Flow は今どうなっているか
mizdra
PRO
0
660
Cloudflare is Agents
chimame
0
170
AI Readyの正体はデータマネジメントだ メダリオン2.0の最前線
freee
PRO
0
330
AI時代に設計が 最大の生産性レバーになる 意図駆動開発とデータを消さない設計|Don't Delete Your Data or Your Intent — Design as the Deepest Lever in the AI Era
tomohisa
1
1.1k
30年振りにコンパイラの定数整数除算を改善した
herumi
2
490
変わらないものが、変わるものを決める — 意図駆動開発 × イベントソーシング × イミュータブル | What Doesn't Change Decides What Can — IDD × Event Sourcing × Immutability
tomohisa
0
1.8k
What's New in Android 2026
veronikapj
0
270
自動化したのに回らない テスト運用の壁―AI時代の品質責任と生産性
mfunaki
0
360
為什麼你並不需要ViewModel / No, you don't need a ViewModel
lovee
1
500
リアルな遅延を測る仕様
kota_yata
1
120
書籍「プロフェッショナルAI駆動開発」紹介スライド
juntaromatsumoto
0
220
Featured
See All Featured
Primal Persuasion: How to Engage the Brain for Learning That Lasts
tmiket
0
430
Max Prin - Stacking Signals: How International SEO Comes Together (And Falls Apart)
techseoconnect
PRO
0
420
brightonSEO & MeasureFest 2025 - Christian Goodrich - Winning strategies for Black Friday CRO & PPC
cargoodrich
3
780
How GitHub (no longer) Works
holman
316
150k
Producing Creativity
orderedlist
PRO
348
40k
Building Flexible Design Systems
yeseniaperezcruz
330
40k
Making Projects Easy
brettharned
120
6.7k
How to Talk to Developers About Accessibility
jct
2
520
How to audit for AI Accessibility on your Front & Back End
davetheseo
0
500
Connecting the Dots Between Site Speed, User Experience & Your Business [WebExpo 2025]
tammyeverts
11
1k
Stewardship and Sustainability of Urban and Community Forests
pwiseman
0
480
For a Future-Friendly Web
brad_frost
183
10k
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 ͷྫ (͜ΕΑ͘׆༂͢Δ)