👻

Jotaiのatomを自由にテストしたいときに見る記事

2024/09/29に公開

Jotaiのテスト方法に関する記事があんまりないので書きました。
公式ドキュメントにもテストに関するページはあるのですが、わりとあっさりしていて実際テストしようと思うと手探り感が強いです。
この記事では、公式の内容に加えて、Reactに依存せず必要なatomのみをテストする方法をまとめます。

環境&バージョン

viteのテンプレでReactのアプリを作って、JotaiとVitestを入れます。すべてテンプレのデフォルトまたは執筆時の最新版です。そのほかlinter等(biome, eslint)は好きに調整してください

※ 後述しますが、テストのやり方によってはここまでフルセットに色々入れる必要はないこともあります。ここに書いたのこの記事で書かれているテストを動かすための全部入り構成です。

{
  "dependencies": {
    "jotai": "^2.10.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.2",
    "@testing-library/react": "^16.0.1",
    "@testing-library/user-event": "^14.5.2",
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.1",
    "jsdom": "^25.0.1",
    "typescript": "^5.5.3",
    "vite": "^5.4.1",
    "vitest": "^2.1.1"
  }
}

まずは基本のテスト方法(公式)から

まずは公式ページに書かれているテスト方法からです。最初に言っちゃいますが、この方法で満足できるなら次節からの内容は不要です。自分のやりたいテストがこの方法で書けるかどうかを気にしながら読んでみてください。

お約束のカウンターコンポーネントのテストからやっていきましょう。

Counterコンポーネント
import { atom, useAtom } from "jotai";

/** カウンターのatom(Primitive) */
const countrAtom = atom(0);

/** カウンターのコンポーネント */
export const Counter = () => {
  const [count, setCount] = useAtom(countrAtom);

  return (
    <div>
      <h1>Count={count}</h1>
      <button type="button" onClick={() => setCount((c) => c + 1)}>
        CountUp
      </button>
    </div>
  );
};
テスト
test("CountUpボタンを1度クリックするとカウントは1になる", async () => {
  // Arrange
  render(<Provider><Counter /></Provider>);
  const counter = screen.getByText("Count=0");
  const incrementButton = screen.getByText("CountUp");
  // Act
  await userEvent.click(incrementButton);
  // Assert
  expect(counter.textContent).toEqual("Count=1");
});

普通と言えば普通ですね。公式のサンプルとほぼ同じです。
1点気をつけたいのが<Provider><Counter /></Provider>のように<Provider>で囲んでいることです。これがないと複数のテストをまたがってatomの値が共有されてしまうため、「テストを追加したら失敗する」みたいなイヤな動きになります。狙ってこれをやりたいことはそんなにないと思うので、常につけておくのが良いかと思います。

というわけで、無事にatomのテストができました✨
ただ「0が入ってたatomに1足したら1になった」だと「atomのテストってなんだろうか?」という疑問が湧いてきます。なので、もうちょっと複雑にしてみましょう。

派生atomやアクションatomも含めてテストする

もうちょっと複雑にするために、カウンターの表示をただの数字ではなくFizzBuzzにしてみます。
countAtomの派生atomとしてfizzBuzzAtomを作ります。
ついでに、カウンターの数字も勝手にいじられたらイヤなので、countUpActionというアクションatomにしましょう。

カウントをFizzBuzzで返すカウンター
import { atom, useAtomValue, useSetAtom } from "jotai";

/** ベースのカウンターのプリミティブatom */
const countAtom = atom(0);

/** カウンターを1増やすアクション */
const countUpAction = atom(undefined, (get, set) => {
  set(countAtom, (c) => c + 1);
});

/** 現在のカウントをFizzBuzzで提供する派生atom */
const fizzBuzzAtom = atom((get) => {
  const count = get(countAtom);
  if (count % 15 === 0) return "FizzBuzz";
  if (count % 3 === 0) return "Fizz";
  if (count % 5 === 0) return "Buzz";
  return `${count}`;
});

export const FizzBuzzCounter = () => {
  const fizzBuzzCount = useAtomValue(fizzBuzzAtom);
  const countUp = useSetAtom(countUpAction);

  return (
    <div>
      <h1>FizzBuzzCounter</h1>
      <p>Count={fizzBuzzCount}</p>
      <button type="button" onClick={countUp}>
        CountUp
      </button>
    </div>
  );
};

このFizzBuzzCounterのテストはこんな感じでしょうか...

テスト
test("CountUpボタンを3度クリックするとカウントはFizzになる", async () => {
  // Arrange
  render(
    <Provider>
      <FizzBuzzCounter />
    </Provider>,
  );
  const counter = screen.getByText("Count=0");
  const incrementButton = screen.getByText("CountUp");
  // Act
  await userEvent.click(incrementButton);
  await userEvent.click(incrementButton);
  await userEvent.click(incrementButton);
  // Assert
  expect(counter.textContent).toEqual("Count=Fizz");
});

ばっちりですね。冒頭にも書きましたが、これで満足できるのであればこれでおしまいです。実際のユーザーの操作に合わせて見えている通りにテストするのは、テストの原則としても正しいはずです。

ただ、それでも疑問が残る人もいるはずです

  • 🐱 私がテストしたいのはReactコンポーネントのUIではなく、atomのロジックなんだけど…
  • 🐱 1つのコンポーネントをテストしようとしたら芋づるで色んなatomがついてきて、何をテストしてるのかわかんなくなるんだけど…

どちらもある程度複雑なアプリをJotai中心に組み上げていると出てくる疑問だと思います。そもそもJotaiを使う大きなモチベーションの一つが「アプリケーションのロジックをReactのコンポーネントツリー(フックを含む)から切り離したい」というものなので、これに沿うならばテストだってReactの外に出したくなるのは当然です。

Reactを使わずにatomだけをテストする

ということで、やりたいことが少し見えてきました。実装に移る前にもうちょっとだけ理屈を整理します。
JotaiのatomはReactのコンポーネントツリーとは別の世界として、atomの依存関係を表すグラフを持ちます(派生atomは複数の親を持てるので、ツリーではなくグラフになります)。あえて描き足すなら、Reactコンポーネント(やフック)はこのグラフの末端のノードです。

図:fizzBuzzAtom周辺の依存グラフ

アプリのロジックが複雑になってくると、末端のコンポーネントと、自分がテストしたいatomの距離がどんどん離れていきます。これを常に「見えているコンポーネントを操作して」テストするのはしんどいですね。

図:もっと複雑なatomの依存グラフ

つまり、ここから先私たちが知りたいのは「atomの依存グラフの好きな一部分だけを切り出してテストする方法」です。

単一のatomをテストする:プリミティブなatom

前置きが長くなりました。早速、一番シンプルなものから始めます。まずは単一のプリミティブなatomからです。

↓ これだけをテストします

export const countAtom = atom(0);

テストはこんな感じで書けます:

test("カウンターの初期値は0", () => {
  const store = createStore();
  expect(store.get(countAtom)).toBe(0);
});

test("カウンターに値をセットしたらその値が取得できる", () => {
  const store = createStore();
  store.set(countAtom, 42);
  expect(store.get(countAtom)).toBe(42);
});

冒頭のあまり意味を感じられないテストと同じですが、ここではReactを使わずにatomのみをテストできていることに注目してください。

単一のatomをテストする:派生atom

次は派生atomもテストしてみましょう。ここでは先ほどのfizzBuzzAtomをテストします。

fizzBuzzAtom(再掲。exportだけ追加)
export const fizzBuzzAtom = atom((get) => {
  const count = get(countAtom);
  if (count === 0) return `${count}`;
  if (count % 15 === 0) return "FizzBuzz";
  if (count % 3 === 0) return "Fizz";
  if (count % 5 === 0) return "Buzz";
  return `${count}`;
});

このatomを単体でテストするには、依存atomであるcountAtomをモックする必要があります。
※ この例程度であれば、もちろんモックするよりcountAtomそのものを使った方が簡単ですが、この説の趣旨は「atomの依存グラフの任意の部分のみをテストする方法」なので、頑張ってモックします。

といっても、モック自体は難しくありません。派生atomは雑にいうと「read関数やwrite関数の生えたオブジェクト」に過ぎないので、vi.spyOnでスパイを作ってreadの戻り値を書き換えればOKです。

countAtomをモックしてfizzBuzzAtomだけをテストする
test("カウントが10ならFizzBuzzはBuzz", () => {
  const store = createStore();
  // countAtomをスパイ
  const countAtomSpy = vi.spyOn(countAtom, "read");
  // 読み取り時に常に10を返す
  countAtomSpy.mockReturnValue(10);
  expect(store.get(fizzBuzzAtom)).toBe("Buzz");
  // モックをリセット
  countAtomSpy.mockRestore();
});

単一の派生atomをテストする意味があるかどうかはケースバイケースです。
今回のfizzBuzzAtomに関しては、「FizzBuzzを求める処理」だけを純粋な関数に切り出してテストしてしまえば、fizzBuzzAtomのテストを行う意味はあまりない気もします。
こちらも前節同様、この後のための書き方の解説として理解してください。

単一のatomをテストする:アクションatom

アクションatomもテストしておきましょう。ターケットはcountUpActionです。

カウントアップするアクションatom(再掲。exportだけ追加)
export const countUpAction = atom(undefined, (get, set) => {
  set(countAtom, (c) => c + 1);
});

アクションの場合も基本は同じなのですが、いくつか面倒な部分があります。
countAtomが派生atomの場合は↓これで問題ないのですが、今回の例のようにプリミティブなatomの更新をモックで拾おうとすると、うまくいかないことがあります。

countAtomをモックしてcountUpActionのみをテストする
test("カウントアップするとカウンタの値が1増える", () => {
  const store = createStore();
  // モックして初期値を1にする
  const countAtomReadSpy = vi.spyOn(countAtom, "read");
  countAtomReadSpy.mockReturnValue(1);
  // countAtomの書き込みをスパイ
  const countAtomWriteSpy = vi.spyOn(countAtom, "write");
  // カウントアップ
  store.set(countUpAction);
  // countAtomの書き込みが引数(get, set, 2)で呼ばれたことを確認
  expect(countAtomWriteSpy).toHaveBeenCalledWith(
    expect.any(Function), // <-- get
    expect.any(Function), // <-- set
    2 // <-- これを確認したい
  );
});

ご存知の通り、プリミティブなatomの更新には以下の2通りがあるので、write関数を呼ぶ場合の引数も異なるためです。

// ① getしてsetする
const c = get(countAtom);
set(countAtom, c + 1); // 上の例はこっちならうまくいく

// ② 現在の値を受け取って更新値を返す関数を渡す
set(countAtom, (c) => c + 1); // こっちだとダメ

前述した通り、プリミティブなatomをわざわざモックする意義は薄いので、この場合は素直にcountAtomまでテスト対象に入れてしまう(=単にモックしないだけ)のが良いかと思います。

countAtomはモックせずにcountUpActionをテストする
test("カウントアップするとカウンタの値が1増える", () => {
  const store = createStore();
  // 初期値を取得
  const before = store.get(countAtom);
  // カウントアップ
  store.set(countUpAction);
  // カウント後の値を取得
  const after = store.get(countAtom);
  // 1増えていることを確認
  expect(after).toBe(before + 1);
});

こっちの方が圧倒的にいいですね。
とは言え、アクションの結果がプリミティブなatomに即繋がるケースばかりではないはずです。いくつもアクションが連鎖的に呼ばれる構造の中の一部だけをテストしたい、という場合には最初に挙げたようにatomのwriteをモックして、toHaveBeenCalledWithで次のアクションの呼び出しをチェックする、といったパターンも覚えておくと良いと思います。

いくつかのatomの塊をテストする

長々書いてきましたが、ようやく意味のあるパートです。
ここまでのパターンを組み合わせれば、atomの依存グラフの任意の部分だけを自由に切り取ってテストすることが可能になります。

ここまでの例はそもそもatomが3つしか出てこないので、最後にもう少し複雑にしてやってみましょう。

長いのでコードはちゃんと読まなくていいです。この例では今までのFizzBuzzに加えて「世界のナベアツゲーム」を実装しました。「世界のナベアツゲーム」はFizzBuzzの日本版のようなゲームで、「3の倍数と3が付く数字だけアホ(🤪)になる」ものです。[1]
gameModeAtomでFizzBuzzと「世界のナベアツゲーム」を切り替えることで、gameCounterAtomが現在のカウント値に応じた文字列を返します。

(読まなくていいよ)FizzBuzzと「世界のナベアツ」を切り替えてプレイできる機能追加
/** ベースのカウンターのプリミティブatom */
export const countAtom = atom(0);
/** ゲームモードのプリミティブatom */
export const gameModeAtom = atom<"fizzbuzz" | "nabeatsu">("fizzbuzz");

/** カウンターを1増やすアクション */
export const countUpAction = atom(undefined, (get, set) => {
  const c = get(countAtom);
  set(countAtom, c + 1);
});

/** 現在のカウントをFizzBuzzで提供する派生atom */
export const fizzBuzzAtom = atom((get) => {
  const count = get(countAtom);
  if (count === 0) return `${count}`;
  if (count % 15 === 0) return "FizzBuzz";
  if (count % 3 === 0) return "Fizz";
  if (count % 5 === 0) return "Buzz";
  return `${count}`;
});

/** 
 * 現在のカウントを世界のナベアツで提供する派生atom
 * ※ 3の倍数と3が付く数字でアホ🤪になる
 */
export const nabeatsuAtom = atom((get) => {
  const count = get(countAtom);
  if (count === 0) return `${count}`;
  if (count % 3 === 0 || count.toString().includes("3")) return "🤪";
  return `${count}`;
});

/** ゲームモードに従ってカウンターの表示を提供する派生atom */
export const gameCounterAtom = atom((get) => {
  const gameMode = get(gameModeAtom);
  return gameMode === "fizzbuzz" ? get(fizzBuzzAtom) : get(nabeatsuAtom);
});

一応依存グラフの図も書いておきます:

図:世界のナベアツゲームを追加したatomの依存グラフ

今回はこの中で「gameModeAtomを切り替えることでgameCounterAtomがゲームの設定に応じた文字列を返すこと」をを確認しましょう。

つまり、こういうテストです:

test("ゲームモードがfizzbuzzならFizzBuzzの結果が返る", () => {
  // TODO
});

test("ゲームモードがnabeatsuなら「世界のナベアツ」の結果が返る", () => {
  // TODO
});

留意したいのは、今回のテストではあくまでゲームのモードに応じた文字列が返ることをテストしたいのであってFizzBuzzや「世界のナベアツ」自体はどうでも良いことです。FizzBuzzのルールが変わったり「アホ」の絵文字が変わったりしてもテストが壊れてはいけません。

前置きが長くなりましたが、今までのパターンを組み合わせれば、このテストは以下のように書けます。

test("ゲームモードがfizzbuzzならFizzBuzzの結果が返る", () => {
  const store = createStore();
  // fizzBuzzAtomをスパイして値を"FizzBuzz"に固定
  const fizzBuzzAtomSpy = vi.spyOn(fizzBuzzAtom, "read");
  fizzBuzzAtomSpy.mockReturnValue("FizzBuzz");
  // nabeatsuAtomをスパイして値を"Nabeatsu"に固定
  const nabeatsuAtomSpy = vi.spyOn(nabeatsuAtom, "read");
  nabeatsuAtomSpy.mockReturnValue("Nabeatsu");

  // ゲームモードを"fizzbuzz"に変更
  store.set(gameModeAtom, "fizzbuzz");
  // FizzBuzzの結果が返ることを確認
  expect(store.get(gameCounterAtom)).toBe("FizzBuzz");

  // リセット
  fizzBuzzAtomSpy.mockRestore();
  nabeatsuAtomSpy.mockRestore();
});

もちろん、どの範囲をテストするかはプログラムのモジュール分割やプロジェクトにおけるユニットテストの位置付けによっても変わってくるので正解はありません。ここまでのパターンを組み合わせて、目的にあったテストを自由に書いてください。

補足:テスト用のカスタムフックを作ってatomをテストする

最後に、もう一つの選択肢としてカスタムフックを作ってテストする方法も書いておきます(これは公式ドキュメントにもある方法です)。
基本的にはatomを直接テストするのと変わらないのですが、他のカスタムフックと組み合わせてテストを書きたいときには有用だと思います。

フックを使ってatomをテストする最小例は以下のようなものです。

test("FizzBuzzの初期値は'0'", () => {
  const { result } = renderHook(() => useAtomValue(fizzBuzzAtom), { wrapper: Provider });
  expect(result.current).toBe("0");
});

useAtomValue(またはuseAtom, useSetAtom等)をrenderHookで描画し、結果をチェックします。冒頭の例と同様、<Provider />で囲まないといけないので、この場合はrenderHookの第二引数にwrapperの指定を追加して対応します。

とは言え、実際には単体のatomだけをこの方法でテストしたいケースは限られる(あまり意味がない)はずです。以下のようにテスト対象をまとめたカスタムフックを用意し、これをrenderHookで描画するのが汎用的かと思います。

// テスト用のカスタムフック
const useFizzBuzz = () => {
  // テストしたい・テストで必要なカスタムフック (jotai以外のものも必要に応じて)
  const countUp = useSetAtom(countUpAction);
  const fizzBuzz = useAtomValue(fizzBuzzAtom);
  return { countUp, fizzBuzz };
};

test("カウントアップを呼ぶとカウントが増えてFizzBuzzが返る", () => {
  const { result } = renderHook(() => useFizzBuzz(), { wrapper: Provider });
  // 初期値は"0"
  expect(result.current.fizzBuzz).toBe("0");
  // カウントアップx3
  act(() => {
    result.current.countUp();
    result.current.countUp();
    result.current.countUp();
  });
  // カウントが3増えて"Fizz"が返る
  expect(result.current.fizzBuzz).toBe("Fizz");
});

ただし、この方法はやり過ぎると「テスト専用のコード」が増えて実際の挙動との乖離が出やすくなりそうです。あまり厚くならないように気をつけて使いたいところです。

脚注
  1. 私はこれを大昔の新入社員時代に飲み会ゲームとして習ったのですが、本来は桂三度さんという方の持ちネタだそうです。このゲームがどこまで一般的なのかわからないので、誤りや失礼などありましたらお知らせください🙇 ↩︎

Discussion