GraphQLのアプリケーションにテストを書きやすくする工夫をした
こんにちは。スターフェスティバル株式会社の ikkitang です。
今回は社内の GraphQL アプリケーションのテストを書きやすくしたくて、リアーキテクトをした話を書いてみたいと思います。
一応、調査当時の話だったりするのもあるので、アプリケーションの構成としてはこんな感じです。詳細については後で補足します!
- Node.js 16.14.2
- express 4.17.3
- apollo-server-express 3.6.7
アプリケーションの構成
本題に入る前にコンテキスト共有の為に、今回対象になった GraphQL アプリケーションの立ち位置や責務を共有したいと思います。
まず、このアプリケーションの構成ですが Apollo を使用して GraphQL を構築しています。また、土台とするフレームワークとして express が採用されており、express で Apollo を使用する為に apollo-server-express を使用して 全体が構成されています。
アプリケーションの立ち位置としては、 Frontend と Backend の間にある BFF 層として位置しています。 主な責務として Frontend のリクエストに対して認証を行ったり、Backend にある REST API に対してリクエストを送りレスポンスを整形して Frontend に返すといった責務を担っていました。
アプリケーションを取り巻く課題
このアプリケーションが運用されて 一年程ですが、一つの課題を持っていました。
課題を認識する為にざっくりとしたコード例を紹介します。(諸々の定義等は Apollo ドキュメントの例を使用したいと思います)
apollo-server-express では Frontend からのリクエストを受けた後の振る舞いを resolvers に記述していきます。Apollo のドキュメントの例にこのアプリケーションの責務を加味したコード例が以下です。
const resolvers = {
Query: {
user(parent, args, context) {
// Frontからの認証
const { error, frontUser } = await context.authentication;
// Mark: error時のException
// BackendのAPIへリクエストする為のAPIClientを準備
const apiClient = await context.apiClientFactory(frontUser.token);
const result = apiClient.findUser(args.id);
// MARK: status_code チェック
// Frontへのレスポンスに合わせて整形
return {
id: result.id,
name: result.name,
};
},
},
};
ちょっと Apollo ドキュメントの例に大分無理矢理合わせた感じなので、frontUser と user の違いは何? とか突っ込み所はありますね・・・。
ここで共有したかった所としては、Resolver 自身が持っている責務が多いという点です。この Resolver では リクエストの認証、広義の Model と言えるデータ取得、レスポンスの加工 といった一般的な MVC の責務を全て担っています。
Production Ready GraphQL (紹介記事)でも「 優れた Resolver はほとんどコードを含んでない事が多い。 (略) GraphQL レイヤーで多くのロジックを処理し始める事は、時間と共に古くなったり間違ったりする危険をはらむ」と記述されています。
また、コードの観点でもこの Resolvers にテストを書くのは非常に面倒であるという課題があります。
まず、resolvers
をテストするためには parent
・args
・context
と GraphQL ライブラリの Resolvers の型にあったオブジェクトを用意する必要があります。 例えば、顕著な物だと context
オブジェクトです。GraphQL における context
はユーザーによって型定義をする事が出来るのですが、このアプリケーションでは認証 API に接続する為のauthentication
、バックエンドの API に接続する為の Client を作る為のAPIClientFactory
など複数のオブジェクトが含まれていました。ユースケースのテストをする為に本来ならアプリケーションの責務であるクラスを用意しないといけないというのが課題でした。
ドキュメント(Mocking)を漁ると ApolloServer 全体を Mock して振る舞いを変える事によって E2E テストのように Resolver の振る舞いを実行する事が出来たりするのですが、こちらも同じようにドメインロジックの動きを確認するためには準備する物が多すぎる課題があります。
アプリケーションの責務の変更
前節で課題について触れましたが、とはいえ中々課題を改善する優先度を上げられませんでした。 あくまで BFF 層として Backend 側の API を呼んでいるだけで十分薄くなっている事とかもその理由の一つです。
そんな中、この度の社内の開発案件によって以下のようにアーキテクチャを変更する必要性が出てきました。このアプリケーションが使われているプロダクトでイベント駆動設計を取り入れる為、GraphQL がただの BFF から API Gateway として振る舞うようになった事です。(大分コンテキストを端折っています。 私の個人ブログにはなりますが 在籍 1 年ブログ の新規技術のキャッチアップの節をご覧いただくともう少し背景が伝わるかもしれません。)
このまま開発を進めると、Resolvers は各ドメインロジックにおいてデータの在り処や確認すべきバリデーションルールの実装も知っている状態になります。また複数の永続化層の接続を知っておく必要も出てきて、GraphQL レイヤーがプレゼンテーション層・ビジネス層・インフラ層など複数のレイヤーにまたがる大きな物になってしまう、という事で課題が一回り大きくなってしまいました。
これにより、一気に課題を解決する優先度を上げてリアーキテクトを進めていく事にしました。
リアーキテクトの方針
Resolvers を工夫しても解決には至らないだろうな、という事はなんとなく感じていました。前述の通り、Resolvers にテストを施す事が面倒くさい事が理由です。
安定したアプリケーションアーキテクチャを構築する為には、GraphQL レイヤーの分割とアプリケーションとしてテストを書くのを面倒くさいと感じないような工夫をする事はどちらも必要不可欠なものとして考えていきました。「アプリケーションでテストが書ける状態にする」と「チームでテストが書けている状態にする」というのは別ベクトルで大変だし、重要な物ですよね。
最終的な方針としては、Resolvers を可能な限り薄くする方針としました。
Resolvers は Frontend からの認証とドメイン層のクラスを呼ぶだけにしてしまう事で、そもそも書くのが難しい Resolvers に対してテストを書く意義を薄くする方針として、その代わりにドメイン層のクラスをテストする事でアプリケーションとしてテストが書かれている状態にしていく方針です。
ディレクトリ構成は以下のようなイメージです。
src
├── schema
│ └── user
│ ├── typeDefs.ts
│ └── resolvers.ts
├── domains
│ └── user
│ └── entities
│ └── User.ts
│ └── usecases
│ └── FindUserUseCase.ts
│ └── repositories
│ └── UserRepository.interface.ts
└── infrastructures
└── user
└── repositories
└── MySQLUserRepository.ts
ドメイン層のクラスは 〇〇UseCase
というクラス名にしています。
Resolvers はリクエストのあったパラメータを原則バリデーションなしでこの UseCase クラスの interface に沿って渡します。パラメータのバリデーションは UseCase クラスの仕事で、バリデーションを通したパラメータを永続化層(Repository)に渡して Get するなり、Entity に変換した後で Save するなりをしています。
UseCase クラスは DI 出来るように DI ライブラリを導入しました。選定は npm trends で tsyringe
と typedi
と inversify
をピックアップした上で比較しました。
アクティブさだったり star 数だと inversify
が圧倒的だったのですが、最終的にはtypedi
に決めました。 bundle size の小ささ (inversify に比べて typedi が 5分の1
)、また今回重視したいことは多機能である事ではないので、最低限の機能が提供されている所、乗り換えを検討したときにある程度捨てやすそうな所(依存が Resolvers と UseCase 単位になるので後からリファクタリングしやすそうだなと感じた)ぐらいが選定の理由です。
という事で最終的にはこのようなコードになりました。Resolvers 単位ではドメイン層がカプセル化されたんでは無いでしょうか。
const resolvers = {
Query: {
user(parent, args, context) {
// Frontからの認証
const { error, frontUser } = await context.authentication;
// Mark: error時のException
const usecase = Container.get(FindUserUseCase);
const result = await usecase.run(args.id);
// Frontへのレスポンスに合わせて整形
return {
id: result.id,
name: result.name,
};
},
},
};
まとめ
という事で、リアーキテクトとしてこんな事が実現できました。
- Resolvers の責務が多かった所に対して ドメイン層部分を別クラス(ユースケース)に切り出す事で責務の限定された Resolvers を定義する事が出来た
- ユースケースをテストする為には 簡単なモック(interface を実装した Repository クラスを作りさえすれば良い)をする事さえすれば良いので、最小限の労力でテストを書ける状態の実現が出来た
「さ〜〜〜て!!!これで準備出来た!!! アプリケーションも書いて、テストも書くぞ〜〜〜!!!」 って思った矢先、ts-jest のタイムアウトが発生してテストが一向に通らなくなる事件が発生します。
15s => 0.6s と 25 倍の高速化が実現出来たんですが、それについてはまた別の記事にてご紹介させて頂ければと思います。
この記事は以上です。長文読んでくださってありがとうございました!
Discussion