【設計入門2026 #18】モジュールを分ける

テストとモジュール分割

テストの意義

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

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

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

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

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

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

モジュール分割

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

Image in a image block
  • 入力が与えられたら何かを行い出力を返す
  • 処理によって状態を書き換えることがある
    • 処理が終わっても残り続け、次以降の処理に影響を与えるもの

Webアプリケーションの場合

Image in a image block

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

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
type Sex = "man" | "woman";
type Age = { value: number }; // 0~99とする
type Income = { value: number }; // 0~999(単位:百万)とする

function isAcceptable(
  sex: Sex,
  age: Age,
  income: Income
): boolean {
  if (
    (sex === "man" && age.value >= 18) ||
    (sex === "woman" && age.value >= 16)
  ) {
    if (income.value >= 5) {
      return true;
    }
  }
  return false;
}

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

Image in a 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
// 関数の分割方法はあくまで一例です

type Sex = "man" | "woman";
type Age = { value: number };
type Income = { value: number };

function isMarriable(
  sex: Sex,
  age: Age
): boolean {
  return (
    (sex === "man" && age.value >= 18) ||
    (sex === "woman" && age.value >= 16)
  );
}

function isRich(income: Income): boolean {
  return income.value >= 5;
}

function isAcceptable(
  isMarriable: boolean,
  isRich: boolean
): boolean {
  return isMarriable && isRich;
}

// 使うとき
isAcceptable(
  isMarriable("man", { value: 30 }),
  isRich({ value: 10 })
); // true

この時のパターン数は

Image in a 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つに増加しています。

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

また、全部合わせたときに本当に動作するのか?という点でテストの信頼性を下げるということにもなります。

変更容易性

適切なモジュール分割は変更を容易にしますが、分割の粒度が小さすぎると逆に変更しづらくなることがあります。

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

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

どうすれば良いのか?

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

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