🧪

全域関数を使いこなせば異常系のテストコードが減るかもしれない仮説

2024/01/30に公開

はじめに

全域関数を使いこなせば異常系のテストコードが減るかもしれないという仮説についてつらつらと書いていきます。
本記事は筆者自身も実務レベルでどう組み込んでいくのか生煮え状態であり、議論をするための土台くらいにしかならないレベルのクオリティで出していることをお許しください。

全域関数とは全ての入力値に対して対応する出力値が一つに決まる関数を指します。いわゆる関数型プログラミング言語の考え方です。
ある特定の入力(引数)に対して例外を投げてしまったり、処理が止まってしまったり、同じ入力なのに出力が都度変わってしまったりするとその関数は全域関数ではありません。

"Domain Modeling Made Functional" から引用したものとしては以下が定義になります。

A mathematical function links each possible input to an output. In functional programming we try to design our functions the same way, so that every input has a corresponding output. These kinds of functions are called total functions.

Scott Wlachin, 2018, Domain Modeling Made Functionalより

https://learning.oreilly.com/library/view/domain-modeling-made/9781680505481/

本記事ではこの全域関数一体何がいいの?というところを色々書いていこうと思っています。そのあとに最初の一文の通り、全域関数をつかいこなせば異常系のテストコードを減らせるかもしれないという仮説を書きます。

全域関数(Total Functions)とは?

全域関数とは前述の通り全ての入力値に対して出力値が一意に決まる関数を指します。

では全ての入力値に対して出力値が一意に決まらない関数とはなんでしょうか?
例として代表的なものが割り算です。

例えばある数値をとってその数字で12を割る関数を考えてみます。
以降のコードはScalaを使って書いていきますが、おそらく雰囲気で読めるレベルのコードになっているかと思います。

def divide12By(denominator: Int): Int = {
  12 / denominator
}

この入力と出力の関係を表にマッピングしてみます。

入力 出力
4 3
3 4
2 6
1 12
0 ArithmeticException

表をみると出力値が決まらない入力値としてゼロがあります。
四則演算において足し算、引き算、掛け算は全域関数ですが、割り算は0を母数にとると異常終了してしまうため全域関数とは呼べません。

さて、この関数を全域関数にするには二つの方法があります。
一つ目は異常値を型で表すようにすることで、二つ目はそもそも正常な値しか出力しないように入力の型を限定することです。

これ以降の関数を全域関数にする方法は以下のkawasimaさんの記事を大いに参考にしています。
https://scrapbox.io/kawasima/Totality

1. 異常値を型で表すようにする

例外が投げられていたものを型で明示的に返却できるようにします。このアプローチはOption、Maybe、Either、Resultなどでnullを型で表現したり、エラーを型で表現したりするアプローチです。

例えば、12を割る関数において、0で割るという例外状況を避けるために、Option型を使用します。Option型は、値が存在する場合はSomeでラップし、存在しない場合はNoneを返すことで、例外を型システムの一部として扱います。

def divide12By(denominator: Int): Option[Int] = {
  if (denominator == 0) None
  else Some(12 / denominator)
}

この入力と出力の関係を表にマッピングしてみます。

入力 出力
4 Some(3)
3 Some(4)
2 Some(6)
1 Some(12)
0 None

ゼロの入力値に対して出力値が一意に決まらなかったのに対してNoneという出力値が一意に決まるようになりました。

2. 入力値を全域性が担保できる範囲に限定する

もう一つ方法があります。それは「入力値を全域性が担保できる範囲に限定する」ともいえますし、「正常な値しか出力しないように入力の型を限定すること」ともいえます。

入力値を全域性が担保できる範囲に限定するとはどういうことか説明していきます。
これは今回の例でいうとゼロが異常値を返す入力値になりますがその値をそもそも関数が受け取れないように制限することを指します。

ここでは、不正な引数(この場合は0)を受け入れないようにするために、NonZeroIntというカスタム型を作成し、それを使用して全域関数を実装する方法を解説します。この方法により、関数の呼び出し側が不正な値を渡すことができなくなり、エラーハンドリングの必要性が外部に移ります。

case class NonZeroInt private (value: Int)

object NonZeroInt {
  def apply(value: Int): Option[NonZeroInt] = {
    if (value != 0) Some(new NonZeroInt(value))
    else None
  }
}

def divide12By(denominator: NonZeroInt): Int = {
  12 / denominator.value
}

// 呼び出し元
val input1 = NonZeroInt(1).getOrElse(throw new IllegalArgumentException("ゼロは入力不可です。"))
println(divide12By(input1)) // 12を出力

// ゼロの時はエラーを発生させる
val input2 = NonZeroInt(0).getOrElse(throw new IllegalArgumentException("ゼロは入力不可です。"))
println(divide12By(input2)) // 実行時エラー「ゼロは入力不可です。」

// ゼロの時は何もしない
val input3 = NonZeroInt(0)
input3.foreach(divide12By) // 何もされない

println(divide12By(0)) // コンパイルエラー

この入力と出力の関係を表にマッピングしてみます。

入力 出力
NonZeroInt(4) 3
NonZeroInt(3) 4
NonZeroInt(2) 6
NonZeroInt(1) 12
0 ArithmeticException

divide12Byという関数だけでみると0という入力値を受け取ることができなくなっています。結果0以外の全てのIntを入力値にとる関数となり、割り算は0以外の全てのIntに対して出力値が一意にきまるため全域関数となります。

とはいってもNonZeroIntのインスタンスを作成するときに異常値をリターンする(ここを1のパターンでOptionやEitherにしてもよいし、例外でもよい)ので、エラーハンドリングを呼び出し側に強制するようにしたともいえます。

1のメリット: 異常値を型で表すとなにがいいのか?

1の異常値を型で表すようにする手法と例外をつかった手法のメリットとして一般的に挙げられるのは、呼び出し側にエラーハンドリングを強制できることにあります。
Javaだと検査例外といって例外のハンドリングを呼び出し元で強制させる手法がありますが、KotlinやJavaScriptなどにはありません。

雑にメリットをChatGPTに出してもらいました。

  1. 明示性と可読性の向上:
    エラーを表現する型を使用することで、関数がエラーを返す可能性があることが明示されます。これにより、エラーハンドリングが必要な箇所がコード上で明確になり、プログラムの可読性が向上します。

  2. 安全性の向上:
    型システムを利用してエラーを表現することで、プログラマーはエラーを無視することなく、それに適切に対処する必要が生じます。これにより、予期せぬ例外によるランタイムエラーを減らすことができます。

  3. 関数型プログラミングとの相性:
    OptionやEitherなどの型は、関数型プログラミングのパターンとよく合います。これらの型は、関数の合成やチェーン処理を容易にし、より宣言的なコーディングスタイルを促進します。

  4. 型安全性:
    型システムを使用してエラーを扱うことで、型安全性が向上し、コンパイル時に多くのエラーを検出できます。

  5. エラー情報の柔軟性:
    EitherやResult型を使用すると、エラーに関連する追加情報(エラーメッセージ、エラーコードなど)を簡単に付加できます。

デメリットは省きますが、主に学習曲線や型情報を付加することによるオーバーヘッドなどの観点で議論されることが多いです。

ただこの辺の議論はすでにし尽くされているので、今回は2の入力値を全域性が担保できる範囲に限定するアプローチについてその有用性を考えていきたいです。

2のメリット①: 入力値で全域性が担保できる範囲に限定すると何がいいのか?

メリットとしては「12を0で割ることはできない」という一番重要なロジックを型のみで表現できることかと思います。

def divide12By(denominator: NonZeroInt): Int

視認性もそうですが、この関数をみるだけで「あ、ゼロは受け付けないんだな」ということが内部の実装を見ずにわかります。
また、視認性だけでなく、上述のとおり型による表現になるためコンパイルフェーズでエラーを確実に検出することができます。

2のメリット②: 入力値で全域性が担保できると異常系のテストコードを省けるか?

ここから少し強めの仮説を展開していきます。

上述の通り「12を0で割ることはできない」という仕様をコンパイルフェーズで担保できるようになりました。
そのため、この仕様に関してはテストを書く必要がありません(コンパイルエラーをテストするコードは書けないし意味がない)。

残る異常系の仕様としては「0が入力された時に『ゼロは入力不可です。』というエラーメッセージを返す」という仕様があります。

あえて書き出すなら仕様は下記の3つです。

  1. 正常系: 0以外の整数が入力された時にその整数で12を割る
  2. 異常系: 0を入力することはできない
  3. 異常系: 0をに入力した時に「ゼロは入力不可です。」というエラーメッセージを返す

2と3はほぼ同じ(3は2を兼ねる)ですが、あえて分けて記述しています。

その上でこれらの仕様のどこに注力してテストコードを書くべきでしょうか?自分なら1を書いて3は書かない、もしくはNonZeroIntのユニットテストとして記述して、実際に12を0で割るようなテストケースは書かないと思います。

それはなぜかというと2と3を比べた時の重要さにおいて2の仕様の方が圧倒的に重要であり、3のエラーメッセージはせいぜいメッセージを間違えたところで対した問題にならないからです。

「入力値で全域性が担保する」 とはいわば、 「異常値の判定とエラーハンドリングを分割すること」であり、異常値の判定がコンパイルフェーズで検証できていればエラーハンドリングの検証はプロダクトの品質目標によりますが二の次でも良いとも言えます。

この考え方は色々なところで適用できると思っています。
異常値と正常値を返す関数をまず正常な入力→正常な出力だけに限定した関数に切り分ける。そして異常値の判定とエラーハンドリングを分ける。それをすることで異常値の判定をコンパイルフェーズで検証できるようにする。
これができれば少なくとも統合テストレベルで異常系のテストコードを省けるようになり、プロダクト全体の保守性を上げることができると思いました。

以上生煮え状態ですが、読んでいただきありがとうございました。

株式会社ログラス テックブログ

Discussion