CLOVER🍀

That was when it all began.

Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Keeping Components Pure)

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

こちらの続きです。

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.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"
  },

今回から、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のスナップショットも使うことにしました。

Snapshot | Guide | 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を変更するとテストが一斉に失敗するようになるはずなので、更新するのも大変でしょうし。