これは、なにをしたくて書いたもの?
こちらの続きです。
Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Describing the UI) - CLOVER🍀
「Learn React」の「Describing the UI」から、「Keeping Components Pure」をやってみます。
Keeping Components Pure – React
基本的には写経のシリーズです。
Learn Reactをやってみる
Learn Reactをただ写経するのではなく、以下の条件でやります。再掲です。
- 環境はViteで構築する
- TypeScriptで書く
- Vitest、React Testing Library、jest-domを使ったテストを書く
今回の対象はこちらのページです。
Keeping Components Pure – React
環境
今回の環境はこちら。
$ node --version v22.14.0 $ npm --version 10.9.2
準備
Viteを使って、Node.jsプロジェクトを作成します。
$ npm create vite@latest keeping-components-pure -- --template react-ts $ cd keeping-components-pure
依存ライブラリー、テスト用のライブラリーをインストール。
$ npm i $ npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @types/react @types/react-dom $ npm i -D prettier
結果はこちら。
$ npm ls keeping-components-pure@0.0.0 /path/to/keeping-components-pure ├── @eslint/js@9.25.0 ├── @testing-library/dom@10.4.0 ├── @testing-library/jest-dom@6.6.3 ├── @testing-library/react@16.3.0 ├── @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.19 ├── eslint@9.25.0 ├── globals@16.0.0 ├── jsdom@26.1.0 ├── prettier@3.5.3 ├── react-dom@19.1.0 ├── react@19.1.0 ├── typescript-eslint@8.30.1 ├── typescript@5.7.3 ├── vite@6.3.2 └── vitest@3.1.1
自動生成された不要なファイルは削除しておきます。
$ 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" },
今回から、Vitestのスナップショットを使います。
Command Line Interface / Options / update
Learn ReactのDescribing the UIのKeeping Components Pureをやってみる
Purity: Components as formulas
Keeping Components Pure / Purity: Components as formulas
src/Recipe.tsx
type RecipeProps = { drinkers: number; }; export function Recipe(props: RecipeProps) { return ( <ol> <li>Boil {props.drinkers} cups of water.</li> <li> Add {props.drinkers} spoons of tea and {0.5 * props.drinkers} spoons of spice. </li> <li>Add {0.5 * props.drinkers} cups of milk to boil and sugar to taste.</li> </ol> ); }
test/Recipe.test.tsx
import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { Recipe } from '../src/Recipe'; test('Keeping Components Pure, Recipe test', () => { const { container } = render(<Recipe drinkers={4} />); expect(screen.getByRole('list')).toBeInTheDocument(); expect(screen.getAllByRole('listitem')).toHaveLength(3); expect(screen.getByText('Boil 4 cups of water.')).toBeInTheDocument(); expect(screen.getByText('Add 4 spoons of tea and 2 spoons of spice.')).toBeInTheDocument(); expect(screen.getByText('Add 2 cups of milk to boil and sugar to taste.')).toBeInTheDocument(); expect(container).toMatchSnapshot(); });
src/RecipeBoard.tsx
import { Recipe } from './Recipe'; export function RecipeBoard() { return ( <section> <h1>Spiced Chai Recipe</h1> <h2>For two</h2> <Recipe drinkers={2} /> <h2>For a gathering</h2> <Recipe drinkers={4} /> </section> ); }
test/RecipeBoard.test.tsx
import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { RecipeBoard } from '../src/RecipeBoard'; test('Keeping Components Pure, RecipeBoard test', () => { const { container } = render(<RecipeBoard />); expect(screen.getAllByRole('heading')).toHaveLength(3); expect(screen.getByText('Spiced Chai Recipe')).toBeInTheDocument(); expect(screen.getByText('For two')).toBeInTheDocument(); expect(screen.getByText('For a gathering')).toBeInTheDocument(); expect(screen.getAllByRole('listitem')).toHaveLength(6); expect(screen.getByText('Boil 4 cups of water.')).toBeInTheDocument(); expect(screen.getByText('Boil 2 cups of water.')).toBeInTheDocument(); expect(container).toMatchSnapshot(); });
今回から、Vitestのスナップショットも使うことにしました。
保存されたスナップショットの例。
test/__snapshots__/Recipe.test.tsx.snap
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Keeping Components Pure, Recipe test 1`] = ` <div> <ol> <li> Boil 4 cups of water. </li> <li> Add 4 spoons of tea and 2 spoons of spice. </li> <li> Add 2 cups of milk to boil and sugar to taste. </li> </ol> </div> `;
手動での動作確認は、こんな感じで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 { RecipeBoard } from './RecipeBoard'; export function App() { return <RecipeBoard />; }
Side Effects: (un)intended consequences
Keeping Components Pure / Side Effects: (un)intended consequences
src/Cup.tsx
type CupProps = { guest: number; }; export function Cup(props: CupProps) { return <h2>Tea cup for guest #{props.guest}</h2>; }
test/Cup.test.tsx
import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { Cup } from '../src/Cup'; test('Keeping Components Pure, Cup test', () => { render(<Cup guest={2} />); expect(screen.getByRole('heading')).toHaveTextContent('Tea cup for guest #2'); });
src/TeaSet.tsx
import { Cup } from './Cup'; export function TeaSet() { return ( <> <Cup guest={1} /> <Cup guest={2} /> <Cup guest={3} /> </> ); }
test/TeaSet.test.tsx
import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { TeaSet } from '../src/TeaSet'; test('Keeping Components Pure, TeaSet test', () => { const { container } = render(<TeaSet />); expect(screen.getAllByRole('heading')).toHaveLength(3); expect(screen.getByText('Tea cup for guest #1')).toBeInTheDocument(); expect(screen.getByText('Tea cup for guest #2')).toBeInTheDocument(); expect(screen.getByText('Tea cup for guest #3')).toBeInTheDocument(); expect(container).toMatchSnapshot(); });
src/TeaGathering.tsx
import { Cup } from './Cup'; export function TeaGathering() { let cups = []; for (let i = 1; i <= 12; i++) { cups.push(<Cup key={i} guest={i} />); } return cups; }
test/TeaGathering.test.tsx
import { render, screen } from '@testing-library/react'; import { expect, test } from 'vitest'; import { TeaGathering } from '../src/TeaGathering'; test('Keeping Components Pure, TeaGathering test', () => { const { container } = render(<TeaGathering />); expect(screen.getAllByRole('heading')).toHaveLength(12); expect(container).toMatchSnapshot(); });
今回はここまでです。
おわりに
Learn ReactのDescribing the UIから、Keeping Components Pureをやってみました。
ちょっと短めだったので、次の「Understanding Your UI as a Tree」を合わせてやろうかなと思ったのですが、こちらが
大きめだったので分割することにしました。
Understanding Your UI as a Tree – React
今回から、Vitestのスナップショットを使うようにしてみました。DOMを文字列比較するのに便利だとは思うのですが、
実際に修正を続けていったとするとどのくらいスナップショットを使うテストにした方がいいか悩みそうな気もするので
慣れて感覚を掴んでからですね。
UIを変更するとテストが一斉に失敗するようになるはずなので、更新するのも大変でしょうし。