この記事は?
皆さんお久しぶりです。@cosmeの開発エンジニアをしております、村田です。@cosmeを運営する株式会社アイスタイルではPHP -> TypeScriptへの技術移行を進めており、フレームワークとしてはexpress, oclif, そして本記事で紹介するJavaScript製のテスティングフレームワークであるjestなどの各種ツールを使って開発を進めています。
この記事で紹介する内容は、チームでテストコードを書く文化を定着していく話です。というのも、既存プロダクトにて元々テストコードが十分に書かれていない部分があったため、リプレース後のサービスではしっかりとテストを書いていこう、ということでチームで一致団結しました。今まではテスト記述に対する基準は開発者によって任されていたところ、私の担当しているバックエンドのプロジェクトでは、テスト記述の優先度を高くしてリリース基準としてもテストコードの記述を含むことを追加することにしました。
テストを書く理由
テストコードを書くことは多くのメリットがあります。私が感じていることは、テストによってコード品質が保たれているため、安心して開発ができるということです。例えば、コードの共通箇所に変更を加えたとき、テストが通らなければ危険な変更であるということがわかりますが、テストコードがないと、デグレなどあらゆるの可能性を目視で確認しながら変更しなければならず、開発効率が良くなく、バグのリスクも増えてしまいます。また、特に本番公開しているプロダクトでは古いバージョンの技術を使っていては脆弱性が混入するリスクがあるため、テストをしっかり書いていればバージョンアップの際もスムーズに対応することができます。
ただ、こういった重要性を理解できているのは、私が実際にテストを書いてきてそういった恩恵を感じてきたからであって、テストは書いてみない限りその重要性を実感できず、(工数が増える、優先度が低い)などの理由を付けてテストを書かない方向に倒れてしまうこともあると感じます。
テストを書かない理由
組織やチームにテストを書く文化がないと、テストを書く慣習は定着しにくく、テストを書いた経験がないと自信を持って工数を主張しにくさがあったりと、テストを書くことに腰が重くなりやすい印象です。
この記事では、テストを書くことを前提 として話を進めていきます。テストを書かない理由ではなく、書くこと前提から入ることで、誰でもテストを書ける足がかりを目指していくことを目標にします。
まずは、テストを書きやすいコード基盤 から考えていきましょう!
テスタブルなコード基盤に整える
全面的にテストを書いていく上で大事なことは、コード基盤をテスタブルにするということです。著者は大規模なAPI基盤やシンプルなCLIシステムまで開発を担当しているため、よりシンプルで考えやすいCLIシステムを例にテスタブルなディレクトリについて考えてみたいと思います。
src
|- commands
|- application/services
|- infrastructure/repositories
|- utils
・commands(ユーザー・インターフェイス に関する層)
私たちのチームではCLIシステムを実現するためにoclifというフレームワークを使っており、oclifでは自由に引数を指定できたりと便利なフレームワークです。oclifでは、commands配下にユーザーインターフェースの処理を配置します。
commands層の主な関心は、ユーザーからのインプットを受け取って標準出力で結果を出すことであり、application/services層の関数を呼び出すことで、アプリ特有のビジネスロジックとは疎結合になっています。
・application/services層 (アプリケーションサービス に関する層)
こちらの層ではアプリケーション特有のビジネスロジックを書いていきます。データベースと通信している部分は、次に紹介するinfrastructure/repositories層で分離することで関心を分けています。
・infrastructure/repositories層 (永続化に関する層)
こちらの層ではデータベースと接続するSQLの処理を書いていきます。(※ 本システムでは、Knexというクエリビルダーを用いているが代替可能。)
・utils層
日付を決まったフォーマットに変換するなど最もピュアな処理の関数を入れている層で、最もテストを書きやすい層であるとも言えます。
どんなテストを書くのか?
著者が考えるテストを書くときに考慮する点は主に以下の3つです。
・意味のあるテストを書くこと
これはこう書くと当たり前のようですが、実装に対してどういうテストを書けば良いか?ということと同値です。正常系、異常系までインプットとして様々なテストケースを与えて、アウトプット(実行結果)が期待するものであるかどうか?をテストによって検証していきます。
・テストケースを正常系・異常系まで網羅していること
意外と抜けがちだと感じるのが正常系は網羅できたものの異常系のテストができていないというものです。異常系になると品質を損なうので、テストは正常系だけでなく注意して異常系まで網羅できるように心がけます。また、テストを書いていくうちに、エラーハンドリングが不十分であることに気づき、実装の方にハンドリング処理を増やすこともあります。
・AAA(arrange, act, assert)が十分に意識されていること
テストケースの可読性をあげる一つの方法として、AAAがあります。
AAAパターンでは以下の順番にテストを書きます:
Arrange: テストケースの準備
Act: 実行
Assert: 検証
この順番に書くことによって、テストコードの可読性が上がると考えられるため、テスト記述ではAAAを意識して書いていきます。
実際に書いているテスト例(jest)
application/service層を着目して、データベースに対して毎週収集されたデータを公開する処理publishのテストを考えてみます。
import { injectable, inject } from "tsyringe";
import { DiTokens } from "@/config/diTokens";
import { Logger } from "@/utils";
import { FeelingRankingRepository } from "@/repositories/feelingRanking";
@injectable()
export class FeelingRankingService {
constructor(
@inject(DiTokens.Logger)
private logger: Logger,
@inject(DiTokens.Repositories.FeelingRanking)
private feelingRankingRepository: FeelingRankingRepository,
) {}
/**
* ランキングデータを公開する
* @returns {Promise}
**/
public async publish(): Promise<void> {
this.logger.debug(`FeelingRanking Publish: start`);
const count = await this.feelingRankingRepository.checkLatestRankingDataAndGetRecordCount();
if (count === 0) {
this.logger.error(`今週の対象データはまだ公開されていません。確認の上、再処理をお願いします。`);
throw Error(`No data to publish this week in FeelingRanking`);
}
try {
await this.feelingRankingRepository.publish();
this.logger.debug(`FeelingRanking Publish: end`);
} catch (e) {
this.logger.error(`FeelingRanking Publish error: ${e}`);
throw e;
}
}
}
publishの処理としてはこの通りになります。
正常系としてはシンプルですが、留意すべきは異常系で、今回の処理だと少なくとも以下の3箇所での異常系を考えることができます。
// 異常系パターン1: 返ってくるPromiseが失敗すると異常系になる。
await this.feelingRankingRepository.checkLatestRankingDataAndGetRecordCount();
// 異常系パターン2: count=0だとエラーになる。
if (count === 0) {
this.logger.error(`今週の対象データはまだ公開されていません。確認の上、再処理をお願いします。`);
throw Error(`No data to publish this week in FeelingRanking`);
}
// 異常系パターン3: 返ってくるPromiseが失敗すると異常系になる。
await this.feelingRankingRepository.publish();
前述の通り、異常系までテストには盛り込むべきです。これら3つの異常系を全て盛り込み、テストを書くと以下のようになります。
import { FeelingRankingService } from "@/application/services/feelingRankingService";
import { FeelingRankingRepositorySystemError } from "@/infrastructures/repositories/feelingRanking";
import { FeelingRankingRepository } from "@/repositories/feelingRanking";
import { Logger } from "@/utils";
// モック関数
const feelingRankingRepositoryMock = jest.mocked<FeelingRankingRepository>({
publish: jest.fn(),
});
// モック関数
const loggerMock = jest.mocked<Logger>({
info: jest.fn(),
error: jest.fn(),
trace: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
fatal: jest.fn(),
});
describe("FeelingRanking", () => {
let feelingRanking: FeelingRankingService;
beforeEach(() => {
// 依存性の注入
// モック関数で引数を埋める
feelingRanking = new FeelingRankingService(loggerMock, feelingRankingRepositoryMock);
});
afterEach(() => {
// テスト内でmockの返り値を設定しているところがあるため、次のテストでは無効にする。
// 例) feelingRankingRepositoryMock.checkLatestRankingDataAndGetRecordCount.mockResolvedValue(10);
jest.resetAllMocks();
});
describe("#publish", () => {
describe("正常系", () => {
it("publishが正常に動作すること(ランキングデータ10件存在)", async () => {
feelingRankingRepositoryMock.checkLatestRankingDataAndGetRecordCount.mockResolvedValue(10);
await feelingRanking.publish();
expect(feelingRankingRepositoryMock.publish).toHaveBeenCalled();
});
});
describe("異常系", () => {
it("publishでランキングデータが0件の場合publish処理が行われないこと", async () => {
const error = new Error("No data to publish this week in FeelingRanking");
feelingRankingRepositoryMock.checkLatestRankingDataAndGetRecordCount.mockResolvedValue(0);
try {
await feelingRanking.publish();
} catch (e) {
expect(e).toEqual(error);
expect(feelingRankingRepositoryMock.publish).not.toHaveBeenCalled();
}
});
it("checkLatestRankingDataAndGetRecordCountでエラーが発生した場合FeelingRankingRepositorySystemErrorがスローされること", async () => {
const systemError = new FeelingRankingRepositorySystemError("test");
feelingRankingRepositoryMock.checkLatestRankingDataAndGetRecordCount.mockRejectedValue(systemError);
try {
await feelingRanking.publish();
} catch (e) {
expect(e).toEqual(systemError);
expect(feelingRankingRepositoryMock.publish).not.toHaveBeenCalled();
}
});
it("publishでエラーが発生した場合FeelingRankingRepositorySystemErrorがスローされること", async () => {
const systemError = new FeelingRankingRepositorySystemError("test");
feelingRankingRepositoryMock.publish.mockRejectedValue(systemError);
try {
await feelingRanking.publish();
} catch (e) {
expect(e).toEqual(systemError);
expect(feelingRankingRepositoryMock.publish).toHaveBeenCalled();
}
});
});
});
});
上記テストの解説
・feelingRankingRepository
・logger
はそれぞれ、publish関数の中で使っている関数で、jestを用いてテストを行う場合jest.mockedでモック化することで、
feelingRanking = new FeelingRankingService(loggerMock, feelingRankingRepositoryMock);
を呼び出して使うときにlogger, feelingRankingRepositoryをテストコード上では実装しなくても、よしなにテストを走らせてくれます。
・正常系
本実装のコードでは、
checkLatestRankingDataAndGetRecordCountが正しい値(>0)を返すことによって正常系の処理になるので、非同期通信の結果をモックできるmockResolvedValueを用いることによって、本体の処理であるfeelingRanking.publishをACTしたときに、
publishが期待通りに呼ばれたことを検証(Assert)することができます。
feelingRankingRepositoryMock.checkLatestRankingDataAndGetRecordCount.mockResolvedValue(10);
await feelingRanking.publish();
expect(feelingRankingRepositoryMock.publish).toHaveBeenCalled();
・異常系
異常系のパターンは3つあるので、そのうちの1つ(ランキングデータが0件の場合)について説明します。この場合は、先ほどとは逆に、checkLatestRankingDataAndGetRecordCountが0を返すときには、本実装の通りエラーが発生することを検証して異常系を網羅できました。
先ほどと異なる点はACTがtry catchの構文に入っていることで、こうする事によってキャッチしたエラーが想定通りであるか?まで検証できます。
it("publishでランキングデータが0件の場合publish処理が行われないこと", async () => {
const error = new Error("No data to publish this week in FeelingRanking");
feelingRankingRepositoryMock.checkLatestRankingDataAndGetRecordCount.mockResolvedValue(0);
try {
await feelingRanking.publish();
} catch (e) {
expect(e).toEqual(error);
expect(feelingRankingRepositoryMock.publish).not.toHaveBeenCalled();
}
});
終わりに
記事を書いてみて、チームにテストを導入させていきたい、と考えるチームではまず誰か1人が、次第に皆がテストを書くようになると文化が根付いていくと改めて実感しました。そのためにはテストを書きやすいコード基盤も大事です。紹介したコードはTypeScriptではありますが、他技術、他チームにも転換できる話だと感じているため、ぜひ取り入れてみてください!