これは、なにをしたくて書いたもの?
こちらの続きです。
Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Describing the UI) - CLOVER🍀
Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Keeping Components Pure) - CLOVER🍀
Learn ReactをVitest、React Testing Libraryを使ってやってみる(Understanding Your UI as a Tree) - CLOVER🍀
今回は「Learn React」の「Adding Interactivity」から「Responding to Events」をやってみます。
基本的には写経のシリーズです。
Learn Reactをやってみる
Learn Reactをただ写経するのではなく、以下の条件で進めていきます。再掲です。
- 環境はViteで構築する
- TypeScriptで書く
- Vitest、React Testing Library、jest-domを使ったテストを書く
今回の対象はこちらのページです。
Vitestのモック
今回は、扱うテーマの中にwindow.alertがよく出てくるのですが、jsdomがwindow.alertを実装していないことやイベントの伝播の
確認などの関係上、モックを利用することにします。
テストコードはVitestで書いているので、Vitestのモック機能を使うことにします。
ガイドはこちらです。
Vitestでモックを使う時は、viというヘルパーを使うことになります。
環境
今回の環境はこちら。
$ node --version v22.15.0 $ npm --version 10.9.2
準備
Viteを使って、Node.jsプロジェクトを作成します。
$ npm create vite@latest responding-to-events -- --template react-ts $ cd responding-to-events
依存ライブラリー、テスト用のライブラリーをインストール。
$ npm i $ npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom @types/react @types/react-dom $ npm i -D prettier
インストールしたライブラリーの一覧とバージョンはこちら。
$ npm ls responding-to-events@0.0.0 /path/to/responding-to-events ├── @eslint/js@9.25.1 ├── @testing-library/dom@10.4.0 ├── @testing-library/jest-dom@6.6.3 ├── @testing-library/react@16.3.0 ├── @testing-library/user-event@14.6.1 ├── @types/react-dom@19.1.2 ├── @types/react@19.1.2 ├── @vitejs/plugin-react@4.4.1 ├── eslint-plugin-react-hooks@5.2.0 ├── eslint-plugin-react-refresh@0.4.20 ├── eslint@9.25.1 ├── globals@16.0.0 ├── jsdom@26.1.0 ├── prettier@3.5.3 ├── react-dom@19.1.0 ├── react@19.1.0 ├── typescript-eslint@8.31.1 ├── typescript@5.7.3 ├── vite@6.3.3 └── vitest@3.1.2
自動生成された不要なファイルは削除しておきます。
$ find src -type f | grep -vE 'main.tsx|vite-env.d.ts' | xargs rm -rf $ rmdir src/assets
Viteで作成したプロジェクトから、追加・変更した設定ファイルを載せておきます。
tsconfig.json
{ "files": [], "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.vitest.json" } ] }
tsconfig.vitest.json
{ "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, "types": ["@testing-library/jest-dom"] }, "include": [ "test", "vitest.config.ts", "vitest.setup.ts" ] }
vitest.config.ts
import { defineConfig } from 'vite' export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], }, })
vitest.setup.ts
import '@testing-library/jest-dom/vitest';
.prettierrc.json
{ "singleQuote": true, "printWidth": 100 }
package.jsonのscriptsの内容。
"scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", "typecheck": "tsc -b", "typecheck:watch": "tsc -b --w", "format": "prettier --write src/**/*.{ts,tsx,css} test/**/*.{ts,tsx}", "test": "vitest run", "test:update": "vitest run -u", "test:watch": "vitest watch" },
Learn ReactのAdding InteractivityのResponding to Eventsをやってみる
Testing Libraryのuser-eventも使いますが、セットアップ部分は共通化しておきます。
test/setup.tsx
import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ReactNode } from 'react'; export function setup(jsx: ReactNode) { return { user: userEvent.setup(), ...render(jsx), }; }
Adding event handlers
Responding to Events / Adding event handlers
src/Button.tsx
export function Button() { const handleClick = () => alert('You clicked me!'); return <button onClick={handleClick}>Click me</button>; }
test/Button.test.tsx
import { screen } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { Button } from '../src/Button'; import { setup } from './setup'; afterEach(vi.restoreAllMocks); test(' Responding to Events, Button test', async () => { const { user, container } = setup(<Button />); expect(screen.getByText('Click me')).toBeInTheDocument(); expect(container).toMatchSnapshot(); await user.click(screen.getByText('Click me')); expect(container).toMatchSnapshot(); const alert = vi.spyOn(window, 'alert').mockImplementation((arg) => arg); await user.click(screen.getByText('Click me')); expect(alert).toHaveBeenCalledTimes(1); expect(alert).toHaveBeenCalledWith('You clicked me!'); });
Vitestのモックを初めて使いました。
今回はvi.SpyOnでwindow.alertをモックにしてアサーション。
const alert = vi.spyOn(window, 'alert').mockImplementation((arg) => arg); await user.click(screen.getByText('Click me')); expect(alert).toHaveBeenCalledTimes(1); expect(alert).toHaveBeenCalledWith('You clicked me!');
テスト終了後にvi.restoreAllMocksを呼び出し、モック化した関数を元の定義に戻しています。
afterEach(vi.restoreAllMocks);
ちなみにjsdomはwindow.alertを実装していないので、裏ではこんなスタックトレースが出力されています。
Error: Not implemented: window.alert
at module.exports (/path/to/responding-to-events/node_modules/jsdom/lib/jsdom/browser/not-implemented.js:9:17)
at /path/to/responding-to-events/node_modules/jsdom/lib/jsdom/browser/Window.js:960:7
at handleClick (/path/to/responding-to-events/src/Button.tsx:2:29)
at executeDispatch (/path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:16368:9)
at runWithFiberInDEV (/path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:1522:13)
at processDispatchQueue (/path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:16418:19)
at /path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:17016:9
at batchedUpdates$1 (/path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:3262:40)
at dispatchEventForPluginEventSystem (/path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:16572:7)
at dispatchEvent (/path/to/responding-to-events/node_modules/react-dom/cjs/react-dom-client.development.js:20658:11) undefined
ちょっと驚きますが、Errorがスローされるわけではないのでテストの進行自体には影響しません。
手動での動作確認は、こんな感じでAppコンポーネントに組み込むコンポーネントを変えながら行っています。
src/main.tsx
import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; createRoot(document.getElementById('root')!).render( <StrictMode> <App /> </StrictMode>, );
src/App.tsx
import { Button } from './Button'; export function App() { return <Button />; }
Reading props in event handlers
Responding to Events / Adding event handlers / Reading props in event handlers
src/AlertButton.tsx
import { PropsWithChildren } from 'react'; type AlertButtonProps = { message: string; }; export function AlertButton(props: PropsWithChildren<AlertButtonProps>) { return <button onClick={() => alert(props.message)}>{props.children}</button>; }
test/AlertButton.test.tsx
import { screen } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { AlertButton } from '../src/AlertButton'; import { setup } from './setup'; afterEach(vi.restoreAllMocks); test('Responding to Events, AlertButton test', async () => { const { user, container } = setup(<AlertButton message="show message">Hello World</AlertButton>); expect(screen.getByText('Hello World')).toBeInTheDocument(); expect(container).toMatchSnapshot(); const alert = vi.spyOn(window, 'alert').mockImplementation((arg) => arg); await user.click(screen.getByText('Hello World')); expect(alert).toHaveBeenCalledOnce(); expect(alert).toHaveBeenCalledWith('show message'); });
src/Toolbar.tsx
import { AlertButton } from './AlertButton'; export function Toolbar() { return ( <div> <AlertButton message="Playing!">Play Movie</AlertButton> <AlertButton message="Uploading!">Upload Image</AlertButton> </div> ); }
test/Toolbar.test.tsx
import { setup } from './setup'; import { screen } from '@testing-library/react'; import { Toolbar } from '../src/Toolbar'; import { afterEach, expect, test, vi } from 'vitest'; afterEach(vi.restoreAllMocks); test('Responding to Events, Toolbar test', () => { const { container } = setup(<Toolbar />); expect(screen.getAllByRole('button')).toHaveLength(2); expect(screen.getByText('Play Movie')).toBeInTheDocument(); expect(screen.getByText('Upload Image')).toBeInTheDocument(); expect(container).toMatchSnapshot(); });
Naming event handler props
Responding to Events / Adding event handlers / Naming event handler props
src/Button2.tsx
import { MouseEventHandler, PropsWithChildren } from 'react'; type Button2Props = { onSmash: MouseEventHandler<HTMLButtonElement>; }; export function Button2(props: PropsWithChildren<Button2Props>) { return <button onClick={props.onSmash}>{props.children}</button>; }
test/Button2.test.tsx
import { screen } from '@testing-library/react'; import { expect, test, vi } from 'vitest'; import { Button2 } from '../src/Button2'; import { setup } from './setup'; test('Responding to Events, Button2 test', async () => { const handler = vi.fn((arg) => arg); const { user, container } = setup(<Button2 onSmash={handler}>Hello World</Button2>); expect(screen.getByText('Hello World')).toBeInTheDocument(); expect(container).toMatchSnapshot(); await user.click(screen.getByText('Hello World')); expect(handler).toHaveBeenCalledOnce(); });
今回は、イベントハンドラーに渡す関数をモック化しました。
const handler = vi.fn((arg) => arg); ... expect(handler).toHaveBeenCalledOnce();
Stopping propagation
Responding to Events / Adding event handlers / Stopping propagation
src/Button3.tsx
import { MouseEventHandler, PropsWithChildren } from 'react'; type Button3Props = { onClick: MouseEventHandler<HTMLButtonElement>; }; export function Button3(props: PropsWithChildren<Button3Props>) { return ( <button onClick={(e) => { e.stopPropagation(); props.onClick(e); }} > {props.children} </button> ); }
test/Button3.test.tsx
import { expect, test, vi } from 'vitest'; import { setup } from './setup'; import { Button3 } from '../src/Button3'; import { screen } from '@testing-library/react'; test('Responding to Events, Button3 test', async () => { const handler = vi.fn((arg) => arg); const { user, container } = setup(<Button3 onClick={handler}>Hello World</Button3>); expect(screen.getByText('Hello World')).toBeInTheDocument(); await user.click(screen.getByText('Hello World')); expect(handler).toHaveBeenCalledOnce(); expect(container).toMatchSnapshot(); });
src/Toolbar2.tsx
import { Button3 } from './Button3'; export function Toolbar2() { return ( <div className="Toolbar" onClick={() => { alert('You clicked on toolbar!'); }} > <Button3 onClick={() => alert('Playing!')}>Play Movie</Button3> <Button3 onClick={() => alert('Uploading!')}>Upload Image</Button3> </div> ); }
test/Toolbar2.test.tsx
import { screen } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { Toolbar2 } from '../src/Toolbar2'; import { setup } from './setup'; afterEach(vi.restoreAllMocks); test('Responding to Events, Toolbar2 test', async () => { const alert = vi.spyOn(window, 'alert').mockImplementation((arg) => arg); const { user, container } = setup(<Toolbar2 />); expect(screen.getByText('Play Movie')).toBeInTheDocument(); await user.click(screen.getByText('Play Movie')); expect(alert).toHaveBeenCalledTimes(1); // e.stopPropagation();をコメントアウトすると2になる expect(container).toMatchSnapshot(); });
Preventing default behavior
Responding to Events / Adding event handlers / Preventing default behavior
src/Signup.tsx
export function Signup() { return ( <form onSubmit={(e) => { e.preventDefault(); alert('Submitting!'); }} > <input /> <button>Send</button> </form> ); }
test/Signup.test.tsx
import { afterEach, expect, test, vi } from 'vitest'; import { Signup } from '../src/Signup'; import { setup } from './setup'; import { screen } from '@testing-library/react'; afterEach(vi.restoreAllMocks); test('Responding to Events, Signup test', async () => { const alert = vi.spyOn(window, 'alert').mockImplementation((arg) => arg); const { user, container } = setup(<Signup />); expect(screen.getByRole('button')).toBeInTheDocument(); await user.click(screen.getByRole('button')); expect(alert).toHaveBeenCalledOnce(); expect(container).toMatchSnapshot(); });
おわりに
「Learn React」の「Adding Interactivity」から「Responding to Events」をやってみました。
今回はVitestでのモックの使い方や、イベントハンドラーをPropsとして受け取る時にどういう型がよいのかに悩んだりすることが
多かったです。
ちょっとずつ進んでいる感じはしますね…。