CLOVER🍀

That was when it all began.

Learn ReactをVitest、React Testing Library、jest-domを使ってやってみる(Describing the UI)

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

最近少しReactを触っているのですが、いきなり書こうとしても「基礎力が全然足りないな?」と思うようになったので、
Reactの「Learn React」をやってみることにしました。

半分くらいは写経で、あまり説明を書いていくつもりはないのでエントリーとしてはとてもおもしろくないと思います。

写経外のことも含めて、自分のメモとしての利用が目的です。

Learn Reactをやってみる

Learn Reactとは、Reactのドキュメントのこのあたりの一連のコンテンツのことを指しています。

書籍とかWebを見ていても実感が湧かないので、では書いてみるかとやってみたもののどうにも基礎力が足りないと思う
場面が増えてきたのでこちらをちゃんとやってみようかなと思った次第ですね。

なお、完全な写経ではさすがにおもしろくないので、以下の条件でやってみようと思います。

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

このあたりの周辺技術にも慣れようという狙いがあります。

Learn Reactにはそれなりにページ数があるので、全部のページをやろうとは思っていません。今回のスタンスで自分がやって
おいた方がよさそうだと思うページを対象にします。

また、内容的にはほぼ写経なので動作結果などはほとんど書きません。自分自身は確認しますが、結果自体はLearn Reactの
各ページを見ればよい話なので。

写経外のことをやって、自分が思ったことや環境のメモを残すのが今回のようなエントリーを書く目的としては大きいです。

という前置きをして、今回はこちらのページを対象にします。

Describing the UI – React

環境

今回の環境はこちら。

$ node --version
v22.14.0


$ npm --version
10.9.2

準備

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

$ npm create vite@latest describing-the-ui -- --template react-ts
$ cd describing-the-ui

依存ライブラリーなどをインストール。テスト用のライブラリーもここでインストールしてしまいます。

$ 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
describing-the-ui@0.0.0 /path/to/describing-the-ui
├── @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

Testing Library

生成されたプロジェクトの設定を変更していくのですが、その前にTesting Libraryを少し見ておきます。

Testing LibraryのWebサイトはこちら。

Testing Library | Testing Library

Testing Libraryは、UIコンポーネントをテストするためのライブラリーです。

The @testing-library family of packages helps you test UI components in a user-centric way.

Introduction | Testing Library

コアライブラリーと、各フレームワーク向けにラップされたライブラリーで構成されています。

ReactのUIコンポーネントテストで使うのはReact Testing Libraryです。

React Testing Library | Testing Library

これはDOM Testing Library上に構築されています。

Introduction | Testing Library

DOM Testing Libraryはjsdom上でDOM操作を行うようになっているので、今回はテストに使うVitestをjsdom向けに
設定することになります。

コアなAPIについては今回はクエリーのみを使いますが、このあたりはちゃんと見ておいた方がよさそうですね…。

About Queries | Testing Library

また、Jest向けのカスタムマッチャーも使います。こちらはJest向けに見えますが、Vitest向けの内容も含まれています。

jest-dom | Testing Library

テストを含めて設定を行う

Viteのテンプレートを使ってプロジェクトを作成したところから、Vitestを使ったテストができるようになるまで設定を変えて
いきます。

tsconfig.jsonにVitest用の設定を追加。

tsconfig.json

{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" },
    { "path": "./tsconfig.vitest.json" }
  ]
}

追加したVitest用の設定はこんな感じです。

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"
  ]
}

tsconfig.app.jsonの内容をベースに、Vitest向けの設定を行っています。

Vitestの設定。テンプレートではViteの設定ファイルが生成されるのですが、Vitest向けの設定ファイルは独立して作成することに
しました。

vitest.config.ts

import { defineConfig } from 'vite'

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
})

environmentjsdoimを指定し、Node.js環境上でブラウザ関連のAPIが扱えるようにします。

テストで使うReact Testing Libraryはこのあたりが必要になるようなので。

vitest.setup.tsでは、テストコード向けのセットアップ内容を記述します。

vitest.setup.ts

import '@testing-library/jest-dom/vitest';

今回追加しているのは、Testing Libraryのjest-domのカスタムマッチャーを利用できるようにする設定です。

jest-dom | Testing Library

jest-domはDOMに関するJestのカスタムアサーションを追加するライブラリーですが、Vitest向けのサポートもあったりします。

jest-dom / Usage / With Vitest

手順ではVitestで使うtsconfig.jsonの設定で、typesvitest/globals含めるようになっていますが、これとVitestのglobals
組み合わせることでJestのtestexpectを明示的にimportする必要がなくなります。

Vitestのドキュメントでは明示的にimportさせていることが多いので、今回はここは外しました。

ここまでで、tsconfig.jsonが3つのtsconfigを参照しているのですが、includeごとに設定が違うということになりますね。

各ファイルからincludeのみを抜粋します。

tsconfig.app.json

  "include": ["src"]

tsconfig.node.json

  "include": ["vite.config.ts"]

tsconfig.vitest.json

  "include": [
    "test",
    "vitest.config.ts",
    "vitest.setup.ts"
  ]

Prettierの設定も追加。

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

Viteのテンプレートから追加したものは、previewより後ろですね。

では、準備ができたのでLearn ReactのDescribing the UIを進めていきましょう。

Learn ReactのDescribing the UIをやってみる

では、Learn ReactのDescribing the UIを進めていきます。

Describing the UI – React

ここから先は、セクションごとにソースコード、テストコードの順に載せていきます。

Your first component

Describing the UI / Your first component

src/Profile.tsx

export function Profile() {
  return <img src="https://i.imgur.com/MK3eW3As.jpg" alt="Katherine Johnson" />;
}

test/Profile.test.tsx

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

test('Describing the UI, Profile test', () => {
  render(<Profile />);

  expect(screen.getByAltText('Katherine Johnson')).not.toBeUndefined();
  expect(screen.getByRole('img').getAttribute('src')).toStrictEqual(
    'https://i.imgur.com/MK3eW3As.jpg',
  );
  expect(screen.getByAltText('Katherine Johnson')).toBeInTheDocument();
});

src/Gallery.tsx

import { Profile } from './Profile';

export function Gallery() {
  return (
    <section>
      <h1>Amazing scientists</h1>
      <Profile />
      <Profile />
      <Profile />
    </section>
  );
}

test/Gallery.test.tsx

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

test('Describing the UI, Gallery test', () => {
  render(<Gallery />);

  expect(screen.getByRole('heading')).toBeInTheDocument();
  expect(screen.getAllByRole('img')).toHaveLength(3);
});

Testing Libraryが提供する、クエリーAPIに慣れないといけないですね。

About Queries | Testing Library

クエリーには種類があり、関数名がget〜query〜find〜のどれで始まるかで振る舞いが変わります。

About Queries / Types of Queries

また、関数名にAllが入るかどうかで取得件数も変わります。

どのような条件でクエリーするかについては優先度の指針があり、まずはロールを使うのがよさそうです。

About Queries / Priority

ロールとは、WAI-ARIAロールのことらしいです。

WAI-ARIA ロール - ARIA | MDN

ちなみに動作確認は、main.tsxをこのようにして

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>,
);

App.tsxの中身を作成したコンポーネントに置き換えて確認していきます。

src/App.tsx

import { Gallery } from "./Gallery";

export function App() {
  return <Gallery/>;
}
Passing props to a component

Describing the UI / Passing props to a component

src/utils.ts

type Person = {
  imageId: string;
};

export function getImageUrl(person: Person, size: string = 's') {
  return `https://i.imgur.com/${person.imageId}${size}.jpg`;
}

src/Avatar.tsx

import { getImageUrl } from './utils';

type AvatarProps = {
  size: number;
  person: PersonProps;
};

type PersonProps = {
  name: string;
  imageId: string;
};

export function Avatar({ person, size }: AvatarProps) {
  return (
    <img
      className="avatar"
      src={getImageUrl(person)}
      alt={person.name}
      width={size}
      height={size}
    />
  );
}

test/Avatar.test.tsx

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

test('Describing the UI, Avatar test', () => {
  render(<Avatar size={50} person={{ name: 'Test User', imageId: 'YfeOqp2' }} />);

  const img = screen.getByRole('img');

  const imgAttributes = Array.from(img?.attributes!).reduce((attrs, attr) => {
    return { ...attrs, [attr.name]: attr.value };
  }, {});

  expect(imgAttributes).toStrictEqual({
    class: 'avatar',
    src: 'https://i.imgur.com/YfeOqp2s.jpg',
    alt: 'Test User',
    width: '50',
    height: '50',
  });
});

src/Card.tsx

import { ReactNode } from 'react';

export function Card(props: { children: ReactNode }) {
  return <div className="card">{props.children}</div>;
}

test/Card.test.tsx

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

test('Describing the UI, Card test', () => {
  render(<Card>{<h1>Hello World</h1>}</Card>);

  expect(screen.getByRole('heading')).toHaveTextContent('Hello World');
  expect(document.querySelector('.card')).toBeInTheDocument();
});

src/Profile2.tsx

import { Avatar } from './Avatar';
import { Card } from './Card';

export function Profile2() {
  return (
    <Card>
      <Avatar size={100} person={{ name: 'Katsuko Saruhashi', imageId: 'YfeOqp2' }} />
    </Card>
  );
}

test/Profile2.test.tsx

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

test('Describing the UI, Profile2 test', () => {
  render(<Profile2 />);

  expect(document.querySelector('.card')).toBeInTheDocument();
  expect(screen.getByAltText('Katsuko Saruhashi')).toBeInTheDocument();
});
Conditional rendering

Describing the UI / Conditional rendering

src/Item.tsx

type ItemProps = {
  name: string;
  isPacked: boolean;
};

export function Item(props: ItemProps) {
  return (
    <li className="item">
      {props.name} {props.isPacked && '✅'}
    </li>
  );
}

test/Item.test.tsx

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

afterEach(cleanup);

test('Describing the UI, Item test1', () => {
  render(<Item isPacked={true} name="Orange" />);

  expect(screen.getByRole('listitem')).toHaveClass('item');
  expect(screen.getByRole('listitem')).toHaveTextContent('Orange ✅');
});

test('Describing the UI, Item test2', () => {
  render(<Item isPacked={false} name="Apple" />);

  expect(screen.getByRole('listitem')).toHaveClass('item');
  expect(screen.getByRole('listitem')).toHaveTextContent('Apple');
});

src/PackingList.tsx

import { Item } from './Item';

export function PackingList() {
  return (
    <section>
      <h1>Sally Ride&apos;s Packing List</h1>
      <ul>
        <Item isPacked={true} name="Space suit" />
        <Item isPacked={true} name="Helmet with a golden leaf" />
        <Item isPacked={false} name="Photo of Tam" />
      </ul>
    </section>
  );
}

test/PackingList.test.tsx

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

test('Describing the UI, PackingList test', () => {
  render(<PackingList />);

  expect(screen.getByRole('heading')).toHaveTextContent("Sally Ride's Packing List");
  expect(screen.getAllByRole('listitem')).toHaveLength(3);
});

test/Item.test.tsxでは2回renderを使っているのですが、なにもしないと他のテストのDOMの状態が残ってしまうようです。

この状態をクリアするには、cleanupを呼び出すのだとか。

afterEach(cleanup);

API / cleanup

テストの最後に呼び出すように設定することが多そうなのですが、個人的には最初にやるのが好みという気が…。

Rendering lists

Describing the UI / Rendering lists

src/List.tsx

import { getImageUrl } from './utils';

type Person = {
  id: number;
  name: string;
  profession: string;
  accomplishment: string;
  imageId: string;
};

const people: Person[] = [
  {
    id: 0,
    name: 'Creola Katherine Johnson',
    profession: 'mathematician',
    accomplishment: 'spaceflight calculations',
    imageId: 'MK3eW3A',
  },
  {
    id: 1,
    name: 'Mario José Molina-Pasquel Henríquez',
    profession: 'chemist',
    accomplishment: 'discovery of Arctic ozone hole',
    imageId: 'mynHUSa',
  },
  {
    id: 2,
    name: 'Mohammad Abdus Salam',
    profession: 'physicist',
    accomplishment: 'electromagnetism theory',
    imageId: 'bE7W1ji',
  },
  {
    id: 3,
    name: 'Percy Lavon Julian',
    profession: 'chemist',
    accomplishment: 'pioneering cortisone drugs, steroids and birth control pills',
    imageId: 'IOjWm71',
  },
  {
    id: 4,
    name: 'Subrahmanyan Chandrasekhar',
    profession: 'astrophysicist',
    accomplishment: 'white dwarf star mass calculations',
    imageId: 'lrWQx8l',
  },
];

export function List() {
  const listItems = people.map((person) => (
    <li key={person.id}>
      <img src={getImageUrl(person)} alt={person.name} />
      <p>
        <b>{person.name}:</b>
        {' ' + person.profession + ' '}
        known for {person.accomplishment}
      </p>
    </li>
  ));

  return (
    <article>
      <h1>Scientists</h1>
      <ul>{listItems}</ul>
    </article>
  );
}

test/List.test.tsx

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

test('Describing the UI, List test', () => {
  render(<List />);

  expect(screen.getByRole('heading')).toHaveTextContent('Scientists');

  expect(screen.getByRole('list')).toBeInTheDocument();

  const listItems = screen.getAllByRole('listitem');
  expect(listItems).toHaveLength(5);

  expect(within(listItems[0]).getByRole('img').getAttribute('src')).toStrictEqual(
    'https://i.imgur.com/MK3eW3As.jpg',
  );
  expect(within(listItems[0]).getByRole('paragraph')).toHaveTextContent(
    'Creola Katherine Johnson: mathematician known for spaceflight calculations',
  );
  expect(within(listItems[0]).getByAltText('Creola Katherine Johnson')).toBeInTheDocument();
});

要素内の要素をさらにクエリーするには、withinを使うようです。

Querying Within Elements | Testing Library

今回はここまでにします。

「Keeping components pure」と「Your UI as a tree」は単独でやった方がよさそうです。

おわりに

Learn ReactのDescribing the UIを、Vitest、React Testing Library、jest-domを使ってやってみました。

書かれている内容を写経するだけなら困ることはないと思うのですが、ここにVitestやReact Testing Libraryを加えると
途端に難易度が跳ね上がり、特に環境整理にとてもとても苦労しました。

設定の意味をある程度押さえられるまで、ずっと設定ファイルを修正して試行錯誤していましたね。

それからUIコンポーネントのテストって難しいですね。難しいというかめんどうというか…。

どこまでアサーションしたらいいんだろう?とか、UIコンポーネント入れ子になっている時はどうテストするのが
いいんだろう?とか、スナップショットを使った方がいいんだろう?とか、気になることが多いです。

jest-domにも慣れた方がいいので、しばらく試行錯誤しながら慣れていこうと思います。