テストとは《写像》である〜なぜ自動テストが書けないのか〜
自動テストが書けない根本的な理由を、数学の「写像」という概念を援用して考察する。テスト対象のロジックを写像(純粋関数)にすることがテストの前提である、という視点。
二・一五一四 写像関係は、像の要素とものとの関係からなる。
ウィトゲンシュタイン『論理哲学論考』1
この記事でいうテストとは、主に、Vladimir Khorikov『単体テストの考え方/使い方』 で言われている出力値ベーステストのことです。
概要
「テストを書きたい。でもテストが書けない。」 「テストを書く前に、コードを直す必要がある。」
そこら辺の話を数学の写像という概念を援用して書いてみたい。 といっても、筆者は文系なので、そんなに深い話はできない。 以下、「こう思いました」という随筆レベルの話であることはご了承されたい。
これを書こうと思った理由は、自動テストにおいては、実際のテストツールの使い方のようなテクニック的な話も大事だが、もっと根本の概念の理解が重要なのではと考えているからだ。
そして、まず、なぜテストが書けないかというと、 「テストというのは、ある構造をもっていて、その構造にあてはまらないものは、テストできない――」 と私は考えている。
その構造こそが「写像」だ。 だから、「テストを書く」というのは、テストを書く、ずっと前、 つまり、コードの一文字目をタイプするところからはじまっているのだ。
テストとは数学的な意味での「写像」
以下、この記事で述べたいことを列挙する。
- 自動テストとは、写像である。
- 自動テストが書けないのは、テスト対象のロジックが写像の形になっていないからである。
- つまり、コードを書いた後にテストが書けない状態というのは、ある意味、手遅れ。コードを書く最初から、「テスト対象のロジックを写像にする」ということを意識する必要がある。
- 自動テストを書くには、
- まず、テストしたいロジックを明確にし、
- そのロジックが写像になるようにコードを書く
- 必要がある。
- プログラミング的な写像とはつまり、純粋関数 である。
- 副作用のある関数と、純粋関数 を意識して分けて書く。
テストとは写像
プログラミングのテストとは、例えば、以下のようなものだ。
import { expect, test } from "bun:test";
function add(a: number, b: number): number {
return a + b;
}
test("2 + 2", () => {
expect(add(2 , 2)).toBe(4);
});
上の例だと、add という関数の入力と、出力がある。
そして、出力が、期待される値と等しいかという確認をしている。
これを疑似的に、関数の形であらわしてみる。
function テスト(テストしたい処理の出力) {
return テストしたい処理の出力 === 期待される処理の結果
}
さらに、「テストしたい処理の出力」とは、上のadd関数を見ればわかる通り、
「テストしたい処理への入力」にたいする出力だ。
まとめると、テストとは、
《「テストしたい処理」「テストしたい処理の入力」という 2 つの引数をとって、 「テストしたい処理の出力」と「期待される処理の結果」が同じかどうかを返す関数》
として見られる。
関数とは、写像の一種であるから、テストとは、写像ということになる。
数学的な「写像」とは何か
ここで、数学的な「写像」とは何かをもう少し突っ込んで考えていく。 写像の具体例で、わかりやすいものは、高校でならった、一次関数、二次関数などの関数だ。 また、写像は、英語では、「map, mapping」であり、つまりは、マッピングである。
群論の解説書から、写像の定義を以下引用する。
定義(写像) 二つの集合、$X$ と $Y$ があって、$X$ に属しているどの元(引用者註: 集合の要素)に対しても、$Y$ に属している元がそれぞれ一つずつ対応するとき、その対応のことを「$X$ から $Y$ への写像」といいます。$f$ が $X$ から $Y$ への写像であることを、
$f:X \longrightarrow Y$
と表記します。また、写像 $f$ によって、$X$ の元 $x$ と $Y$ の元 $y$ に対応することを、
$f:x \longmapsto y$
と表記します。 このとき、$y$ を「写像 $f$ による $x$ の像」といいます。「$x$ における写像 $f$ の値」ともいいます。$x$ における写像 $f$ の値を、
$f(x)$
と表記します。2
定義(始域と終域)
集合 $X$ から集合 $Y$ への写像 $f : X \to Y$ に対して、
- 集合 $X$ を「写像 $f$ の始域」といいます。
- 集合 $Y$ を「写像 $f$ の始域」といいます。
定義(定義域と値域)
集合 $X$ から集合 $Y$ への写像 $f : X \to Y$ に対して、
- 集合 $X$ を「写像 $f$ の定義域」といいます。
- 集合 ${ f(x) | x \in X }$ を「写像 $f$ の値域」といいます。
また、写像 $f$ の値域を「写像 $f$ の像」 ともいい、Image $f$ と表記します。すなわち、
Image $f = { f(x) | x \in X }$
大雑把にまとめると、入力に対して、出力が一対一に決まるのを写像という。 入力が、始域、出力が、終域である。 冒頭でのウィトゲンシュタインの引用文での「像」とは、上の定義で言えば、Image $f$ のようなものであろう。
意識して、副作用と純粋関数を分ける
上の定義から、もっと、プログラミングに引き付けて考えてみる。 さきほど、
テストとは、
《「テストしたい処理」「テストしたい処理の入力」というひたつの引数をとって、 「テストしたい処理の出力」と「期待される処理の結果」が同じかどうかを返す関数》
と述べた。 これが成り立つには、そもそも、「テストしたい処理」自体が、 「入力」->「期待される出力」という形でないと成り立たない。
具体的には、副作用がある形は、単純には、上の図式に当てはまらない。 例えば、次ような関数だ。
function updateState(a: number, b: number): number {
// StateA の値をaに更新。
updateStateA(a);
// StateB の値をbに更新。
updateStateB(b);
}
こういった、関数のテストを書くために、いわゆる「テストのテクニック」が必要になる。 だが、より根本的には、そもそも初めに処理を書くときに、テストしたいロジックが写像になるように書くことだ。
写像をイメージして関数を書く。 写像をイメージして書くというのは、どういうことか。 私は、引数は、写像の始域、戻り値は、写像の終域ということをイメージして書くことだと考えている。 これは、一般的なプログラミング用語だと純粋関数を書くということだ。 ただ、命令型の言語で、すべてを純粋関数として書くのは、現実的でない。 したがって、現実的には、副作用とテストしたいロジックをひとつの関数に混在させるのではなく、 意識して、副作用と純粋関数を分けることであると、私は考える。
余談 1 テスト駆動開発(TDD)とは、写像を強制させるノウハウのひとつ
テスト駆動開発(TDD)というものがある。 これは、私の解釈では、上に述べたようなテストしたい処理を自然に写像にする開発方式だと考えている。
「写像」がどうとかいう理論抜きにして、テストが通るかどうかという観点で、 実装すると、自然と、「テストしたい処理」が「写像」になるのだ。
余談 2 フロントエンドの自動テストは理論的に難しい
余談 2 として、フロントエンドの自動テストについて書く。 フロントエンドとは、「ユーザーがブラウザを操作して、画面がどう変わるか」 という部分がかなりの割合を占めている。
これは、ブラウザの UI 上で起こることで、単純な写像としての表現が難しい。
テストを書くと、 「ブラウザのこの要素で、このイベントがあり、それを受けて、UI が変化した。 UI が変化したのは、この要素があることから確認できる。」
といったものになる。
ウィトゲンシュタイン「写像関係は、像の要素とものとの関係からなる。」 これにならっていえば、フロントエンドの「要素とものとの関係」は、「風が吹けば桶屋が儲かる」ような、紆余曲折を経たものだ。
途中、様々な事象が連鎖して発生しており、単純な「始域-終域」の写像で表現するのは難しい。
結果、テストを書いても、複雑なものになりやすい。 そうすると、それを読むのも、維持するのも大変なものとなる。 これ以上、この件について、書くと、記事の主題から離れるのでやめるが、 フロントエンドのテストには、理論的な難しさがあると私は考えている。