💯

JestをTypeScriptで使う

2022/03/08に公開1

概要

前提

こんにちは。本日は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オプションにより設定ファイルを使い分けるようなことも可能です。

package.json
{
  "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

これでできる設定ファイルが以下です。

jest.config.js
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

jest自体の初期設定のコマンドもありますが、対話形式が多少煩雑なので上記で行っています。

テスト対象の設定

rootsを設定ファイルに追加します。プロジェクトのrootは<rootDir>を使用可能です。

Jestがファイルを検索するために使用するディレクトリのパスのリスト。

jest.config.js
/** @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ではレポートの出力形式を指定します。
jest.config.js
/** @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を返す関数を例とします(実用性の有無はさておき)。

テスト対象のコード(正常系)

src/index.ts
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
};

正常系のユニットテスト

test/index.test.ts
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().mockResolvedValueaxios.getをmock化します。外部呼び出しをmock化することで、外部APIの死活や変更に依存せず、fetchTodoTitle関数自体の振る舞いをテストすることができるようになります。ここでいう「fetchTodoTitleの振る舞い」とは、src/index.tsでいうところの

  • statusが200の時だけ返答を返す
  • res.data.titleの値を返す

を意図しています。

Jestのmock関数のドキュメントはここらへんです。

テスト対象のコード(異常系)

次に異常系を追加します。404エラーが返ってきたらNotFoundErrorというカスタムエラーを追加します。今回は一つのファイルに書いていますが、エラークラスなんかはファイルをわけてもいいかもしれません。

src/index.ts
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.isAxiosErrorjest.fn().mockReturnValue, axios.getjest.fn().mockRejectedValueでmock化します。axiosは400系, 500系のレスポンスを取得した際に、例外をthrowするのでjest.fn().mockRejectedValueでmockしています。

tests/index.test.ts
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

NaosukeNaosuke

異常系のテストですが、fetchTodoTitle(99999999)の部分が、期待と異なり正常に終了してしまった場合もPASSしてしまいそうに思ったのですが、どうでしょう?自分で確かめてないので、自信ないですが。。。