9日目: @testing-library/react-hooks を探訪

フロントエンドに疎い自分は、react hooks のテスト、未だに書いたことがない。

そこで手習いとして、以下の記事を参考に単体テストを書いてみることにした。

qiita.com

ということで今日は @testing-library/react-hooks を探訪する。

アプリケーション

まずは create-vite のボイラープレートでアプリケーションを作成する。

$ npm create vite@latest

✔ Project name: … hello-react
✔ Select a framework: › React
✔ Select a variant: › TypeScript + SWC

Scaffolding project in /path/to/hello-react...

Done. Now run:

  cd hello-react
  npm install
  npm run dev

セットアップして起動して動作を確認。さっそく src/App.tsxuseCounter を組み込む。

import { useState } from 'react';

type UseCounter = {
  count: number;
  increment: () => void;
  decrement: () => void;
};

export const useCounter = (initialValue: number): UseCounter => {
  const [count, setCount] = useState(initialValue);

  const increment = (): void => setCount(count + 1);
  const decrement = (): void => setCount(count - 1);

  return {
    count,
    increment,
    decrement,
  };
};
-import { useState } from 'react'
+import { useCounter } from './hooks/useCounter'
 import reactLogo from './assets/react.svg'
 import viteLogo from '/vite.svg'
 import './App.css'

 function App() {
-  const [count, setCount] = useState(0)
+  const { count, increment, decrement } = useCounter(0)

   return (
     <>
@@ -17,9 +17,13 @@ function App() {
         </a>
       </div>
       <h1>Vite + React</h1>
+      <h2>Counter: {count}</h2>
       <div className="card">
-        <button onClick={() => setCount((count) => count + 1)}>
-          count is {count}
+        <button onClick={() => { increment() }}>
+          Count++
+        </button>
+        <button onClick={() => { decrement() }}>
+          Count--
         </button>
         <p>
           Edit <code>src/App.tsx</code> and save to test HMR

動いた。

テスト

さっそくテストを書くのだが、まずは vitest をセットアップ。

$ npm i -D vitest

package.jsontest スクリプトを追加して、vitest.config.ts を追加。

/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
  plugins: [react()],
  test: {
    // ... Specify options here.
  },
})

src/hooks/useCounter.test.ts を追加。ここではまだ動作確認用のテストで済ませる。

import { describe, expect, test } from 'vitest';

describe('useCounter', () => {
  test('should increment', () => {
    expect(1).toBe(1);
  });
});

テスト実行。

$ npm run test

> hello-react@0.0.0 test
> vitest

 DEV  v0.34.6 /path/to/hello-react

 ✓ src/hooks/useCounter.test.ts (1)
   ✓ useCounter (1)
     ✓ should increment

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  21:12:55
   Duration  705ms (transform 95ms, setup 0ms, collect 30ms, tests 5ms, environment 0ms, prepare 227ms)

動いた。

本題: @testing-library/react-hooks

本題である @testing-library/react-hooks を導入する。

$ npm i -D @testing-library/react-hooks

...とインストールされるはずが依存関係が解決できずにエラーに。

どうも React 18.2 以上だとバージョン指定をしないとインストールできないらしい。

README を見ると、「置き換えてね」とある。素直に従う。

$ npm i -D @testing-library/react@^13.1

インストールできた。テストを追加する。

import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  test('increment を実行すると, count が 1 増える', () => {
    const { result } = renderHook(() => useCounter(100));
    expect(result.current.count).toBe(100);
    act(() => result.current.increment());
    expect(result.current.count).toBe(101);
  });

  test('decrement を実行すると, count が 1 減る', () => {
    const { result } = renderHook(() => useCounter(100));
    expect(result.current.count).toBe(100);
    act(() => result.current.decrement());
    expect(result.current.count).toBe(99);
  });
});

renderHookuseCounterレンダリングして、result.currentuseCounter の戻り値を取得できるようだ。 だが、このテストコードは動かない。 jsdom が必要だったのでインストールしてセットアップする。

$ npm i -D jsdom

vitest.config.tsjsdom を追加。

diff --git a/vitest.config.ts b/vitest.config.ts
index d97ef3e..229adb2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,5 +6,6 @@ export default defineConfig({
   plugins: [react()],
   test: {
     // ... Specify options here.
+    environment: 'jsdom',
   },
 })

これで動いた。

$ npm run test run

> hello-react@0.0.0 test
> vitest run

 RUN  v0.34.6 /path/to/hello-react

 ✓ src/hooks/useCounter.test.ts (2)
   ✓ useCounter (2)
     ✓ increment を実行すると, count が 1 増える
     ✓ decrement を実行すると, count が 1 減る

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  21:39:11
   Duration  1.84s (transform 67ms, setup 0ms, collect 304ms, tests 35ms, environment 615ms, prepare 161ms)

記事によると、テスト毎に cleanup するとよいらしい。最終的にテストコードは以下になった。

import { describe, expect, test, beforeEach } from 'vitest';
import { renderHook, act, cleanup } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  beforeEach(() => {
    cleanup();
  });

  test('increment を実行すると, count が 1 増える', () => {
    const { result } = renderHook(() => useCounter(100));
    expect(result.current.count).toBe(100);
    act(() => result.current.increment());
    expect(result.current.count).toBe(101);
  });

  test('decrement を実行すると, count が 1 減る', () => {
    const { result } = renderHook(() => useCounter(100));
    expect(result.current.count).toBe(100);
    act(() => result.current.decrement());
    expect(result.current.count).toBe(99);
  });
});

OSSのコード自体には触れてはいないけれど、今日もまたひとつ、良い探訪ができた。

create-vite のボイラープレート使って、さくっと素振りするの、本当に捗るわぁ...。

追記

useCounter の返り値がオブジェクト型なのが、 useState とは異なっていて違和感を憶えたのでちょっと書き直し。

   test('increment を実行すると, count が 1 増える', () => {
     const { result } = renderHook(() => useCounter(100));
-    expect(result.current.count).toBe(100);
-    act(() => result.current.increment());
-    expect(result.current.count).toBe(101);
+    expect(result.current[0]).toBe(100);
+    act(() => result.current[1]());
+    expect(result.current[0]).toBe(101);
   });

   test('decrement を実行すると, count が 1 減る', () => {
     const { result } = renderHook(() => useCounter(100));
-    expect(result.current.count).toBe(100);
-    act(() => result.current.decrement());
-    expect(result.current.count).toBe(99);
+    expect(result.current[0]).toBe(100);
+    act(() => result.current[2]());
+    expect(result.current[0]).toBe(99);
   });
 });

各要素をインデックスを使って参照しているけれど、果たしてこれで合っているのかはわからない。

ちなみに以下記事を読むに、配列でもオブジェクトでもどちらでもいいんじゃないか? ということが書かれていてなるほどなーとなった。

blog.ojisan.io

配列だと命名の自由度があるのは確かにそう。