環境構築

今回は Create React App を使用します。
Create React App には初期状態でJestが組み込まれているのですぐに使うことができます。

$ yarn create react-app jest-test --template typescript
$ cd jest-test

シンプルなTypeScriptファイルをテスト

Reactの前にシンプルなTypeScriptファイルをテストしてみます。
ユニットテストを最初に試すのに単純な足し算関数が例に挙げられることが多いですが、ここでも例にならって単純な足し算をする関数を作ってみましょう。

src/utils/sum.ts

const sum = (number1: number, number2: number): number => {
	return number1 + number2
}
export default sum

次にテストファイルを作ります。
テストファイルを作る場所はテストするファイルと同階層に置くか、__tests__ディレクトリの中に配置することが多いようです。
また、ファイル名に関しては.test.tsspec.tsという拡張子で作成します。
今回は__tests__/sum.test.tsというディレクトリとファイル名にします。

sumの引数に「2,3」を渡すと「5」が返ることをテストコードを書いてみましょう。

src/__tests__/sum.test.ts

import sum from 'utils/sum'

test('合計値が正しい', () => {
	expect(sum(2, 3)).toBe(5)
})

これでテストをする準備ができました。
Create React App では下記コマンドを実行してテストします。

$ yarn run test

これは想定通りの値なのでテストはpassedになるはずです。
試しに値を変更してfailedになることも確認してみてください。

基本的に一つの機能に対し複数のテストを書くことになると思います。
その場合describeでまとめることができます。

describe('sumのテスト', () => {
	test('合計値が正しい', () => {
		expect(sum(2, 3)).toBe(5)
	})

	test('合計値が正しくない', () => {
		expect(sum(2, 3)).not.toBe(6)
	})
})

ちなみに値がイコールでない場合をテストするにはnotを使用します。

Reactコンポーネントのテスト

次はコンポーネントをテストしてみます。
例えばこんな感じのコンポーネントがあるとします。

src/components/Header.tsx

import React from 'react'

type Props = {
	title?: string
}

const Header: React.FC<Props> = ({
	title
}) => {
	return (
		<header>
			<h1>{title || 'React App'}</h1>
		</header>
	)
}

export default Header

title Propsがある場合は変数を表示して、ない場合はデフォルトの文字列を表示するというシンプルなコンポーネントです。

コンポーネントをテストするには@testing-library/reactをインポートします。
テストを書く前にscreen.debug()を実行してみてください。

src/__tests__/Header.test.tsx

import { render, screen } from '@testing-library/react'
import Header from 'components/Header'

test('Propsがない場合はデフォルト文字列が表示される', () => {
	render(<Header />)
	screen.debug()
})

実際にどのように表示されるかタグが出力されると思います。

一部だけ確認したい場合はlogDOMが使えます。

import { logDOM, render, screen } from '@testing-library/react'
import Header from 'components/Header'

test('Propsがない場合はデフォルト文字列が表示される', () => {
	render(<Header/>)
	logDOM(screen.getByText('React App'))
})

想定通りならこの結果をexpectすればいいだけですね。

では、React Appが表示されるというテストコードを書いてみます。

test('Propsがない場合はデフォルト文字列が表示される', () => {
	const { container } = render(<Header />)
	expect(container.innerHTML).toMatch('React App')
})

container.innerHTMLでHTMLが取れるので、toMatchで指定した文字列が含まれるかテストしています。

Propsがある場合のテストは次のようになります。

test('Propsがある場合は変数が表示される', () => {
	const title: string = 'テスト'
	const { container } = render(<Header title={title} />)
	expect(container.innerHTML).toMatch(title)
})

React Hooksのテスト

Hooksはコンポーネントでしか実行できないのでテストするのはちょっとややこしそうですね。
これも@testing-library/reactを使うと簡単にテストできます。

テストするのはこんな感じのよくあるカウンターフックです。

src/hooks/counter.ts

import { useState } from 'react'

export type returnType = {
	count: number
	increment: () => void
	decrement: () => void
}

export const useCounter = (): returnType => {
	const [count, setCount] = useState<number>(0)

	const increment = () => setCount(count + 1)
	const decrement = () => setCount(count - 1)

	return {
		count,
		increment,
		decrement
	}
}

Hooksをテストするには@testing-library/reactrenderHookactを使用します。
HooksにはrenderHookを通してアクセスして、メソッドを実行するときはactの中で実行します。

src/__tests__/counter.test.ts

import { renderHook, act } from '@testing-library/react'
import { useCounter } from 'hooks/counter'

describe('Counter Hooks', () => {
	test('インクリメントされる', () => {
		const { result } = renderHook(() => useCounter())
		act(() => result.current.increment())
		expect(result.current.count).toBe(1)
	})
	
	test('デクリメントされる', () => {
		const { result } = renderHook(() => useCounter())
		act(() => result.current.decrement())
		expect(result.current.count).toBe(-1)
	})
})

イベントテスト

さきほど作ったカウンターフックをコンポーネントに組み込んでみました。

src/__tests__/counter.test.ts

import React from 'react'
import { useCounter } from 'hooks/counter'

const Counter: React.FC = () => {
	const { count, increment, decrement } = useCounter()

	return (
		<div>
			{count}
			<button onClick={increment}>+</button>
			<button onClick={decrement}>-</button>
		</div>
	)
}

export default Counter

ボタンをクリックするとカウントされるコンポーネントです。
次はこのイベントをテストしてみます。
イベントをテストするには@testing-library/user-eventuserEventを使用します。

src/__tests__/Counter.test.tsx

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from '../components/Counter'

test('インクリメントされる', () => {
	render(<Counter />)
	userEvent.click(screen.getByRole('button', {name: '+'}))
	expect(screen.getByText(1)).toBeInTheDocument()
})

現在 Create React App でインストールされる@testing-library/user-eventはバージョン13ですが、14からは記述方法が変わっています。

test('インクリメントされる', async () => {
	const user = userEvent.setup()
	render(<Counter />)

	const button = screen.getByRole('button', {name: '+'})
	await user.click(button)

	expect(screen.getByText(1)).toBeInTheDocument()
})

setupが必要だったりasyncで書かないといけなかったりでちょっと記述が増える感じですね。

Mockを使用したテスト

次にテストするのはDateを渡すと、その日が過去の日付の場合trueを未来ならfalseを返す関数です。

src/utils/isPast.ts

export const isPast = (date: Date): boolean => {
	return date.getTime() < Date.now()
}

ここまでくればtrue,falseをテストするのは楽勝ですね。
ちなみにtoBeTruthytruetoBeFalsyfalseであることテストできます。

src/__tests__/isPast.test.ts

import { isPast } from 'utils/isPast'

describe('isPast util', () => {
	test('過去の日付の場合はtrue', () => {
		const date = new Date('2022-04-30')
		expect(isPast(date)).toBeTruthy()
	})

	test('未来の日付の場合はfalse', () => {
		const date = new Date('2022-06-30')
		expect(isPast(date)).toBeFalsy()
	})
})

しかし、このテストコードには問題があります。
テストコードを書いた時点では「2022-06-30」は未来の日付で問題なくても、「2022-06-30」を過ぎた後テストを実行すると失敗してしまいます。

この問題を解決するにはモックという機能を使用します。
モックを使用することで関数の戻り値を固定にすることができます。
簡単なコードで試してみましょう。

test('Mock test', () => {
	const spy = jest.spyOn(Date, 'now')
	spy.mockReturnValue(new Date('2021-05-20').getTime())

	console.log(new Date(Date.now()))
})

ログに表示されたのは現在の日時ではなくmockReturnValueで設定した「2021-05-20」になったと思います。

これを使って先ほどのテストを書き直してみます。
複数のテストの前に同じ処理を実行したい場合はbeforeEachを使用します。

import { isPast } from 'utils/isPast'

describe('isPast util', () => {
	beforeEach(() => {
		const spy = jest.spyOn(Date, 'now')
		spy.mockReturnValue(new Date('2021-05-01').getTime())
	})

	test('過去の日付の場合はtrue', () => {
		const date = new Date('2021-04-30')
		expect(isPast(date)).toBeTruthy()
	})

	test('未来の日付の場合はfalse', () => {
		const date = new Date('2021-06-30')
		expect(isPast(date)).toBeFalsy()
	})
})

このように実行した日時や外部の要因によってテスト結果が変わってしまうような処理はモックを使用することで解決することができます。