🐈

Jest でgRPC × Typescript のテストを実行する

2022/05/03に公開

はじめに

JestはJavascriptもしくはTypescript向けのテストフレームワークです。

Jest基礎

まずはJestを使用する際に押さえておくべき基礎事項を簡単にみていきます。

頻繁に利用するコマンド

全テストを実行

npx jest -i

ファイルに変更が加えられたときにテストを実行

npx jest -i --watch

特定のファイルを実行

npx jest -i tests/handlers/claimServiceHanler.test.ts

https://zenn.dev/tentel/books/08b63492b00f0a/viewer/27c963#特定のファイルを指定して実行

https://jestjs.io/ja/docs/getting-started

セットアップについて

ファイルの中で一番最初のみ実行

beforeAll(() => {
  return initializeCityDatabase();
});

ファイルの中で一番最後のみ実行

afterAll(() => {
  return clearCityDatabase();
});
  • 補足説明
    ネストした describe の中では、外部の beforeAllafterAllbeforeEachafterEach が継承される。
    ※ネストした descirbe の外部のbeforeAllbeforeEach がdescribe内部の beforeAllbeforeEach よりも先に実行される。
    一方で、ネストした descirbe の外部の afterAllafterEachがdescribe内部の beforeAllbeforeEach よりも後に実行される。

https://jestjs.io/ja/docs/setup-teardown

(余談)テストスイートとテストケースの違い

テストスイートとテストケースの言葉の定義が曖昧になっていたので簡単に整理をします。

テストケース

期待する動作を確認するためのテストの実体です。

テストスイート

テストケースの集まりをテストスイートと呼びます。

https://www.itmedia.co.jp/im/articles/1111/07/news187.html#:~:text=ソフトウェアテスト(動的テスト,ものをテストスイートという。

(余談)カバレッジの種類

カバレッジには複数の種類が存在します。
Jestでは以下の3種類のカバレッジをテスト実行時に出力してくれるので、複数種類のカバレッジの違いを押さえておきましょう。

ステートメントカバレッジ

全処理について、テストがどれだけ網羅されているかを示しています。

ブランチカバレッジとは

条件分岐をどれだけ網羅できているかどうかを示しています。

function カバレッジ

全関数のうち、通過した関数の割合を示しています。

https://hldc.co.jp/blog/2018/08/22/1641/
https://qiita.com/turmericN/items/e3e48f04800e8c0b723c

テスト時に使用したソースコード

以下に、実際に社内で開発を行う際に使用したテスト関連のソースコードを記載します。

src
└── backend
       ├── src
       |    └── handlers
       └── tests
            ├── handlers
            └── helper
	          ├── index.ts
		  ├── prisma.ts
		  ├── with-database-cleaner.ts
		  └── with-grpc.ts

gRPCサーバーを起動するためのソースコード

gRPCサーバーを起動した上でテストを実行する必要があるので、以下のファイル内にて定義されているwithGrpcメソッドをテスト実行ファイル内で呼び出してやるようにします。

with-grpc.ts
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { ProtoGrpcType } from "~/src/__generated__/honeycomb";
import { promisifyAll } from "bluebird";
import { operatorServiceHandlers } from "~/src/handlers/operatorServiceHandlers";
import { archiveServiceHandlers } from "~/src/handlers/archiveServiceHandlers";


const PROTO_PATH = `${__dirname}/../../proto/honeycomb.proto`;
const packageDefinition = protoLoader.loadSync(PROTO_PATH);

export const proto = grpc.loadPackageDefinition(
  packageDefinition
) as unknown as ProtoGrpcType;

function getServer(): grpc.Server {
  const server = new grpc.Server();
  server.addService(
    proto.honeycomb.OperatorService.service,
    operatorServiceHandlers
  );
  server.addService(
    proto.honeycomb.ArchiveService.service,
    archiveServiceHandlers
  );
  
  return server;
}

const host = "0.0.0.0:9999";

type SubtypeConstructor<
  Constructor extends new (...args: any) => any,
  Subtype
> = {
  new (...args: ConstructorParameters<Constructor>): Subtype;
};

type NewType<T extends grpc.Client, F extends grpc.ServiceDefinition> =
  SubtypeConstructor<typeof grpc.Client, T> & { service: F };

export const withGrpc = <
  T extends grpc.Client,
  F extends grpc.ServiceDefinition
>(
  definition: NewType<T, F>
) => {
  let server: grpc.Server;

  beforeAll((done) => {
    server = getServer();

    server.bindAsync(
      host,
      grpc.ServerCredentials.createInsecure(),
      (err: Error | null, port: number) => {
        if (err) {
          console.error(`Failed to bind server: ${err.message}`);
          done();
        } else {
          server.start();
          done();
        }
      }
    );
  });

  const def = new definition(host, grpc.credentials.createInsecure());
  const client = promisifyAll(def);

  beforeAll((done) => {
    const deadline = new Date();
    deadline.setSeconds(deadline.getSeconds() + 10);

    client.waitForReady(deadline, (err) => {
      if (err) {
        console.error("Failed to connect gRPC server:", err);
      }
      done();
    });
  });

  afterAll((done) => {
    server.tryShutdown((err) => {
      if (err) {
        console.error("Failed to shtdown gRPC server: ", err);
      }
      done();
    });
  });

  return client;
};

データベース内のデータを削除するためのソースコード

もし仮にデータベース内に既存レコードが残っていた際にテストの実行結果に影響を及ぼしてしまう可能性があるので、テスト実行時にはデータベース内のレコードを全削除してやる必要があります。
その際に、以下のファイル内にて定義されている withDatabaseCleander メソッドを使用します。

import { PrismaClient, Prisma } from "@prisma/client";

export const withDatabaseCleander = (): void => {
  const prisma = new PrismaClient();
  const modelNames = Prisma.dmmf.datamodel.models.map((model) => model.name);

  beforeEach(async () => {
    await Promise.all(
      modelNames.map((modelName) => {
        const m = (prisma as any)[modelName];
        return m.deleteMany();
      })
    );
    await prisma.$disconnect();
  });
};

reuse PrismaClient

複数のテストファイルにわたってテストを実行している時に、テストスイートごとにPrismaClient のインスタンスを生成していると、下記の様なエラーが発生する。

発生したMySQLのエラー

● clientCvPoint service › ClientCvPointUpdate › should success


    Invalid `m.deleteMany()` invocation in
    /Users/nagaitakuya/MyWorkspace/honeycomb-admin-web/src/backend/tests/helper/with-database-cleaner.ts:11:18

       8 await Promise.all(
       9   modelNames.map((modelName) => {
      10     const m = (prisma as any)[modelName];
    → 11     return m.deleteMany()
      Error querying the database: Server error: `ERROR HY000 (1040): Too many connections'

       6 |
       7 |   beforeEach(async () => {
    >  8 |     await Promise.all(
         |     ^
       9 |       modelNames.map((modelName) => {
      10 |         const m = (prisma as any)[modelName];
      11 |         return m.deleteMany();

発生したprismaの警告

console.warn
      warn(prisma-client) There are already 10 instances of Prisma Client actively running.

対処法

  • テスト実行時にインスタンスを再利用する
    prisma公式ドキュメントを参照すると、対処法のヒントとなる情報が記載されている。

https://www.prisma.io/docs/guides/performance-and-optimization/connection-management#prismaclient-in-long-running-applications

課題

gRPCとTypescriptを使用して開発を行っていくと、いくつかの課題が出てきました。
課題が出てきたことによって、結果的にはgRPCとTypescriptでのリプレイス作業は断念し、他の技術スタックにて開発を進めることになりました。
gRPCとTypescriptを使用して開発を進めていく際に課題となった点は以下のものになります。

  • テストを実行する際に必要となるfixtureやfactoryなどのツール群が不足している。

  • テスト実行時にメモリの消費が激しく、本番環境での実運用の際にもパフォーマンス面で懸念事項が発生する可能性がある。

終わりに

今回の記事ではJestを使用してgRPCとTypescriptのテスト環境を整備するために必要な基本事項をまとめさせていただきました。
上述した様に、開発を進めていく中で、gRPCとTypescriptを使用して開発を進めるには課題となる点がいくつか存在したため、結果的には今回の記事で解説させていただいたような形式での開発は断念することになりました。
gRPCとTypescriptで開発を進めた場合と比較して、gRPCとGolangでの開発に関しては、上述した課題をある程度解消できるということもあり、gRPCとGolangの技術選定にて開発体制を仕切り直した上で開発を再開いたしました。

Discussion