CLOVER🍀

That was when it all began.

Learn ReactをVitest、React Testing Libraryを使ってやってみる(Understanding Your UI as a Tree)

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

こちらの続きです。

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」の「Describing the UI」から、「Understanding Your UI as a Tree」をやってみます。

Understanding Your UI as a Tree – React

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

Learn Reactをやってみる

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

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

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

Understanding Your UI as a Tree – React

user-event Testing Library

今回は、Testing Libraryより新しいテスト用ライブラリーを使います。こちらのuser-event Testing Libraryですね。

Introduction | Testing Library

user-eventは、ブラウザ内でインタラクションが行われた時に発生するイベントをディスパッチすることで、ユーザーの
インタラクションをエミュレーションするTesting Libraryの一種です。

Testing LibraryにはfireEventsというものもあって、こちらもユーザーのイベントを扱うことができます。

Firing Events | Testing Library

違いは?というと以下にuser-event側に書かれています。fireEventsはDOMイベントをディスパッチしますが、user-eventでは
複数のイベントを発生させられるのが違いのようです。

fireEvent dispatches DOM events, whereas user-event simulates full interactions, which may fire multiple events and do additional checks along the way.

以下のように、ユーザーがテキストボックスに入力するとフォーカスが当たり、キーイベントと入力イベントがトリガーされ、
値の選択・操作が行われるといったところですね。

For example, when a user types into a text box, the element has to be focused, and then keyboard and input events are fired and the selection and value on the element are manipulated as they type.

ちなみにfireEventsでも、ほとんどの場合はuser-eventを使うのがよいだろうと言っています。

Most projects have a few use cases for fireEvent, but the majority of the time you should probably use @testing-library/user-event.

Firing Events | Testing Library

user-eventを使うには、ライブラリーの追加が必要です。

今回は両方扱ってみます。

環境

今回の環境はこちら。

$ node --version
v22.15.0


$ npm --version
10.9.2

準備

Viteを使って、Node.jsプロジェクトを作成します。

$ npm create vite@latest understanding-your-ui-as-a-tree -- --template react-ts
$ cd understanding-your-ui-as-a-tree

依存ライブラリー、テスト用のライブラリーをインストール。

$ 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

今回から@testing-library/user-eventを追加しています。

インストールしたライブラリーの一覧とバージョンはこちら。

$ npm ls
understanding-your-ui-as-a-tree@0.0.0 /path/to/understanding-your-ui-as-a-tree
├── @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.0
├── typescript@5.7.3
├── vite@6.3.2
└── 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のDescribing the UIのUnderstanding Your UI as a Treeをやってみる

The Render Tree(前半)

こちらの前半部分です。

Understanding Your UI as a Tree / The Render Tree

src/Copyright.tsx

type CopyrightProps = {
  year: number;
};

export function Copyright(props: CopyrightProps) {
  return <p className="small">©️  {props.year}</p>;
}

test/Copyright.test.tsx

import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import { Copyright } from '../src/Copyright';

afterEach(cleanup);

test('Understanding Your UI as a Tree, Copyright test1', () => {
  const { container } = render(<Copyright year={2004} />);

  expect(screen.getByRole('paragraph')).toHaveClass('small');
  expect(screen.getByText('©️  2004')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

test('Understanding Your UI as a Tree, Copyright test2', () => {
  const { container } = render(<Copyright year={2025} />);

  expect(screen.getByRole('paragraph')).toHaveClass('small');
  expect(screen.getByText('©️  2025')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

src/FancyText.tsx

type FancyTextProps = {
  title?: boolean;
  text: string;
};

export function FancyText(props: FancyTextProps) {
  return props.title ? (
    <h1 className="fancy title">{props.text}</h1>
  ) : (
    <h3 className="fancy cursive">{props.text}</h3>
  );
}

test/FancyText.test.tsx

import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import { FancyText } from '../src/FancyText';

afterEach(cleanup);

test('Understanding Your UI as a Tree, FancyText test1', () => {
  const { container } = render(<FancyText title text="Hello" />);

  expect(screen.getByRole('heading')).toHaveClass('fancy title');
  expect(screen.getByText('Hello')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

test('Understanding Your UI as a Tree, FancyText test2', () => {
  const { container } = render(<FancyText text="World" />);

  expect(screen.getByRole('heading')).toHaveClass('fancy cursive');
  expect(screen.getByText('World')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

src/quates.ts

export const quates = [
  'Don’t let yesterday take up too much of today.” — Will Rogers',
  'Ambition is putting a ladder against the sky.',
  "A joy that's shared is a joy made double.",
];

src/InspirationGenerator.tsx

import { ReactNode, useState } from 'react';
import { FancyText } from './FancyText';
import { quates } from './quates';

export function InspirationGenerator({ children }: { [key: string]: ReactNode }) {
  const [index, setIndex] = useState(0);
  const quate = quates[index];
  const next = () => setIndex((index + 1) % quates.length);

  return (
    <>
      <p>Your inspirational quote is:</p>
      <FancyText text={quate} />
      <button onClick={next}>Inspire me again</button>
      {children}
    </>
  );
}

test/InspirationGenerator.test.tsx

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test } from 'vitest';
import { InspirationGenerator } from '../src/InspirationGenerator';

afterEach(cleanup);

test('Understanding Your UI as a Tree, InspirationGenerator test, use fireEvent', () => {
  const { container } = render(
    <InspirationGenerator>
      <ul>
        <li>foo</li>
        <li>bar</li>
      </ul>
    </InspirationGenerator>,
  );

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getAllByRole('listitem')).toHaveLength(2);

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByText("A joy that's shared is a joy made double.")).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

test('Understanding Your UI as a Tree, InspirationGenerator test, use userEvent', async () => {
  const user = userEvent.setup();

  const { container } = render(
    <InspirationGenerator>
      <ul>
        <li>foo</li>
        <li>bar</li>
      </ul>
    </InspirationGenerator>,
  );

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getAllByRole('listitem')).toHaveLength(2);

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByText("A joy that's shared is a joy made double.")).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

ここでfireEventsとuser-eventを使っています。

fireEventsを使っている方。

test('Understanding Your UI as a Tree, InspirationGenerator test, use fireEvent', () => {
  const { container } = render(
    <InspirationGenerator>
      <ul>
        <li>foo</li>
        <li>bar</li>
      </ul>
    </InspirationGenerator>,
  );

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getAllByRole('listitem')).toHaveLength(2);

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  ....

こういう部分ですね。

  fireEvent.click(screen.getByText('Inspire me again'));

user-eventを使っている方。

test('Understanding Your UI as a Tree, InspirationGenerator test, use userEvent', async () => {
  const user = userEvent.setup();

  const { container } = render(
    <InspirationGenerator>
      <ul>
        <li>foo</li>
        <li>bar</li>
      </ul>
    </InspirationGenerator>,
  );

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getAllByRole('listitem')).toHaveLength(2);

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  ...

user-eventの場合、asyncを使うことになります。

test('Understanding Your UI as a Tree, InspirationGenerator test, use userEvent', async () => {

userEvent#setupして

  const user = userEvent.setup();

awaitを使ってイベントを呼び出すことになります。

  await user.click(screen.getByText('Inspire me again'));

DOMのアサーションはどうしようかなと思ったのですが、複数回スナップショットを取るとそれぞれ区別してくれそうだったので、
こちらを利用することにしました。

test/__snapshots__/InspirationGenerator.test.tsx.snap

// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html`

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use fireEvent 1`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    Don’t let yesterday take up too much of today.” — Will Rogers
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use fireEvent 2`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    Ambition is putting a ladder against the sky.
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use fireEvent 3`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    A joy that's shared is a joy made double.
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use fireEvent 4`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    Don’t let yesterday take up too much of today.” — Will Rogers
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use userEvent 1`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    Don’t let yesterday take up too much of today.” — Will Rogers
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use userEvent 2`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    Ambition is putting a ladder against the sky.
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use userEvent 3`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    A joy that's shared is a joy made double.
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

exports[`Understanding Your UI as a Tree, InspirationGenerator test, use userEvent 4`] = `
<div>
  <p>
    Your inspirational quote is:
  </p>
  <h3
    class="fancy cursive"
  >
    Don’t let yesterday take up too much of today.” — Will Rogers
  </h3>
  <button>
    Inspire me again
  </button>
  <ul>
    <li>
      foo
    </li>
    <li>
      bar
    </li>
  </ul>
</div>
`;

複数回取得すると、番号が増えていくみたいですね。1回だけでも1と入ります。

src/App.tsx

import { Copyright } from './Copyright';
import { FancyText } from './FancyText';
import { InspirationGenerator } from './InspirationGenerator';

export function App() {
  return (
    <>
      <FancyText title text="Get Inspired App" />
      <InspirationGenerator>
        <Copyright year={2004} />
      </InspirationGenerator>
    </>
  );
}

test/App.test.tsx

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import { App } from '../src/App';
import { ReactNode } from 'react';
import userEvent from '@testing-library/user-event';

afterEach(cleanup);

test('Understanding Your UI as a Tree, App test, use fireEvent', () => {
  const { container } = render(<App />);

  expect(screen.getByRole('heading', { name: /Get Inspired App/ })).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: /Don’t let yesterday take up too much of today.” — Will Rogers/,
    }),
  ).toBeInTheDocument();
  expect(screen.getByText('©️  2004')).toHaveClass('small');

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

function setup(jsx: ReactNode) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}

test('Understanding Your UI as a Tree, App test, use userEvent', async () => {
  const { user, container } = setup(<App />);

  expect(screen.getByRole('heading', { name: /Get Inspired App/ })).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: /Don’t let yesterday take up too much of today.” — Will Rogers/,
    }),
  ).toBeInTheDocument();
  expect(screen.getByText('©️  2004')).toHaveClass('small');

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

こちらもイベントを扱うのでfireEventsとuser-eventを使っているのですが、user-eventについては少し使い方を変えています。

function setup(jsx: ReactNode) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}

test('Understanding Your UI as a Tree, App test, use userEvent', async () => {
  const { user, container } = setup(<App />);

こちらに習ったものですね。

Introduction / Writing tests with userEvent

手動での確認は、こちらに組み込むコンポーネントを変えながら行っています。

src/main.tsx

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);
The Render Tree(後半)

こちらの後半部分です。

Understanding Your UI as a Tree / The Render Tree

前半で書いたものがそのまま使えるものについては、新しく作りません。

src/Color.tsx

type ColorProps = {
  value: string;
};

export function Color(props: ColorProps) {
  return (
    <div style={{ backgroundColor: props.value, height: '100px', width: '100px', margin: '8px' }} />
  );
}

test/Color.test.tsx

import { render } from '@testing-library/react';
import { expect, test } from 'vitest';
import { Color } from '../src/Color';

test('Understanding Your UI as a Tree, Color test', () => {
  const { container } = render(<Color value="#B73636" />);

  expect(container).toContainHTML(
    '<div style="background-color: rgb(183, 54, 54); height: 100px; width: 100px; margin: 8px;" />',
  );

  expect(container).toMatchSnapshot();
});

src/inspirations.ts

type Inspiration = {
  type: string;
  value: string;
};

export const inspirations: Inspiration[] = [
  { type: 'quote', value: 'Don’t let yesterday take up too much of today.” — Will Rogers' },
  { type: 'color', value: '#B73636' },
  { type: 'quote', value: 'Ambition is putting a ladder against the sky.' },
  { type: 'color', value: '#256266' },
  { type: 'quote', value: "A joy that's shared is a joy made double." },
  { type: 'color', value: '#F9F2B4' },
];

src/InspirationGenerator2.tsx

import { ReactNode, useState } from 'react';
import { Color } from './Color';
import { FancyText } from './FancyText';
import { inspirations } from './inspirations';

type InspirationGenerator2Props = {
  children: ReactNode;
};

export function InspirationGenerator2(props: InspirationGenerator2Props) {
  const [index, setIndex] = useState(0);
  const inspiration = inspirations[index];
  const next = () => setIndex((index + 1) % inspirations.length);

  return (
    <>
      <p>Your inspirational {inspiration.type} is:</p>
      {inspiration.type === 'quote' ? (
        <FancyText text={inspiration.value} />
      ) : (
        <Color value={inspiration.value} />
      )}
      <button onClick={next}>Inspire me again</button>
      {props.children}
    </>
  );
}

test/InspirationGenerator2.test.tsx

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReactNode } from 'react';
import { afterEach, expect, test } from 'vitest';
import { InspirationGenerator2 } from '../src/InspirationGenerator2';

afterEach(cleanup);

test('Understanding Your UI as a Tree, InspirationGenerator2 test, use fireEvent', () => {
  const { container } = render(
    <InspirationGenerator2>
      <ul>
        <li>foo</li>
        <li>bar</li>
      </ul>
    </InspirationGenerator2>,
  );

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getAllByRole('listitem')).toHaveLength(2);

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getByText("A joy that's shared is a joy made double.")).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

function setup(jsx: ReactNode) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}

test('Understanding Your UI as a Tree, InspirationGenerator2 test, use userEvent', async () => {
  const { user, container } = setup(
    <InspirationGenerator2>
      <ul>
        <li>foo</li>
        <li>bar</li>
      </ul>
    </InspirationGenerator2>,
  );

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getAllByRole('listitem')).toHaveLength(2);

  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(screen.getByText("A joy that's shared is a joy made double.")).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational quote is:');
  expect(
    screen.getByText('Don’t let yesterday take up too much of today.” — Will Rogers'),
  ).toBeInTheDocument();

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByRole('paragraph')).toHaveTextContent('Your inspirational color is:');
  expect(screen.queryByRole('heading')).not.toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

src/App2.tsx

import { Copyright } from './Copyright';
import { FancyText } from './FancyText';
import { InspirationGenerator2 } from './InspirationGenerator2';

export function App2() {
  return (
    <>
      <FancyText title text="Get Inspired App" />
      <InspirationGenerator2>
        <Copyright year={2004} />
      </InspirationGenerator2>
    </>
  );
}

test/App2.test.tsx

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
import { App2 } from '../src/App2';
import { ReactNode } from 'react';
import userEvent from '@testing-library/user-event';

afterEach(cleanup);

test('Understanding Your UI as a Tree, App2 test, use fireEvent', () => {
  const { container } = render(<App2 />);

  expect(screen.getByRole('heading', { name: /Get Inspired App/ })).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: /Don’t let yesterday take up too much of today.” — Will Rogers/,
    }),
  ).toBeInTheDocument();
  expect(screen.getByText('©️  2004')).toHaveClass('small');

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(container).toMatchSnapshot();

  fireEvent.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

function setup(jsx: ReactNode) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  };
}

test('Understanding Your UI as a Tree, App2 test, use userEvent', async () => {
  const { user, container } = setup(<App2 />);

  expect(screen.getByRole('heading', { name: /Get Inspired App/ })).toBeInTheDocument();
  expect(
    screen.getByRole('heading', {
      name: /Don’t let yesterday take up too much of today.” — Will Rogers/,
    }),
  ).toBeInTheDocument();
  expect(screen.getByText('©️  2004')).toHaveClass('small');

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(container).toMatchSnapshot();

  await user.click(screen.getByText('Inspire me again'));

  expect(screen.getByText('Ambition is putting a ladder against the sky.')).toBeInTheDocument();

  expect(container).toMatchSnapshot();
});

おわりに

「Learn React」の「Describing the UI」から、「Understanding Your UI as a Tree」をやってみました。

今回は初めてイベントを扱ってみましたね。これが理由で、前回のエントリーと一緒にはしなかったのですが。

これで「Describing the UI」の内容は終わりです。基礎中の基礎的なことはひとまずできたのでしょうか。

次は「Adding Interactivity」に移ります。

Adding Interactivity – React

「Describing the UI」は初回で最初の要約的なページで一気にやりましたが、次のコンテンツからもっと細かくするかどうかは
内容を見て決めたいと思います。