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
750
上手に付き合うコンポーネントテスト
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
390
Other Decks in Programming
See All in Programming
Claude Team Plan導入・ガイド
tk3fftk
0
240
AI Engineeringは、AIプロダクトだけのものか? 〜AIがソフトウェアを作る時代の新しい当たり前〜 / No AI in your product. AI Engineering in your development.
rkaga
4
310
これって Effect でできたのでは? / TSKaigi Mashup Kansai #2
susisu
0
130
継続モナドとリアクティブプログラミング
yukikurage
3
670
PHPだって関数型したい 〜できること、できないこと〜 / fp-in-php
jsoizo
1
260
琵琶湖の水は止められてもNet--HTTPのリトライは止められない / You might be able to stop the water flow of Lake Biwa but you can't stop Net::HTTP retries
luccafort
PRO
0
490
変わらないものが、変わるものを決める — 意図駆動開発 × イベントソーシング × イミュータブル | What Doesn't Change Decides What Can — IDD × Event Sourcing × Immutability
tomohisa
0
830
AI時代のPHPer生存戦略 ~「言語、もうなんでもよくない?」に本気で向き合う~
vivion
0
210
AI時代、エンジニアはどう育つのか -未経験エンジニアの成長を間近で見て考えたこと-
thasu0123
0
200
ルールを書いて終わらせないハーネスエンジニアリング
yug1224
4
1.8k
複数の Claude Code が"放置"されてしまう問題をCLI ダッシュボードを自作して解決した話
sumihiro3
0
580
『コードを書く以外の』エンジニアリング〜課金基盤移行プロジェクト推進のためのTips4選
yuriko1211
0
560
Featured
See All Featured
The innovator’s Mindset - Leading Through an Era of Exponential Change - McGill University 2025
jdejongh
PRO
1
230
エンジニアに許された特別な時間の終わり
watany
108
250k
The #1 spot is gone: here's how to win anyway
tamaranovitovic
3
1.1k
Leo the Paperboy
mayatellez
8
2k
Speed Design
sergeychernyshev
33
2k
Crafting Experiences
bethany
1
230
How GitHub (no longer) Works
holman
316
150k
How to Get Subject Matter Experts Bought In and Actively Contributing to SEO & PR Initiatives.
livdayseo
0
160
30 Presentation Tips
portentint
PRO
1
360
Scaling GitHub
holman
464
140k
State of Search Keynote: SEO is Dead Long Live SEO
ryanjones
0
240
The Director’s Chair: Orchestrating AI for Truly Effective Learning
tmiket
1
230
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 ͷྫ (͜ΕΑ͘׆༂͢Δ)