React は JavaScript のライブラリの一つであり UI をコンポーネントベースで作るためのものです。React の中にはフックという仕組みがあり、これを使うとロジックとコンポーネントによる画面定義を分離しやすくなりコードの見通しをよくしやすいです。
フックの導入 – React
フック早わかり – React
ロジックを抜き出したものがフックです。ロジックのみということでコマンドラインテストが有効であり、Jest(JavaScript のテストツール。高速さとシンプルさが売りです)等を用いたテストをしたくなる時が少なくありません。
Jest · 🃏 Delightful JavaScript Testing
Jest でテストをする時の典型的な例として次が挙げられます。
テスト対象のソースコード
/** * 配列を要素 n 個の複数の小さい配列に分割。余りは最後の配列に入れる(最後の配列だけ n より小さいことがある)。 * @param {any[]} arr * @param {Number} n * @return {any[][]} */ export const splitArrayByEqualSize = <T>(arr: T[], n: number): T[][] => { return arr.reduce((pre: T[][], c: T, i): T[][] => (i % n ? pre : [...pre, arr.slice(i, i + n)]), []); }; /** * 任意桁数での四捨五入 * @param numberArg * @param precisionArg */ const round = (numberArg: number, precisionArg: number): number => { const shift = (number: number, precision: number, reverseShift: boolean) => { if (reverseShift) { precision = -precision; } const numArray = ('' + number).split('e'); return +(numArray[0] + 'e' + (numArray[1] ? +numArray[1] + precision : precision)); }; return shift(Math.round(shift(numberArg, precisionArg, false)), precisionArg, true); }; /** * PHP の number_format を JavaScript で実装 * @see ドキュメント https://www.php.net/manual/ja/function.number-format.php * @see 本家 https://github.com/php/php-src/blob/07fa13088e1349f4b5a044faeee57f2b34f6b6e4/ext/standard/math.c#L1011 * @param {Number|String} num 数値を表現する文字列でもOK * @param {Number|String} decimals 数値を表現する文字列でもOK * @param {String} decimalSeparator * @param {String} thousands_separator */ function numberFormat(num: number | string | boolean, decimals = 0, decimalSeparator = '.', thousands_separator = ','): string { let i; num = +num; decimals = +decimals < 0 ? 0 : decimals; // 少数 // 文字列で数値を構築(誤差対策も兼ねます) let strnum: string = round(num, decimals).toString(); let addZero = ''; if (!strnum.toString().includes('.')) { if (decimals > 0) { addZero += '.'; } for (i = 0; i < decimals; i++) { addZero += '0'; } } else { const decimal = strnum.toString().split('.')[1]; for (i = 0; i < decimals - decimal.length; i++) { addZero += '0'; } } strnum = `${strnum}${addZero}`.replace('.', decimalSeparator); // 小数点を置き換え // 千の位区切り let sign = ''; // 後の文字列操作で符号が邪魔なので避難 if (num < 0) { strnum = strnum.slice(1); sign = '-'; } const integerSide: string[] = strnum.split(decimalSeparator)[0].split(''); // 整数部を配列形式で抜き出し const integerSideWithComma = splitArrayByEqualSize<string>(integerSide.reverse(), 3) // 1の位から数えるためにchar[]を反転 .map((t) => t.join('')) // 3桁ずつまとめる .join(thousands_separator) .split('') .reverse() // 文字列に復元 .join(''); return [sign, integerSideWithComma, decimals === 0 ? '' : decimalSeparator, strnum.split(decimalSeparator)[1]].join(''); }
// テストコード describe('number_helper', () => { test('number_format', () => { expect(numberFormat(1234.5678)).toBe('1,235'); expect(numberFormat(-1234.5678)).toBe('-1,235'); expect(numberFormat(1234.6578e4)).toBe('12,346,578'); expect(numberFormat(-1234.56789e4)).toBe('-12,345,679'); expect(numberFormat(0x1234cdef)).toBe('305,450,479'); expect(numberFormat(0o2777777777)).toBe('402,653,183'); expect(numberFormat('123456789')).toBe('123,456,789'); expect(numberFormat('123.456789')).toBe('123'); expect(numberFormat('12.3456789e1')).toBe('123'); expect(numberFormat(true)).toBe('1'); expect(numberFormat(false)).toBe('0'); expect(numberFormat(1234.5678, 2)).toBe('1,234.57'); expect(numberFormat(-1234.5678, 2)).toBe('-1,234.57'); expect(numberFormat(1234.6578e4, 2)).toBe('12,346,578.00'); expect(numberFormat(-1234.56789e4, 2)).toBe('-12,345,678.90'); expect(numberFormat(0x1234cdef, 2)).toBe('305,450,479.00'); expect(numberFormat(0o2777777777, 2)).toBe('402,653,183.00'); expect(numberFormat('123456789', 2)).toBe('123,456,789.00'); expect(numberFormat('123.456789', 2)).toBe('123.46'); expect(numberFormat('12.3456789e1', 2)).toBe('123.46'); expect(numberFormat(true, 2)).toBe('1.00'); expect(numberFormat(false, 2)).toBe('0.00'); expect(numberFormat(1234.5678, 2, '.', ' ')).toBe('1 234.57'); expect(numberFormat(-1234.5678, 2, '.', ' ')).toBe('-1 234.57'); expect(numberFormat(1234.6578e4, 2, '.', ' ')).toBe('12 346 578.00'); expect(numberFormat(-1234.56789e4, 2, '.', ' ')).toBe('-12 345 678.90'); expect(numberFormat(0x1234cdef, 2, '.', ' ')).toBe('305 450 479.00'); expect(numberFormat(0o2777777777, 2, '.', ' ')).toBe('402 653 183.00'); expect(numberFormat('123456789', 2, '.', ' ')).toBe('123 456 789.00'); expect(numberFormat('123.456789', 2, '.', ' ')).toBe('123.46'); expect(numberFormat('12.3456789e1', 2, '.', ' ')).toBe('123.46'); expect(numberFormat(true, 2, '.', ' ')).toBe('1.00'); expect(numberFormat(false, 2, '.', ' ')).toBe('0.00'); expect(numberFormat(1234.5678, 2, ',', ' ')).toBe('1 234,57'); expect(numberFormat(-1234.5678, 2, ',', ' ')).toBe('-1 234,57'); expect(numberFormat(1234.6578e4, 2, ',', ' ')).toBe('12 346 578,00'); expect(numberFormat(-1234.56789e4, 2, ',', ' ')).toBe('-12 345 678,90'); expect(numberFormat(0x1234cdef, 2, ',', ' ')).toBe('305 450 479,00'); expect(numberFormat(0o2777777777, 2, ',', ' ')).toBe('402 653 183,00'); expect(numberFormat('123456789', 2, ',', ' ')).toBe('123 456 789,00'); expect(numberFormat('123.456789', 2, ',', ' ')).toBe('123,46'); expect(numberFormat('12.3456789e1', 2, ',', ' ')).toBe('123,46'); expect(numberFormat(true, 2, ',', ' ')).toBe('1,00'); expect(numberFormat(false, 2, ',', ' ')).toBe('0,00'); }); });
要は結果が明確な多くのテストケースを処理しやすいということです。明確にテスト内容と期待する結果を記述できる様なものの場合テストは書きやすく、一度のテスト時間が長いほどテストをプログラム化することによって作業時間を短縮できます。ちなみに反対にテスト化が難しいのが人間が目で見てデザインの良し悪しを検査する様なテストです。文字のはみ出し、判別しにくい色、崩れたデザインなど人間の目では直感的にわかることでも検査ロジックを網羅するのは手間です。
例の様な関数に渡した値を元にどうこうする処理はテストを作る価値が大いにある対象であり、これは React のフックでしばしば行う処理でもあります。しかし残念ながら Jest そのままでは React フックをテストすることはできません。例えば次の様にエラーが出ます。
import 'jest'; import { useState } from 'react'; const useCounter = () => { const [count, setCount] = useState(0); return { increment: () => setCount((old) => old + 1), decrement: () => setCount((old) => old - 1), count, }; }; describe(__filename, () => { test('useCounter', () => { const counter = useCounter(); }); }); /* Error: 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. */
useState
を関数コンポーネント外で使用したためにエラーが発生しました。この様に React 組み込みのフックを用いているフックはそのまま Jest 内で使用することができません。これを解決するコードをまとめたライブラリの一つが jooks です。
antoinejaussoin/jooks: Testing hooks with Jest
jooks は次の様にフックをラッピングすることでエラーを防ぎ、コンポーネント内で使っているも同然の働きをしてくれます。
import 'jest'; import { useState } from 'react'; import init from 'jooks'; const useCounter = () => { const [count, setCount] = useState(0); return { increment: () => setCount((old) => old + 1), decrement: () => setCount((old) => old - 1), count, }; }; describe(__filename, () => { // init で初期化。フックの結果を返す関数を作り、それを渡します。 const counter = init(() => useCounter()); test('useCounter', () => { // jooks から生成されたオブジェクトの run メソッドを実行する度に state の更新等が走ります。 // jooks.run() でReact でいうマウント、更新、マウント解除の更新が実行されます。 const { increment } = counter.run(); increment(); increment(); // run メソッドで再度得られる結果を比較に使います。 // もし最初の run で count を取得し、それをテストに用いた場合、 // increment が反映されず count === 0 となってテストに失敗します。 expect(counter.run().count).toBe(2); }); });
この様にすると React の制約を楽に満たしつつ、フックのテストがプログラム上でできるようになります。