😎

ユニットテストがしやすいコード構成について過去を振り返って考えてみた

に公開

はじめに

インテグレーションテスト主体でテストを書いていく場合、テスト実行時間が長い問題が出てきた。
(インスタンスをたくさん並べてパラレル実行にするとかやりようはあると思う)

解決するためには出来る限りユニットテストに寄せた方が良いが、どういったコード構成ならユニットテストに寄せやすいのか?考えた。

今あるアーキテクチャから選べばいいじゃんという話もあるが。。。

既存アーキテクチャだとなんかしっくりこないのでしっくりくるのを考えてみた。

全部に当てはまる正解はないので、自分の関わってきたシステムを振り返り、それらを踏まえて考えてみる。

実際に本番稼働していたシステムを元に考えるので本番稼働できるものにはなると思う。

あくまで自分基準での話にはなるので過度な期待はしないでください。

自分のWeb開発はJavaからスタートした話

日立のJP1Scriptが最初の仕事だったけど、WebシステムじゃなかったのでJavaを始点として振り返る。

またJava部分は、Javaを知らない人にもわかるように書いていく。

オールインワンJSPという名の始祖の話

Javaはビルドする言語でコード変更後に毎度フルビルドで結構時間がかかる(10分とか)のが当時ネックだった。
(PCのスペックもメモリ512MBで十分な量が積まれているという世界線の話)

ホットリロード機能もあったが使うライブラリによっては動作せず、JSPというテンプレートエンジンを使えばビルドを回避できると話題に。

え、じゃあ全部JSPに書いちゃえば問題解決じゃん。俺たち天才♪となって作成されたのが始祖オールインワンJSPの誕生経緯となる。

今振り返ると始祖誕生の瞬間であり悪夢の始まりでもあったなと。
(ちなみに僕が作ったわけではないし、僕は幸運にも始祖と遭遇はしなかった)

そういえば便利なものを作り出したらそれが破滅に繋がったと言う話はあるあるだなとも思う。

短期的にビルド時間10分が無くなって生産性が上がったが、コードの肥大化と共にカオスも広がっていき、どんどんメンテが厳しくなる悪循環に陥ったと聞いた。

そしてMVCが出てきた

Model View Controllerの頭文字を取ってMVC。

JSPはView担当なので、そこだけやって後はControllerとModelがやるという割り振り。

ざっくりとした区分けだけど、オールインワンよりはだいぶ良くなった感じ。

ただ、Controllerに処理を書きまくったり、Model側がFatModelと言われて膨れ上がったりと、もう少し区分けを細かくした方が良さそうな感じだった。

ドメイン駆動設計の本にあったレイヤードアーキテクチャ

  • View(画面処理)
  • Controller(リクエストなど捌く処理)
  • Usecase(メイン処理)
  • Service(ロジックやRepositoryを使うなど)
  • Repository(実際のドメインごとにInrfastructureを使って取得する)
  • Infrastructure(DBアクセス用の処理など)
    注)上位層が下位層の処理を呼びだすのは良いが、下位層が上位層を呼び出すのはNGとなる。

細かいところ違うかもだけど、ざっくりこんな感じだったはず。

MVCのModelがFatだった部分を、DBやAPIなどデータアクセスを軸に区分けできたのでスッキリした。

また、ServiceにはDBなどの単語が出てこないので、Infrastructureの変更がServiceには影響しないということができるようになった。
(厳密には影響があったりするがコードの変更は抑えられるし、一箇所に固まっているため変更しやすかった。)

こうしてみるとアーキテクチャ変遷は区分けをどのようにするか?というのがメインとなる印象を受ける。

DIP(Dependency Inversion Principle)「依存性逆転の原則」

  • 上位モジュールは下位モジュールに依存してはならない。両者とも抽象に依存すべきである
  • 抽象は詳細に依存してはならない。詳細は抽象に依存すべきである

という話で、AI曰く海外でも一般的な話らしい。

あと僕はDIP嫌い。

DIとかインターフェース実装の手間が増えて、コードも何が注入されているか?分かりづらくなり、実行時確認になってしまうという欠点がある。

また、取り替えやすいとか言うが、僕は実際に取り替えている例を見たことがなくとても懐疑的な概念。
(コードベタ書きではなくモジュールを組み合わせる設計のシステムに出来るなら機能しそうではある)
(言語にもよると思うがテストもモックライブラリ使ったりすると不要感も上がったりする。)

後述するがローカルでできることは全部やるという概念でいくなら、ローカルでできない部分(外部API実行とか)や環境ごとに差分があるところだけにDIを用意するぐらいで良いのでは?と思う。

他にも

  • ヘキサゴナルアーキテクチャ
  • クリーンアーキテクチャ

色々とあるが、レイヤ分けとか業務ロジックに外部APIなどが影響を与えないようにするなど、大きくは変わっていないように思う。

テストの話

t-wadaさんの記事が参考なる。なんでも良いが例えば下記など。
https://agilejourney.uzabase.com/entry/2023/11/30/103000

テストピラミッドがあり、ユニットテストが一番多くなるようにすると良いという話が載っている。

実際にロジックのテストはメソッドではなく純粋関数としてロジックを書いて、そちらをユニットテストに回すのが自分も良いと思う。

純粋なstatic関数はテストしやすいし、副作用がないのでおすすめ。

また、上記には書いてないかもだが、t-wadaさんはモック変え忘れかなんかで、テストは通ったが本番ではバグが出たという反省からローカルで出来ることは全てやると言っている。

自分の考えもテストの信頼性はインテグレーションテストが一番高いと思うので、ローカルで出来るだけ動かして書きつつ、ユニットテストで書ける部分はユニットテストに書くのが良いと思う。

Webシステムのざっくり処理フロー

  • Controller層
    • Requestパラメータのバリデーション処理1(必須や数値チェックなど単純なもの)
    • Requestをデータオブジェクトに変換する処理
  • Usecase層
    • Requestパラメータのバリデーション処理2(DBの存在チェックなど)
    • メイン処理
    • Reponseオブジェクトを返す
  • 他の層は割愛

フレームワークを使うとController手前でバリデーション処理1が動くことが多い。

それだとユニットテストが書けないのでフレームワークの処理があっても使わずに別のライブラリを使ったり独自に書いたりしたい。

バリデーション処理2の設置位置は判断が分かれるがUsecase層が良いと思う。

バッチなどでUsecase層を動かした場合は、データがあることはわかっていることが多いが最終チェックということで、あったほうが良いしControllerとも同じ処理にできるのは良い点と思う。

上記踏まえてのTypeScriptでのサンプルコード

// リクエストを受け取って簡単なバリデーションを行なってデータオブジェクトを返すバリデート関数
const validate1 = (request: any): { name: string } => {
  if (typeof request.name !== 'string') throw new Error('invalid')
  return { name: request.name }
}

// DBを参照するインテグレーションテストで実行するバリデート関数
type ExistsUserByName = (name: string) => Promise<boolean>
const existsUserByName: ExistsUserByName = async (name: string): Promise<boolean> => {
  return Promise.resolve(true) // Repository層を呼び出す想定だが、ここでは割愛
}

// コントローラー
class HogeController {
  static async greet(request: any) {
    // リクエストを受け取って簡単なバリデーションを行なってデータオブジェクトを返す
    // フレームワーク使うとユニットテストが書きづらいなら別途ライブラリを使うなど検討する
    const data = validate1(request)
    return HogeUsecase.greet(data)
  }
}

// ユースケース
const greetDeps = { existsUserByName }
class HogeUsecase {
  static async greet(
    data: { name: string },
    // DIするなら下記のように局所的な書き方にする
    // また、コード量削減のため変数から型を推論させる
    deps: typeof greetDeps = greetDeps,
  ): Promise<number> {
    const exists = await deps.existsUserByName(data.name)
    if (!exists) throw new Error('not found')
    return 1
  }
}

// 実行
;(async function () {
  // コントローラからの呼び出し
  await HogeController.greet({ name: 'John' })

  // テストなどでユースケースを呼び出す場合
  try {
    await HogeUsecase.greet({ name: 'John' }, { existsUserByName: async (name) => false }) // エラーを返す
    throw new Error('test error!!') // ここには来ない
  } catch (error) {
    console.log('ok!!')
  }
})()

実行できるように書いたので試したい人はプレイグラウンドでどうぞ
https://www.typescriptlang.org/play/?noCheck=true

この書き方の利点は、DIが関数単位なのでユニットテストが書きやすくシンプルな点。

欠点としては、DIは Composition Root で作るのがよいというルールに則っていない点。
注)Composition Rootは、起動時にRoot一箇所でDIを構築して以後変えないという原則

あとやろうと思えばコントローラでDIを変更できてしまうので、そこは出来ないようにガードコードなどをおいた方が良さそう。

またdepsが多すぎるUsecaseがあると定義部分が肥大化するのが課題かなと。
(やること多すぎるUsecaseを作らなければ良い気もするし、本当に一部だけDIを絞るならそこまで多くならなそうではある)

さいごに

今回の実装はTypeScriptの柔軟性のおかげで実装出来ている部分も多いと思うので、読んでいる方の現場の言語では実装できないかもしれません。

ただ、何かしらのエッセンスは抽出できると思うので、使えそうな箇所があれば使っていただきたいです。

レバテック開発部

Discussion