【No.34】初めてJestテストを書いた話(with NestJS)
皆さんこんにちは!
今年からQLifeのプロダクト開発チームにお世話になっているSです。
入社から3ヶ月になりますが、前職では触れていなかった技術領域が多く、新鮮な日々を過ごしています。
今回は、直近で対応した「Jestテスト」について書かせていただこうと思います。
私自身、JestもNestJSも初めて触ったばかりなので、初学者レベルでクイックスタート的に要点をまとめた内容となります。
では早速始めます。
サンプル
テストコード
import { InternalServerErrorException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { AnimalService } from "./animal.service";
import { AnimalRepository } from "./animal.repository";
import { HogeModule } from "@hoge/hoge";
import { FugaModule } from "@fuga/fuga";
let animalService: AnimalService;
let animalRepository: AnimalRepository;
// 事前処理
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
// テスト対象が依存するモジュールを列挙
providers: [
AnimalService,
{
provide: AnimalRepository,
useValue: {
getAnimalNumbers: jest.fn(),
},
},
HogeModule,
FugaModule,
],
}).compile();
// テスト内で直接使用するモジュールは変数に入れておく
animalService = module.get<AnimalService>(AnimalService);
animalRepository = module.get<AnimalRepository>(AnimalRepository);
});
describe("countAnimalのテスト", () => {
// 正常系テスト
describe("計算対象のデータが全て揃っていた時", () => {
it("正しく結果を返却する", async () => {
// モックデータ指定
(animalRepository.getAnimalNumbers as jest.Mock).mockResolvedValue(
{
lion: 4,
capybara: 3,
penguin: 15,
}
);
// 実行&結果比較
await expect(animalService.countAnimal()).resolves.toEqual(22);
});
});
// 異常系テスト
describe("計算対象のデータが揃っていなかった時", () => {
it("InternalServerErrorExceptionが送出される", async () => {
// モックデータ指定
(animalRepository.getAnimalNumbers as jest.Mock).mockResolvedValue(
{
lion: 4,
capybara: null,
penguin: 15,
}
);
// 実行&結果比較
await expect(animalService.countAnimal()).rejects.toThrow(
new InternalServerErrorException("計算に失敗しました")
);
});
});
});
実行結果
PASS src/example/animal.service.spec.ts
countAnimalのテスト
計算対象のデータが全て揃っていた時
✓ 正しく結果を返却する (51 ms)
計算対象のデータが揃っていなかった時
✓ InternalServerErrorExceptionが送出される (3 ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 2.027 s
前提条件
テスト対象(Nest本体)側の機能を下記にまとめます。登場人物は、AnimalRepositoryとAnimalServiceの2つです。
AnimalRepository
AnimalServiceに注入される
getAnimalNumbers()メソッドを持つ
動物の種類ごとの飼育数をDBから取得して返す
// getAnimalNumbers()返却値サンプル (動物名s: 飼育数n)
{
animal_name1: 10,
animal_name2: 20,
}
AnimalService
countAnimal()メソッドを持つ
AnimalRepository.getAnimalNumbers()を叩き、返却された動物の飼育数を合算して返す
飼育数が登録されていない動物がいた場合、InternalServerErrorExceptionを投げる
説明
実装は大きく分けて、
事前/事後処理を行うbefore/after系の処理
テスト本体を定義するit
の2つからなり、これらをdescribeでマークアップしている形です。
上記の例では、事前処理で依存関係のあるモジュールを読み込んでおき、その後に実行されるテストケースとしてAnimalService.countAnimal()の正常系と異常系をそれぞれ1つずつ定義しています。
事前/事後処理
サンプルコードでは、全てのテストの前に1度だけ(つまりbeforeAll)、依存関係のあるモジュールを全て読み込んでおく処理を行なっています。
providersに依存モジュールを列挙し、さらにテストコード内で直接使いたいモジュールについては変数に呼び出しておきます。
providersはJestというよりNestJSのお作法で、DIコンテナとして機能する部分です。NestJSの@moduleで指定するものと大方イコールになります。
事前/事後処理にはbeforeAllの他に、各テスト項目の実行前に毎回初期化処理を走らせたい場合に使うbeforeEachや、逆に後処理を行うafterEachなどがあります。
モック化
実際にDBに入っているデータにテストを依存させたくないため、repositoryの返却値をモックしています。
beforeAllのprovidersで予め、repositoryをモック化することを示し、
{
provide: AnimalRepository,
useValue: {
getAnimalNumbers: jest.fn(),
},
},
各テストケース内でモックデータを指定しています。
(animalRepository.getAnimalNumbers as jest.Mock).mockResolvedValue(
{
lion: 4,
capybara: 3,
penguin: 15,
}
);
テストケース毎にモックデータを切り替える必要がない場合は、providersでモックデータの指定までまとめて行ってしまうことも可能でした。
{
provide: AnimalRepository,
useValue: {
getAnimalNumbers: jest.fn().mockResolvedValue({
lion: 4,
capybara: 3,
penguin: 15,
}),
},
},
また、サンプルコードには無いパターンですが、戻り値がvoidな関数をモックしたい場合には、mockImplementationを使って関数を渡してやればいいようです。
// 戻り値無し → 空の関数でmockする
jest.fn().mockImplementation(() => {})
// 例外を返させたいとき → 例外をthrowするだけの関数でmockする
jest.fn().mockImplementation(() => {
throw new Error()
})
基本的なテスト
サンプルコードではこの部分。
await expect(animalService.countAnimal()).resolves.toEqual(22);
expectにテスト対象メソッドの返却値を渡して、toEqualで比較してします。
比較メソッドはtoEqualの他にも色々あるので、テストや値の内容によって適宜使い分けます。
また、resolvesはPromiseが解決されることを期待する場合のもので、逆に失敗することを期待する場合にはrejectsを指定します。
テスト対象が同期関数の場合は、諸々省いて
expect(animalService.countAnimal()).toEqual(22);
のように書けます。
例外テスト
サンプルコードではこの部分。
await expect(animalService.countAnimal()).rejects.toThrow(
new InternalServerErrorException("計算に失敗しました")
);
expectに渡したanimalService.countAnimal()から、"計算に失敗しました"というエラーメッセージのInternalServerErrorExceptionがthrowされることを確認しています。
型だけ、あるいはメッセージだけをチェックすることも可能です。
// 型だけ
await expect(animalService.countAnimal()).rejects.toThrow(
InternalServerErrorException
);
// メッセージだけ
await expect(animalService.countAnimal()).rejects.toThrow(
"計算に失敗しました"
);
追加で、少々分かりにくかった例を2点ほど。
1つ目は、テスト対象が同期関数の場合。
正常系テストの例から言えば下記の書き方で出来そうですが…
expect(animalService.synchronousMethod()).toThrow(
new Error("errMsg")
);
これでは上手くいかず、テスト対象をラムダ式でラップしてやる必要があるようです。
expect(() => { animalService.synchronousMethod() }).toThrow(
new Error("errMsg")
);
2つ目は、NestJSのHttpExceptionを使っている場合。
HttpExceptionは、第1引数としてstringのエラーメッセージまたはレスポンスオブジェクト、第2引数としてHTTPステータスコードを指定できます。単純に書けば下記で良さそうで、実際これでテストを通りますが…
await expect(animalService.throwHttpExceptionMethod()).rejects.toThrow(
new HttpException({ errMsg: "NotFound" }, 404);
);
実はこれだと、レスポンスオブジェクトの中身とステータスコードが何であってもテストが通ってしまいました。
どうやらtoThrowでは、例外オブジェクトの型と、第1引数にエラーメッセージを渡した場合はその内容がマッチすることまではテストしてくれるものの、レスポンスオブジェクトの中身とステータスコードは見てくれないようです。
これについてはちょうど良いmatcherが見つからなかったので、関数の実行をtry-catchで囲い、拾った例外オブジェクトに対して値のチェックを行うことで対処しました。
try {
await animalService.throwHttpExceptionMethod();
fail("例外が期待通りにthrowされませんでした。");
} catch (e: any) {
expect(e.response).toEqual({ errMsg: "NotFound" });
expect(e.status).toEqual(404);
}
環境変数の値を含むレスポンスをテストしたい場合
テスト対象内部の処理でNestJSのConfigModuleを使用して環境変数を取得している場合、サンプルコードのままで実行すると、取得結果がundefindになってしまいます。
これについては、事前処理にimportsを追加することで解決出来ました。
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: [`.env.development`],
}),
],
providers: [
// 略
],
}).compile();
// 上書きも可能
process.env.HOGE = "dummy";
copy
書く内容は、NestJS側の@Moduleで行う設定と同様で良さそうです。
まとめ
以上、Jestテストについてでした。
ちょこちょこ分かりにくいポイントがあるなという感想ですが、一度出来て仕舞えばパターン化できるので、マイチートシートを作って充実させていくのが良さそうだなと思いました。
そしてもちろん、そもそもビジネスロジック側をテストしやすい設計にしておくことも大事ですね。
We are hiring!!
最後に、QLifeでは新規メンバーを絶賛募集中です!ご興味のある方は下記採用情報ページをご覧ください。
ご応募お待ちしております!!