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
変わらないものが、変わるものを決める — 意図駆動開発 × イベントソーシング × イミュータブル | What Doesn't Change Decides What Can — IDD × Event Sourcing × Immutability
tomohisa
0
1.8k
これって Effect でできたのでは? / TSKaigi Mashup Kansai #2
susisu
0
250
TSX の <Hoge<Fuga>> という構文に驚いた話 / tsx-type-argument-syntax
kanaru0928
0
240
言葉の格闘技のススメ~紙とペンと言葉から始める、キャリアの描き方~
progresscicada
2
160
不幸な GC
chencmd
0
180
Claude CodeとAgentCore Gatewayを繋ぐ際の認証認可 / Authentication and authorization when connecting Claude Code with AgentCore Gateway
har1101
2
340
夏だ!祭りだ!祭りとはドメインモデリングでは?
ryugen04
0
340
Discordを用いたラボオートメーション関連情報収集の自動化
noguhiro2002
0
150
メールのエイリアス機能を履き違えない
isshinfunada
0
250
Go 1.27 における memory allocation の高速化
andpad
0
270
生成AIで帳票OCRが「簡単に」作れる時代になった?
kon_shou
0
1.1k
freee が目指す データ マネジメント戦略 AI-Ready 時代を支える 攻めのガバナンスとは
freee
PRO
0
440
Featured
See All Featured
Heart Work Chapter 1 - Part 1
lfama
PRO
8
36k
My Coaching Mixtape
mlcsv
0
250
Raft: Consensus for Rubyists
vanstee
141
7.6k
The Organizational Zoo: Understanding Human Behavior Agility Through Metaphoric Constructive Conversations (based on the works of Arthur Shelley, Ph.D)
kimpetersen
PRO
0
420
Intergalactic Javascript Robots from Outer Space
tanoku
273
27k
Building Adaptive Systems
keathley
44
3.2k
State of Search Keynote: SEO is Dead Long Live SEO
ryanjones
0
250
Why Your Marketing Sucks and What You Can Do About It - Sophie Logan
marketingsoph
0
390
[RailsConf 2023] Rails as a piece of cake
palkan
59
6.9k
Practical Tips for Bootstrapping Information Extraction Pipelines
honnibal
25
2k
sira's awesome portfolio website redesign presentation
elsirapls
0
330
Fireside Chat
paigeccino
42
4k
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 ͷྫ (͜ΕΑ͘׆༂͢Δ)