Help us understand the problem. What is going on with this article?

テスト初心者がHooksのテストを書いた振り返りとハマった点について

はじめに

この記事はReact Advent Calendar 2019 12日目の記事です。
はじめまして、@bebeatro:hamster:と申します!最近流行りの未経験からエンジニアというやつで今年からエンジニアになったピカピカの1年生です:boy_tone1:

元々趣味でReact/Typescriptを触っていたのですが、趣味が高じて(+前職の色々なトラブルにより…)フロントエンドエンジニアに転職しました!:v:ただ、趣味と仕事の世界はなかなか異なりまして…:sob:
特にテストは、趣味の間は全く書かず、転職して1からなぜ書くのか、どのように書くのかを実戦と共に学んでいきました。
そんな中で吸収できたことやハマったポイントについて、振り返りも兼ねて記事にまとめたいなーっと考えていたところ、ちょうどReactアドベントカレンダーに枠が1つ空いていましたので、拙筆ではありますが、書かせていただきます!:pencil:

Hooksについて

みなさんHooks使っていますか??おそらくReactのアドベントカレンダーを見ている人は、ほぼ100%Hooksを使用していると思いますが、おさらいということで公式を参照してみますと、

フックとは、関数コンポーネントに state やライフサイクルといった React の機能を “接続する (hook into)” ための関数です。

以上のように定義されています。

そう関数なんですね!useStateのようなHooksを用いることで、ReactのFunctional Componentはステート管理を行うことが可能となり、useEffectのようなHooksを用いることでFunctional Componentでありながら、APIの呼び出しなどが可能となります。

さらにHooksの真価はカスタムフックにあります。これらのHooksや他の関数などを組み合わせることで、オリジナルのHooksを作ることが可能なのです。この簡単さと様々な機能を取り込める柔軟さは本当に革命的だと思います!:dancer:
カスタムフックのメリットについては、@saitoeku3さんが前日にちょうど実例込みでわかりやすく書いてくださってるので、そちらをご覧下さい!

なぜカスタムフックを作るのか

ただ、そんな便利なカスタムフックですがテストとなると少々面倒です:sweat:

カスタムフックのテストに係る問題

カスタムフックのテストにあたり1つ簡単なカスタムフックを書いてみます。

useCounter.ts
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で試してみます。

useCounter.test.ts
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の中で実行すればそりゃルール違反になります。それでは、どのようにテストを行うべきでしょうか?:thinking:

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の中で発火させている状態にします。
そして、renderHookresultオブジェクトを返します。
このresultオブジェクトはcurrentを有しており、これがカスタムフックの返り値と等しくなります。(今回の場合は、result.current === {counter, incrementCounter, decrementCounter}
したがって、今回のuseCounterの場合はresult.current.counterをexpectで比較することで変数の値をテストすることが可能になります!!:tada:

また、result.current.incrementCounterのような関数に関しても、@testing-library/react-hooksから同様にimportしているact関数の中で発火させればFunctional Component内で発火させるのと同様の結果が期待できます!
したがって、上記のテストコードのようにincrementCounterを一回発火させることによりcounterの数字が1となり、decrementCounterが発火することにより、0に戻ります。
いかがでしょうか??無事にカスタムフックのテストを行うことができました!:clap:

時間の流れを伴うカスタムフックについて

ここまでは@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を決めているだけですね。

App.tsx
/** @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つのカスタムフックのコードはそれぞれ

useFizzBuzz.ts
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;
  }
};

useChangeColor.ts
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なんかのテストにはぴったりですね!:smile:
続いて、useFizzBuzzのテストを行っていきます!

useFizzBuzzのテストとハマったポイント

useFizzBuzzは1秒毎に数字を1ずつカウントしていくsetIntervaluseEffectとそのカウントに応じてFizzBuzzを返すuseEffectの2つの機能があります。それでは、@testing-library/react-hooksでこのような時間の制御はどのように行うのでしょうか?直感的には以下のようなテストコードが考えれます。

useFizzBuzz.test.ts
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が返ってくるはずというテストですね。
実はこのテストは一見パスします!「お?いけるのか?:astonished:」と当初は考えたのですが、よくよく見てみると実はsetTimeout内を実行せずに終わっているんですね。したがって、評価する値がBuzzでもパスします。これはいけない!:no_good:

ここでハマってしまい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に変化させるとテストが失敗します!ちゃんと評価してくれているということですね!:thumbsup:
しかし、実はこの方法ではパスしているものの、jestがWarningを吐いてしまいました。
Warningの中身を見てみると... :eyes:

    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も発生しません:hugging:
無事にコンポーネントのロジック部分をHooksに逃し、コンポーネントの可読性とシンプルさを保ちつつ、テストでロジックの品質面も保証することができました!やったね!たえちゃん!:bear:

まとめ

ということで、Hooksのテストについて書かせていただきました。
私がテスト自体に不慣れなことと、Hooksのテストについて日本語ドキュメントが少ないことから、今回のような時間経過のケースでハマってしまい解消するのに結構時間がかかってしまいました…。
この記事が私のようにHooksのテストで悩んでいる方の一助になれば幸いです:bow:

Hooksめちゃくちゃ便利ですし、コミュニティ的にも活気がありまくるので未来しか感じてないです。また、今回紹介した@testing-library/react-hooksはライブラリのアップデートも盛んに行われていますし、今回は紹介できなかったのですが、async/awaitやContextのテストも簡単に書けます!
みなさんもどんどんカスタムフック書いて、どんどんテストを書いて、Hooksを盛り上げていきましょう!:confetti_ball:

最後に、私自身、まだまだペーペーエンジニアですので文章中の誤りなどありましたら、本記事かTwitterでご指摘いただけると助かります!
明日は@fsubalさんが「useSelectorを用いたリファクタリング」について書いてくれます。昨日、本日、明日とHooksが続いているあたり、やはりHooks熱いぜ…!:fire:

おまけ

@testing-library/react-hooksのドキュメントを見ると分かるのですが、実は今回のユースケースにぴったりのAPIは既にあります。しかし、実際にそのAPIを呼び出そうとするとTypescriptの場合、型定義がまだ無く呼び出すことができません。自分で型定義を作ればその限りではないと思いますが、今回のようなユースケースであればTimer Mocksのほうが早くお手軽に実行できるかなとは思います。
また、そのうち型定義がアップデートされれば本記事も不用になるかもですが、そのときはそのときでアップデートされた方法についてQiitaを書ければと思います!:muscle:

bebetaro
Typescript/Reactでフロントエンドやってます。 趣味でreact-nativeなんかも触りつつ、ネイティブにも知見を深めたいです!
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした