はじめに
Custom Hook を使うと面倒くさい事この上ない React をテストする時、ロジック部分を分離して小さくテストしやすくなりそうだなと思い、やってみることにしました。
今回は一番よくあるカウンターのデモと、実際の開発でもよく使うであろう input での入力の例を用いていきます。
testing-library/reactとtesting-library/react-hooksを使ってテストを書いていきます。
GitHub
最終的な成果物リポジトリはこちらです。
カウンターの例
では早速、カウンターのデモを書いていきます。
ボタンを押すと state の値が 1 増えるというシンプルなものです。
reactコンポーネントにべた書きの場合
export const Counter: React.FC = () => {
const { count, setCount } = useState(0);
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
},[]);
return (
<div>
<p>count: {count}</p>
<button onClick={handleIncrement}>add</button>
</div>
);
};
Custom Hookに分離
type ReturnType = {
count: number;
handleIncrement: () => void;
};
export const useCounter = (): ReturnType => {
const [count, setCount] = useState(0);
const handleIncrement = useCallback(() => {
setCount((prevCount) => prevCount + 1);
}, []);
return {
count,
handleIncrement,
};
};
export const Counter: React.FC = () => {
const { count, handleIncrement } = useCounter();
return (
<div>
<p>count: {count}</p>
<button onClick={handleIncrement}>add</button>
</div>
);
};
useCounter.ts で count
変数と handleIncrement
関数を定義しました。
これらを Counter.tsx で呼びだす事で count
と handleIncrement
を利用してレンダリングや件数の実行を行なっています。
Custom Hooksのテスト
import { act, RenderResult, renderHook } from "@testing-library/react-hooks";
import { useCounter, ReturnType } from "../hooks/useCounter";
describe("useCounter", () => {
let result: RenderResult<ReturnType>;
beforeEach(() => {
result = renderHook(() => useCounter()).result;
});
it("countの初期値は0である", () => {
expect(result.current.count).toBe(0);
});
it("handleIncrementを1度呼んだ後、countの値は1である", () => {
act(() => {
result.current.handleIncrement();
});
expect(result.current.count).toBe(1);
});
});
react-testing-library
を使ってテストを実装しています。
react-testing-library
の renderHook
関数を使う事で hooks のテストができるようになります。便利です。
ここでいう handleIncrement
のような、state を更新する処理を act の中で呼ぶ必要があります。
テキスト入力の例
次は react でよく使う input タグに文字を入力する際の Custom Hooks の例を書いていきます。
せっかくなので firstName
と lastName
の 2 つの state を扱うことにします。
追加で firstName
には 8 文字以内、lastName
には 6 文字以内というそれぞれ異なる簡単なバリデーションをつけてみます。
それぞれ input タグを通して入力した文字列を受け取ります。
reactコンポーネントにべた書き
export const TextInput: React.FC = () => {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const handleChangeFirstName = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション 8文字まで
if (firstName.length >= 8) {
return;
}
setFirstName(ev.target.value);
},
[firstName]
);
const handleChangeLastName = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
// バリデーション 6文字まで
if (lastName.length >= 6) {
return;
}
setLastName(ev.target.value);
},
[lastName]
);
return (
<div>
<input type="text" value={firstName} onChange={handleChangeFirstName} />
<input type="text" value={lastName} onChange={handleChangeLastName} />
</div>
);
};
state が 3 つ 4 つとさらに増えていったら書くの結構大変そうですし、バリデーションも全部べた書きにしたらだいぶ辛くなりそうです。
Custom Hookに分離
export type ReturnType = [
string,
(ev: React.ChangeEvent<HTMLInputElement>) => void
];
export const useTextInput = (maxLength: number): ReturnType => {
const [value, setValue] = useState("");
const handleChange = useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
if (value.length >= maxLength) {
return;
}
setValue(ev.target.value);
},
[value, maxLength]
);
return [value, handleChange];
};
export const TextInput: React.FC = () => {
const [firstName, handleChangeFirstName] = useTextInput(8);
const [lastName, handleChangeLastName] = useTextInput(6);
return (
<div>
<input type="text" value={firstName} onChange={handleChangeFirstName} />
<input type="text" value={lastName} onChange={handleChangeLastName} />
</div>
);
};
useCounter.ts で value
変数と handleChange
関数を定義しました。
react コンポーネントでの記述量がグッと減りました。
また、useTextInput の引数に設定した数値がそれぞれの最大入力可能文字数になります。
関数のreturn
useCounter.ts
と useTextInput
で return の時の形が異なります。オブジェクトとして return する場合、呼び出し先で使える変数名・関数名は基本同じ名前でしか呼び出します。
const { count, handleIncrement } = useCounter();
一方、配列にいれて return することで、呼び出し先で使う時に別の名前をつけることができます。
const [ name, handleChangeName ] = useTextInput();
const [ counter, setCounter] = useState<number>(0);
const [ name, setName ] = useState<string>("");
前者の場合、使いたいものだけを呼び出せますが、後者だと順番に応じたものを呼びだすことになるので、例えば useTextInput から handleChangeName
だけを呼び出したい時は以下のようにします。
const [ , handleChangeName ] = useTextInput();
Custom Hooksのテスト
describe("UseTextInput", () => {
let result: RenderResult<ReturnType>;
beforeEach(() => {
result = renderHook(() => useTextInput(10)).result;
});
test("初期値は空文字である", () => {
const [value] = result.current;
expect(value).toBe("");
});
test("入力値が反映される", () => {
const { container } = render(<input type="text" {...[result.current]} />);
const input = container.querySelector("input");
fireEvent.change(input!, { target: { value: "test" } });
expect(input!.value).toBe("test");
});
});
useTextInput.spec.tsx
では DOM のテストと組み合わせて Custom Hook をテストしていきます。
テスト用の input を作成し useTextInput
で作った値と関数を持たせます。
ここのやり方に関してはもっといい方法あるよ!これはよくないよ!!などの意見ありましたら是非お願いします!!
さいごに
今後取っていきたい設計方針としてはこんな感じです。
- react コンポーネントから Custom Hook に分離する
- Custom Hook のユニットテストはどんどんやっていく
- DOM のユニットテストはリソースと相談しつつ頑張っていきます
テストと仲良くなって安全なプログラムを書けるように精進していきます。