🚀 ニフティ’s Notion

🌞 【Webアプリ2024 #3】モジュールを分ける

テストとモジュール分割

テストの意義

今日の開発・運用においてソフトウェアテスト(自動テスト)は必須のものです。

単に書いたコードの正当性を確認するだけにとどまらず

  • 改修によって想定外の箇所が仕様を満たさなくなる(デグレード)ことの検知
  • 内部改善(リファクタリング)を行った場合に、動作が変わっていないことの担保

など、後の運用期間全てに渡って使われ続けます。

逆に自動テストがなければ、何か変更があるたびにそのシステムは仕様が変わってしまう可能性が出てきます。

そうならないためにはなるべく網羅性の高いテストを書き、維持する必要がありますが、どのようにすれば良いでしょうか?

モジュール分割

ソフトウェアとは、つまるところ以下のようなものです。

image block
  • 入力 が与えられたら何かを行い 出力 を返す
  • 処理によって 状態 を書き換えることがある
    • 処理が終わっても残り続け、次以降の処理に影響を与えるもの
  • 例Webアプリケーションの場合

これを十分にテストするためには、入力・状態の組み合わせに対して正しい出力・状態を返すことを保証すれば良いでしょう。つまり

f(入力,事前状態)=(出力,事後状態)f(入力, 事前状態) = (出力, 事後状態)

を、考えられる全てのパターンに対してチェックすれば良いことになります。

ところが現実には入力も状態も数・種類ともに膨大で、とても全パターンチェックできるようなものではありません。

そこでソフトウェアを細かい独立単位( モジュール )に分割し、パターン数を抑えようというのがテスト観点でのモジュール分割です。

「モジュール」をどういう単位で扱うかは場合によります。1モジュール=1クラス、1関数として扱う場合もあれば、複数のクラスや関数をまとめて1モジュールとすることもあります。

💡
簡単にするために、状態を持たない関数の例を取り上げます

マッチングサービスを作ることを考えます。このサービスの契約条件は以下です。

🤝
  • 結婚可能年齢に達している かつ 年収500万円以上
    • 2022年以前の基準(男性18歳、女性16歳)

この判定条件を実装すると、以下のようになるでしょう。

def isAcceptable(
  sex: "man" | "woman",
  age: Age, # 0~99とする
  income: Income # 0~999(単位:百万)とする
) -> bool:
    if sex == "man" and age.value >= 18 or sex == "woman" and age.value >= 16:
        if income.value >= 5:
            return true
    return false

この関数の動作を 完全に 保証するためのパターン数はどうなるかを考えると

image block
  • 2 x 100 x 1,000 = 200,000(通り)

となります。

さて、この条件において、「性別/年齢」と「年収」の条件は互いに独立していそうです。

なので関数を分割してみるとどうでしょう。

# 関数の分割方法はあくまで一例です

def isMarriable(
    sex: "man" | "woman",
    age: Age
) -> bool:
    return sex == "man" and age.value >= 18 or sex == "woman" and age.value >= 16

def isRich(income: Income) -> bool:
    return income.value >= 5

def isAcceptable(
    isMarriable: bool    isRich: bool) -> bool:
    return isMarriable and isRich

# 使うとき
isAcceptable(isMarriable("man", 30), isRich(10)) # True

この時のパターン数は

image block
  • isMarriable() : 2 x 100 = 200(通り)
  • isRich() : 1,000(通り)
  • isAcceptable() : 2 x 2 = 4(通り)
    • 計: 200 + 1,000 + 4 = 1,204(通り)

となります。掛け算だった部分が足し算になり、余計に増えた分を加味してもパターン数が大きく減少しました。

このように、 互いに独立であるものを関数やクラスに分割することで、考慮すべきパターン数を減らす ことができます

モジュール分割のトレードオフ

ではどんどん細かく分けていくべきなのか?というとそうでもありません。

モジュール分割にはデメリットも存在します。

パフォーマンス

モジュールに分けることで、関数呼び出しやデータコピーの回数は増加します。

上記例では関数呼び出しが1回 → 3回に増加しています。

この程度であれば誤差レベルではあるものの、取り扱うデータサイズや呼び出し回数などによっては現実的な問題になってきます。

可読性

モジュールに分けることにより、全ての処理を追うには複数のモジュールを追いかける必要が出てきます。

上記例では読む必要のある関数が 1つ → 3つに増加しています。

適切な分割で可読性が上がることもありますが、不用意な分割は処理があちこちに飛ぶ スパゲッティコード になってしまいます。

変更容易性

粒度の小さいモジュールに分割することにより、変更への対応しやすさは低下します。

上記例では「性別/年齢」と「年収」を完全に独立したものとして分割しましたが、「年収条件を男女で変える」ような改修が入った場合、関数の中身を書き換えるだけでなく、モジュール構成そのものを見直す必要があります。

💡
特に変更容易性の観点から、上記例の分割はあまり適切ではないということになります

どうすれば良いのか?

どういった基準で分割するのかという アーキテクチャパターン を決めて、開発者全員が従う、ということがよく行われます。

代表的なパターンはこの先の章で紹介します。