CLOVER🍀

That was when it all began.

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Responding to Events)

これは、なにをしたくて書いたもの?

こちらの続きです。

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」をやってみます。

Responding to Events – React

基本的には写経のシリーズです。

Learn Reactをやってみる

Learn Reactをただ写経するのではなく、以下の条件で進めていきます。再掲です。

  • 環境はViteで構築する
  • TypeScriptで書く
  • Vitest、React Testing Library、jest-domを使ったテストを書く

今回の対象はこちらのページです。

Responding to Events – React

Vitestのモック

今回は、扱うテーマの中にwindow.alertがよく出てくるのですが、jsdomがwindow.alertを実装していないことやイベントの伝播の
確認などの関係上、モックを利用することにします。

テストコードはVitestで書いているので、Vitestのモック機能を使うことにします。

ガイドはこちらです。

Mocking | Guide | Vitest

Vitestでモックを使う時は、viというヘルパーを使うことになります。

Vi | Vitest

環境

今回の環境はこちら。

$ 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.jsonscriptsの内容。

  "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.SpyOnwindow.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として受け取る時にどういう型がよいのかに悩んだりすることが
多かったです。

ちょっとずつ進んでいる感じはしますね…。