これは、なにをしたくて書いたもの?
最近少しReactを触っているのですが、いきなり書こうとしても「基礎力が全然足りないな?」と思うようになったので、
Reactの「Learn React」をやってみることにしました。
半分くらいは写経で、あまり説明を書いていくつもりはないのでエントリーとしてはとてもおもしろくないと思います。
写経外のことも含めて、自分のメモとしての利用が目的です。
Learn Reactをやってみる
Learn Reactとは、Reactのドキュメントのこのあたりの一連のコンテンツのことを指しています。
- Describing the UI – React
- Adding Interactivity – React
- Reacting to Input with State – React
- Referencing Values with Refs – React
書籍とかWebを見ていても実感が湧かないので、では書いてみるかとやってみたもののどうにも基礎力が足りないと思う
場面が増えてきたのでこちらをちゃんとやってみようかなと思った次第ですね。
なお、完全な写経ではさすがにおもしろくないので、以下の条件でやってみようと思います。
- 環境はViteで構築する
- TypeScriptで書く
- Vitest、React Testing Library、jest-domを使ったテストを書く
このあたりの周辺技術にも慣れようという狙いがあります。
Learn Reactにはそれなりにページ数があるので、全部のページをやろうとは思っていません。今回のスタンスで自分がやって
おいた方がよさそうだと思うページを対象にします。
また、内容的にはほぼ写経なので動作結果などはほとんど書きません。自分自身は確認しますが、結果自体はLearn 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向けの内容も含まれています。
テストを含めて設定を行う
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'], }, })
environmentはjsdoimを指定し、Node.js環境上でブラウザ関連のAPIが扱えるようにします。
テストで使うReact Testing Libraryはこのあたりが必要になるようなので。
vitest.setup.tsでは、テストコード向けのセットアップ内容を記述します。
vitest.setup.ts
import '@testing-library/jest-dom/vitest';
今回追加しているのは、Testing Libraryのjest-domのカスタムマッチャーを利用できるようにする設定です。
jest-domはDOMに関するJestのカスタムアサーションを追加するライブラリーですが、Vitest向けのサポートもあったりします。
jest-dom / Usage / With Vitest
手順ではVitestで使うtsconfig.jsonの設定で、typesにvitest/globals含めるようになっていますが、これとVitestのglobalsを
組み合わせることでJestのtestやexpectを明示的に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.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:watch": "vitest watch" },
Viteのテンプレートから追加したものは、previewより後ろですね。
では、準備ができたのでLearn ReactのDescribing the UIを進めていきましょう。
Learn ReactのDescribing the UIをやってみる
では、Learn ReactのDescribing the UIを進めていきます。
ここから先は、セクションごとにソースコード、テストコードの順に載せていきます。
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が入るかどうかで取得件数も変わります。
どのような条件でクエリーするかについては優先度の指針があり、まずはロールを使うのがよさそうです。
ロールとは、WAI-ARIAロールのことらしいです。
ちなみに動作確認は、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'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);
テストの最後に呼び出すように設定することが多そうなのですが、個人的には最初にやるのが好みという気が…。
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にも慣れた方がいいので、しばらく試行錯誤しながら慣れていこうと思います。