JestをTypeScriptで使う
概要
前提
こんにちは。本日はJest + TypeScriptについて調べたことを書いてみます。
前提として、node: v16.14.0
で、node環境での実行を想定しています。
Jestについて
JestとはJavaScriptのテスティングフレームワークです。
ちょっと前、nodeでのテストといえば、mocha, chai, istanblu, sinonなどを組み合わせて使っていたようです。
Jestではドキュメントでゼロコンフィグ標榜されている通り、基本的な機能に加え、mock, spy, coverageなど多くの機能を少ない設定で使用することが可能です。
設定
初期設定
$ yarn init -y
$ yarn add --dev typescript jest ts-jest @types/jest
$ yarn add axios # 後で使う
$ tsc --init # デフォルトでとりあえずOK
$ mkidr src tests # srcに実装, testsにテストを書きます。
$ touch src/index.ts tests/index.test.ts
以下のようなディレクトリになっています。
├── jest.config.js
├── package.json
├── src
│ └── index.ts
├── tests
│ └── index.test.ts
├── tsconfig.json
└── yarn.lock
npmスクリプトの追加
npmスクリプトを追加します。
例えば、単体テストとE2Eテストを違う設定で行いたい場合などでは、jest --config jest.e2e.config.js
など--config
オプションにより設定ファイルを使い分けるようなことも可能です。
{
"name": "sample_d",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
+ "scripts": {
+ "test": "jest"
+ },
"devDependencies": {
"@types/jest": "^27.4.1",
"jest": "^27.5.1",
"ts-jest": "^27.1.3",
"typescript": "^4.6.2"
},
"dependencies": {
"axios": "^0.26.0"
}
}
TypeScript用の設定
Jestのドキュメントでも紹介されているts-jestを使用します。上の初期設定でインストール済みです。
GitHub: https://github.com/kulshekhar/ts-jest
$ yarn ts-jest config:init
これでできる設定ファイルが以下です。
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
jest自体の初期設定のコマンドもありますが、対話形式が多少煩雑なので上記で行っています。
テスト対象の設定
roots
を設定ファイルに追加します。プロジェクトのrootは<rootDir>
を使用可能です。
Jestがファイルを検索するために使用するディレクトリのパスのリスト。
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
+ roots: ["<rootDir>/tests", "<rootDir>/src"],
};
カバレッジ用の設定
テスト対象のコードのうち、何行分をテストがカバーできているかの割合をカバレッジと呼びます。カバレッジの設定もこちらに追加します。
ドキュメントはここらへんが該当箇所です。
-
collectCoverage
でカバレッジ取得するか否かを決めます -
collectCoverageFrom
でカバレージを計測する対象の実装が載っているファイルを指定します。今回は.ts
拡張子のファイルを対象にし、node_modules
を除く設定にします。 -
coverageDirectory
ではcoverage結果の出力先をを指定します。何も指定しなければcoverageフォルダがプロジェクトルートに作られます。 -
coverageReporters
ではレポートの出力形式を指定します。
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ["<rootDir>/tests", "<rootDir>/src"],
+ collectCoverage: true,
+ collectCoverageFrom: [
+ "**/*.ts",
+ "!**/node_modules/**",
+ ],
+ coverageDirectory: 'coverage_dir',
+ coverageReporters: ["html"]
};
コード例
外部APIとして、JSON Placeholderを使用します。特定のTodoを取得してそのTitle
を返す関数を例とします(実用性の有無はさておき)。
テスト対象のコード(正常系)
mport axios from "axios";
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
export const fetchTodoTitle = async (id: number): Promise<Todo|null> => {
const res = await axios.get(
`https://jsonplaceholder.typicode.com/todos/${id}`
);
if (res.status === 200) {
return res.data.title;
}
return null
};
正常系のユニットテスト
describe('fetch todo title test', () => {
test('200 OK', async () => {
axios.get = jest.fn().mockResolvedValue({
status: 200,
data: {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
})
const res = await fetchTodoTitle(1)
expect(res).toBe("delectus aut autem")
})
})
まず、jest.fn().mockResolvedValue
でaxios.get
をmock化します。外部呼び出しをmock化することで、外部APIの死活や変更に依存せず、fetchTodoTitle
関数自体の振る舞いをテストすることができるようになります。ここでいう「fetchTodoTitle
の振る舞い」とは、src/index.ts
でいうところの
-
status
が200の時だけ返答を返す -
res.data.title
の値を返す
を意図しています。
Jestのmock関数のドキュメントはここらへんです。
テスト対象のコード(異常系)
次に異常系を追加します。404エラーが返ってきたらNotFoundError
というカスタムエラーを追加します。今回は一つのファイルに書いていますが、エラークラスなんかはファイルをわけてもいいかもしれません。
import axios from "axios";
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
+ export class NotFoundError extends Error {
+ public id: number;
+ public constructor(id: number) {
+ super();
+ this.id = id;
+ }
+ }
+ export class UnknownError extends Error {
+ public id: number;
+ public constructor(id: number) {
+ super();
+ this.id = id;
+ }
+ }
export const fetchTodoTitle = async (id: number): Promise<Todo|null> => {
+ try {
const res = await axios.get(
`https://jsonplaceholder.typicode.com/todos/${id}`
);
if (res.status === 200) {
return res.data.title;
}
return null
+ } catch(e) {
+ if(axios.isAxiosError(e) && e.response?.status === 404) {
+ throw new NotFoundError(1)
+ }
+ throw new UnknownError(1)
+ }
};
異常系のユニットテスト
以下がテストコードです。axios.isAxiosError
をjest.fn().mockReturnValue
, axios.get
をjest.fn().mockRejectedValue
でmock化します。axiosは400系, 500系のレスポンスを取得した際に、例外をthrowするのでjest.fn().mockRejectedValue
でmockしています。
import axios from 'axios'
import { fetchTodoTitle, NotFoundError, UnknownError } from '../src/index'
describe('fetch todo title test', () => {
test('200 OK', async () => {
axios.get = jest.fn().mockResolvedValue({
status: 200,
data: {
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
})
const res = await fetchTodoTitle(1)
expect(res).toBe("delectus aut autem")
})
+ test('404 Not Found', async () => {
+ (axios.isAxiosError as unknown) = jest.fn().mockReturnValue(true)
+ axios.get = jest.fn().mockRejectedValue({
+ response:{
+ status: 404,
+ data: undefined,
+ },
+ })
+ try {
+ await fetchTodoTitle(99999999)
+ } catch(e) {
+ expect(e instanceof NotFoundError).toBeTruthy()
+ }
+ })
})
最後に
以上、Jestを用いたユニットテストについて書きました。今回は外部APIへのアクセスを提供するaxiosについてはmock化することで、自分で定義した関数の振る舞いを検査するテストを作成することができました。このテストは外部APIの状況による影響を受けないので、安定した結果を得ることができ、CI/CDなどに入れておくことで意図せぬ変更を検知することができます。
一方で、このテストだけでは外部APIの変更などは検知できません。外部APIへのアクセスをmock化しないテストを作成して、スケジュール実行するなどで対策できるかもしれません。
ということで、今後もしっかりテストを書いていこうと思います。
Discussion
異常系のテストですが、
fetchTodoTitle(99999999)
の部分が、期待と異なり正常に終了してしまった場合もPASSしてしまいそうに思ったのですが、どうでしょう?自分で確かめてないので、自信ないですが。。。