##はじめに
この記事はReact Advent Calendar 2019 12日目の記事です。
はじめまして、@bebeatroと申します!最近流行りの未経験からエンジニアというやつで今年からエンジニアになったピカピカの1年生です
元々趣味でReact/Typescriptを触っていたのですが、趣味が高じて(+前職の色々なトラブルにより…)フロントエンドエンジニアに転職しました!ただ、趣味と仕事の世界はなかなか異なりまして…
特にテストは、趣味の間は全く書かず、転職して1からなぜ書くのか、どのように書くのかを実戦と共に学んでいきました。
そんな中で吸収できたことやハマったポイントについて、振り返りも兼ねて記事にまとめたいなーっと考えていたところ、ちょうどReactアドベントカレンダーに枠が1つ空いていましたので、拙筆ではありますが、書かせていただきます!
##Hooksについて
みなさんHooks使っていますか??おそらくReactのアドベントカレンダーを見ている人は、ほぼ100%Hooksを使用していると思いますが、おさらいということで公式を参照してみますと、
フックとは、関数コンポーネントに state やライフサイクルといった React の機能を “接続する (hook into)” ための関数です。
以上のように定義されています。
そう関数なんですね!useState
のようなHooksを用いることで、ReactのFunctional Componentはステート管理を行うことが可能となり、useEffect
のようなHooksを用いることでFunctional Componentでありながら、APIの呼び出しなどが可能となります。
さらにHooksの真価はカスタムフックにあります。これらのHooksや他の関数などを組み合わせることで、オリジナルのHooksを作ることが可能なのです。この簡単さと様々な機能を取り込める柔軟さは本当に革命的だと思います!
カスタムフックのメリットについては、@saitoeku3さんが前日にちょうど実例込みでわかりやすく書いてくださってるので、そちらをご覧下さい!
ただ、そんな便利なカスタムフックですがテストとなると少々面倒です
##カスタムフックのテストに係る問題
カスタムフックのテストにあたり1つ簡単なカスタムフックを書いてみます。
import { useState, useCallback } from "react";
export const useCounter = () => {
const [counter, setCount] = useState<number>(0);
const incrementCounter = useCallback(() => setCount(counter + 1), [counter]);
const decrementCounter = useCallback(() => setCount(counter - 1), [counter]);
return { counter, incrementCounter, decrementCounter };
};
というわけで、このuseCounter
を用いることによりcounter
変数と、counterを増加させるincrementCount
関数、また減少させるdecrementCount
関数をコンポーネントは受け取ることができます。
それでは、このカスタムフックのテストを書いてみましょう。テストはJSのデファクトスタンダードであるjest
で試してみます。
import { useCounter } from './useCounter'
test('useCounter Hooks Test', () => {
const { counter } = useCounter()
expect(counter).toBe(0)
})
すると以下のようなエラーが出てきました
FAIL src/advent/hooks/useCounter.test.ts
✕ (3ms)
●
Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app
See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.
はい、Hooksを触ったことがある人なら一度は目にしたことがあるInvalid hooks call
ですね!
考えてみれば当然なわけで、HooksはFunctional Componentの中で使用しなければならないのに、jestの中で実行すればそりゃルール違反になります。それでは、どのようにテストを行うべきでしょうか?
##react-hooks-testing-libraryを使おう
というわけで、見出しが全てなのですが、react-hooks-testing-libraryを使えばまるっと解決です。react-hooks-testing-libraryは、
npm install --save-dev @testing-library/react-hooks
または
yarn add -D @testing-library/react-hooks
でちゃちゃっとインストールしちゃってください!
(なおreact-hooks-testing-libraryは、npm上では@testing-library/react-hooks
という名前なので、以後こちらの名前を使います)
そして、@testing-library/react-hooks
を使ったテストコードは以下のようになります。
import { renderHook, act } from "@testing-library/react-hooks";
import { useCounter } from "./useCounter";
test("useCounterフックのテスト", () => {
const { result } = renderHook(() => useCounter());
expect(result.current.counter).toBe(0);
act(() => {
result.current.incrementCounter();
});
expect(result.current.counter).toBe(1);
act(() => {
result.current.decrementCounter();
});
expect(result.current.counter).toBe(0);
});
renderHook
関数の中で、カスタムフックを発火させることにより、擬似的にカスタムフックをFunctional Componentの中で発火させている状態にします。
そして、renderHook
はresult
オブジェクトを返します。
このresult
オブジェクトはcurrent
を有しており、これがカスタムフックの返り値と等しくなります。(今回の場合は、result.current === {counter, incrementCounter, decrementCounter}
)
したがって、今回のuseCounter
の場合はresult.current.counter
をexpectで比較することで変数の値をテストすることが可能になります!!
また、result.current.incrementCounter
のような関数に関しても、@testing-library/react-hooks
から同様にimportしているact
関数の中で発火させればFunctional Component内で発火させるのと同様の結果が期待できます!
したがって、上記のテストコードのようにincrementCounter
を一回発火させることによりcounter
の数字が1となり、decrementCounter
が発火することにより、0に戻ります。
いかがでしょうか??無事にカスタムフックのテストを行うことができました!
##時間の流れを伴うカスタムフックについて
ここまでは@testing-library/react-hooks
のドキュメントを見れば、すぐに分かる内容です。ここからは、より実践的なカスタムフックについてのテストを書こうと思います!
サンプルアプリとしてFizzBuzzカラータイマーというアプリを作成しました
GitHub Pages
https://bebetaro.github.io/2019QiitaReactAdvent/
GitHub
https://github.com/bebetaro/2019QiitaReactAdvent
シンプルな秒ごとにカウントするタイマー機能にFizzBuzzを組み合わせまして、タイマーが3の倍数のときにはFizzとなり文字色が赤になり、5の倍数ではBuzzのとなり青、そして15がFizzBuzzで緑色になる。それだけです。
メインとなるコンポーネントは以下のような構成になっています。
単純にカスタムフックを呼び出し、色と現在のFizzBuzzを決めているだけですね。
/** @jsx jsx */
import { jsx } from "@emotion/core";
import React from "react";
import { useChangeColor, useFizzBuzz } from "./hooks";
import { timer, root } from "./style";
const App: React.FC = () => {
const value = useFizzBuzz();
const color = useChangeColor(value);
return (
<div css={root}>
<h1 css={timer(color)}>Fizz Buzz Color Timer</h1>
<div>{value}</div>
</div>
);
};
export default App;
この2つのカスタムフックのコードはそれぞれ
import { useState, useEffect } from "react";
import { FizzBuzz } from "../type";
export const useFizzBuzz = () => {
const [counter, setCounter] = useState<number>(0);
const [value, setValue] = useState<FizzBuzz>(0);
// counterを1秒に1ずつ増やしていくuseEffect
useEffect(() => {
const timeID = setInterval(() => {
setCounter(prevCounter => prevCounter + 1);
}, 1000);
return () => clearInterval(timeID);
}, []);
//現在の数字に応じて、数字かFizzBuzzがセットされる
useEffect(() => {
const newValue = fizzBuzzGenerator(counter);
setValue(newValue);
}, [counter]);
return value;
};
const fizzBuzzGenerator = (args: number): FizzBuzz => {
if (args === 0) {
return args;
} else if (args % 15 === 0) {
return "FizzBuzz";
} else if (args % 5 === 0) {
return "Buzz";
} else if (args % 3 === 0) {
return "Fizz";
} else {
return args;
}
};
import { useState, useEffect } from "react";
import { FizzBuzz, ColorProps } from "../type";
export const useChangeColor = (value: FizzBuzz) => {
const [color, setColor] = useState<ColorProps>("black");
//FizzBuzzと数字に応じて色を決めています
useEffect(() => {
if (typeof value === "number") {
setColor("black");
} else {
switch (value) {
case "Fizz":
setColor("red");
break;
case "Buzz":
setColor("blue");
break;
case "FizzBuzz":
setColor("green");
break;
default:
setColor("black");
}
}
}, [value]);
return color;
};
となっています。
useFizzBuzz
にて数字かFizzBuzzかを表示するかを決め、その値をuseChangeColor
に渡すことで、変化する色を決めている感じです。
それでは、このカスタムフックのテストを書いていこうと思います!
##useChangeColorのテスト
まずは簡単なuseChangeColor
のテストは以下のようになります。
import { renderHook } from "@testing-library/react-hooks";
import { useChangeColor } from "./useChangeColor";
import { FizzBuzz } from "../type";
test("useChangeColorフックのテスト", () => {
let initialValue: FizzBuzz = 0;
const { result, rerender } = renderHook(() => useChangeColor(initialValue));
expect(result.current).toBe("black");
initialValue = "FizzBuzz";
rerender();
expect(result.current).toBe("green");
initialValue = "Buzz";
rerender();
expect(result.current).toBe("blue");
initialValue = "Fizz";
rerender();
expect(result.current).toBe("red");
});
ポイントはresult
と一緒に渡されているrerender
になります!
rerender
を発火させることで、renderHook
に渡しているカスタムフックに文字通り再レンダーがかかります。これにより、useEffect
がdepsを再評価し、値が異なっていれば再度実行されます。テストでもinitialValue
の変化に対して期待通りの結果が得られていますね!useEffectなんかのテストにはぴったりですね!
続いて、useFizzBuzz
のテストを行っていきます!
useFizzBuzzのテストとハマったポイント
useFizzBuzz
は1秒毎に数字を1ずつカウントしていくsetInterval
のuseEffect
とそのカウントに応じてFizzBuzzを返すuseEffect
の2つの機能があります。それでは、@testing-library/react-hooks
でこのような時間の制御はどのように行うのでしょうか?直感的には以下のようなテストコードが考えれます。
import { renderHook } from "@testing-library/react-hooks";
import { useFizzBuzz } from "./useFizzBuzz";
test("useFizzBuzzフックのテスト失敗例1", () => {
const { result } = renderHook(() => useFizzBuzz());
expect(result.current).toBe(0);
setTimeout(() => {
expect(result.current).toBe("Fizz");
}, 3000);
});
setTimeout
で3秒後にexpectで評価を行えば、カウントが3だからFizzが返ってくるはずというテストですね。
実はこのテストは一見パスします!「お?いけるのか?」と当初は考えたのですが、よくよく見てみると実はsetTimeout
内を実行せずに終わっているんですね。したがって、評価する値がBuzzでもパスします。これはいけない!
ここでハマってしまいsetTimeout
をjestではどう処理しているのかグーグル先生に聞いてみたところ、jest公式ページにぴったりなTimer Mockについての記述がありました!
そこでjestの公式サンプルに沿って改善してみました!
import { renderHook } from "@testing-library/react-hooks";
import { useFizzBuzz } from "./useFizzBuzz";
test("useFizzBuzzフックのテスト失敗例2", () => {
const { result } = renderHook(() => useFizzBuzz());
expect(result.current).toBe(0);
// jest.advanceTimersByTimerが指定時間分jestの中の時間を進めます
jest.advanceTimersByTime(3000);
expect(result.current).toBe("Fizz");
});
これもパスしました!しかも、expect(result.current).toBe("Fizz")
の評価する値をBuzzや3に変化させるとテストが失敗します!ちゃんと評価してくれているということですね!
しかし、実はこの方法ではパスしているものの、jestがWarningを吐いてしまいました。
Warningの中身を見てみると...
Warning: An update to TestHook inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act
in TestHook
in Suspense
そうなんです!今回のような時間経過であれカスタムフックのstateをアップデートさせるものは全てact
の中で行う必要があるんですね!これが最後のピースです。このact
を反映させたテストが以下になります。
import { renderHook, act } from "@testing-library/react-hooks";
import { useFizzBuzz } from "./useFizzBuzz";
test("useFizzBuzzフックの成功例", async () => {
const { result } = renderHook(() => useFizzBuzz());
expect(result.current).toBe(0);
//3秒経過 合計3なのでFizzになるはず
act(() => {
jest.advanceTimersByTime(3000);
});
expect(result.current).toBe("Fizz");
//2秒経過 合計5なのでBuzzになるはず
act(() => {
jest.advanceTimersByTime(2000);
});
expect(result.current).toBe("Buzz");
//10秒経過 合計15なのでFizzBuzzになるはず
act(() => {
jest.advanceTimersByTime(10000);
});
expect(result.current).toBe("FizzBuzz");
//1秒経過 合計16なので16になるはず
act(() => {
jest.advanceTimersByTime(1000);
});
expect(result.current).toBe(16);
});
これでテストもパスしてWarningも発生しません
無事にコンポーネントのロジック部分をHooksに逃し、コンポーネントの可読性とシンプルさを保ちつつ、テストでロジックの品質面も保証することができました!やったね!たえちゃん!
##まとめ
ということで、Hooksのテストについて書かせていただきました。
私がテスト自体に不慣れなことと、Hooksのテストについて日本語ドキュメントが少ないことから、今回のような時間経過のケースでハマってしまい解消するのに結構時間がかかってしまいました…。
この記事が私のようにHooksのテストで悩んでいる方の一助になれば幸いです
Hooksめちゃくちゃ便利ですし、コミュニティ的にも活気がありまくるので未来しか感じてないです。また、今回紹介した@testing-library/react-hooks
はライブラリのアップデートも盛んに行われていますし、今回は紹介できなかったのですが、async/awaitやContextのテストも簡単に書けます!
みなさんもどんどんカスタムフック書いて、どんどんテストを書いて、Hooksを盛り上げていきましょう!
最後に、私自身、まだまだペーペーエンジニアですので文章中の誤りなどありましたら、本記事かTwitterでご指摘いただけると助かります!
明日は@fsubalさんが「useSelectorを用いたリファクタリング」について書いてくれます。昨日、本日、明日とHooksが続いているあたり、やはりHooks熱いぜ…!
##おまけ
@testing-library/react-hooks
のドキュメントを見ると分かるのですが、実は今回のユースケースにぴったりのAPIは既にあります。しかし、実際にそのAPIを呼び出そうとするとTypescriptの場合、型定義がまだ無く呼び出すことができません。自分で型定義を作ればその限りではないと思いますが、今回のようなユースケースであればTimer Mocksのほうが早くお手軽に実行できるかなとは思います。
また、そのうち型定義がアップデートされれば本記事も不用になるかもですが、そのときはそのときでアップデートされた方法についてQiitaを書ければと思います!