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
2k
App Router 悲喜交々
quramy
8
730
上手に付き合うコンポーネントテスト
quramy
6
2.3k
Patched fetch did not work
quramy
6
780
GraphQL あるいは React における自律的なデータ取得について
quramy
18
5.8k
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
380
Other Decks in Programming
See All in Programming
TAKTでAI駆動開発の品質を設計する
j5ik2o
6
1.2k
These Five Tricks Can Make Your Apps Greener, Cheaper, & Nicer
hollycummins
0
280
Signal Forms: Beyond the Basics @ngBaguette 2026 in Paris
manfredsteyer
PRO
0
240
Snowflake Summitでの新機能 CoCo / CoWork / snowflake-summit-2026-overall-what-new-coco
tatsuhiro
1
110
作って学ぶ、 JSX (TSX) ランタイムの基本
syumai
7
1.6k
気圧・高度・GPSを記録&可視化するアプリ「Koudo」を作った話
hjmkth
1
160
メソッドのジェネリクスでGoの夢は広がるか? / Kyoto.go #65
utgwkk
3
710
Hunting Vulnerabilities in Symfony with LLMs
vinceamstoutz
0
540
Developing with AI Agents — Codex, Claude Code & Cowork Practical Guide
x5gtrn
PRO
0
1.3k
Javaの型とAI時代に型が大事な理由 / java types and type in AI era
kishida
2
130
IBM Bobを活用したレガシーアプリの最新化
oniak3ibm
PRO
1
190
Semantic Version 単位で戦略を柔軟に変えて、パッケージアップデートを自動化する
daitasu
0
220
Featured
See All Featured
ピンチをチャンスに:未来をつくるプロダクトロードマップ #pmconf2020
aki_iinuma
128
56k
Chasing Engaging Ingredients in Design
codingconduct
0
220
VelocityConf: Rendering Performance Case Studies
addyosmani
333
25k
Optimising Largest Contentful Paint
csswizardry
37
3.7k
エンジニアに許された特別な時間の終わり
watany
107
250k
Bioeconomy Workshop: Dr. Julius Ecuru, Opportunities for a Bioeconomy in West Africa
akademiya2063
PRO
1
140
Why Our Code Smells
bkeepers
PRO
340
58k
[RailsConf 2023] Rails as a piece of cake
palkan
59
6.7k
The SEO Collaboration Effect
kristinabergwall1
1
480
Data-driven link building: lessons from a $708K investment (BrightonSEO talk)
szymonslowik
1
1.1k
Why Your Marketing Sucks and What You Can Do About It - Sophie Logan
marketingsoph
0
170
Heart Work Chapter 1 - Part 1
lfama
PRO
7
36k
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 ͷྫ (͜ΕΑ͘׆༂͢Δ)