Jotaiのatomを自由にテストしたいときに見る記事
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"
}
}
まずは基本のテスト方法(公式)から
まずは公式ページに書かれているテスト方法からです。最初に言っちゃいますが、この方法で満足できるなら次節からの内容は不要です。自分のやりたいテストがこの方法で書けるかどうかを気にしながら読んでみてください。
お約束のカウンターコンポーネントのテストからやっていきましょう。
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にしましょう。
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コンポーネント(やフック)はこのグラフの末端のノードです。
アプリのロジックが複雑になってくると、末端のコンポーネントと、自分がテストしたい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
をテストします。
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です。
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
です。
export const countUpAction = atom(undefined, (get, set) => {
set(countAtom, (c) => c + 1);
});
アクションの場合も基本は同じなのですが、いくつか面倒な部分があります。
countAtom
が派生atomの場合は↓これで問題ないのですが、今回の例のようにプリミティブなatomの更新をモックで拾おうとすると、うまくいかないことがあります。
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
までテスト対象に入れてしまう(=単にモックしないだけ)のが良いかと思います。
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
が現在のカウント値に応じた文字列を返します。
/** ベースのカウンターのプリミティブ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);
});
一応依存グラフの図も書いておきます:
今回はこの中で「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");
});
ただし、この方法はやり過ぎると「テスト専用のコード」が増えて実際の挙動との乖離が出やすくなりそうです。あまり厚くならないように気をつけて使いたいところです。
-
私はこれを大昔の新入社員時代に飲み会ゲームとして習ったのですが、本来は桂三度さんという方の持ちネタだそうです。このゲームがどこまで一般的なのかわからないので、誤りや失礼などありましたらお知らせください🙇 ↩︎
Discussion
桂三度さんは世界のナベアツの現在の芸名ですよ。落語家に転身されました。