4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

テストは型の代わりにならないし、型はテストの代わりにならない

Posted at

(書きはしたのですが、文章がとっちらかってしまいました…。まあ駄文の存在も許されるのがインターネットのいいところだよね?ということで)

時々ネットで「テストコードを書けばいいのだから(静的)型はいらない」という意見を見る。さすがに型はテストの代わりにならない、というのは自明に近いものがあるので、「型があるのでテストは要らない」はほとんど見ないが、ちょいちょいTypeScript不要論の文脈で前者の意見は見られる。なので、これに関して書く。えっ?タイトルの「型はテストの代わりにならない」はって?まあ自明だけど一応書いただけだ。

ちなみにここで言うテストはテストのパターンを用意して、それがpassするか検証するテストのことを指している。
また、「型」は静的型付け言語での型を指す。実行時型のことを指し示すときは、「実行時型」と書く。(単に書くのがめんどくさい。)

ここで一つの疑問を投げかけよう。

型がない言語でテストが書けますか?

題材とするテスト

題材はJavaScript系言語でメジャーなJESTのドキュメントの一番最初のコードとする。

まずはテスト対象とするコードを示す。

sum.js
function sum(a, b) {
  return a + b;
}
module.exports = sum;

次にこれをテストするコードを示す。

sum.test.js
const sum = require('./sum');

test('adds 1 + 2 to equal 3', () => {
  expect(sum(1, 2)).toBe(3);
});

見ての通りだが、足し算を行う関数に、実際に1と2を入れて、3となることを確認している。単純で典型的でわかりやすいコードだ。

使ってみる

さて、別のコードから文字列を入れてみよう。

concat01.js
const sum = require('./sum');
let pyon = sum('Hoge', 'Pyon');

至極当然のように pyon の中身は"HogePyon"となる。

次に以下のコードを試す。

concat02.js
const sum = require('./sum');
let sanzyu = sum('10', '20');

sanzyu の中身は"1020"となる。

次に以下のコードを試す。

concat03.js
const sum = require('./sum');
let sanzyu = sum([10], [20]);

sanzyu の中身は"1020"となる。

次に以下のコードを試す。

concat04.js
const sum = require('./sum');
let sanzyu = sum({val: 10}, {val: 20});

sanzyu の中身は"[object Object][object Object]"となる。

次に以下のコードを試す。

concat05.js
const sum = require('./sum');
let nyan = sum(null, null);

nyan の中身は0となる。

次に以下のコードを試す。

concat06.js
const sum = require('./sum');
let nyan = sum(null, true);

nyan の中身は1となる。

次に以下のコードを試す。

concat07.js
const sum = require('./sum');
let nyan = sum(true, true);

nyan の中身は2となる。

次に以下のコードを試す。

concat08.js
const sum = require('./sum');
let nyan = sum(null, undefined);

nyan の中身はNaNとなる。

次に以下のコードを試す。

concat09.js
const sum = require('./sum');
let nyan = sum('', undefined);

nyan の中身は"undefined"となる。

次に以下のコードを試す。

concat10.js
const sum = require('./sum');
let num = {
  val: "100",
  [Symbol.toPrimitive](hint) {
    return parseInt(this.val); // 普通は hint で分岐する
  }
};
let nyan = sum(num, num);

nyan の中身は200となる。

テストはパスしている

ここで使われているsumの実装はsum.test.jsのテストをパスしている。テストコードというのは、それをすべてパスしていればテスト対象のコードが(ある程度)正しいと検証するためのコードである。実装とテスト、それぞれが正しいかどうかで4パターン考えられる。

  1. sum.jssum.test.jsが間違っている。
  2. sum.jsは間違っているが、sum.test.jsは正しい。
  3. sum.jsは正しく、上の動作はすべて仕様どおりであるが、テストコードが足りない。
  4. sum.jsは正しく、上の動作はすべて仕様どおりであり、sum.test.jsで十分に検証できている。

sum.jsが間違っているのだろうか?この場合はそれをpassしてしまったテストコードも間違っているといえる。それは本当だろうか?

それとも、上の動作はすべて仕様どおりであるが、テストコードが足りないのだろうか?

それとも、上の動作はすべて仕様どおりであり、かつ、その仕様はsum.test.jsで十分に検証できているのだろうか?

あらゆる製品には動かすための前提条件がある

ソフトウェアも含め、自動車や家電といったあらゆる製品にはその部品も含め動作させる前提条件がある。前提条件がない製品というのは私の知っている範囲ではない。最低でも未だ人類は太陽に突っ込ませることを許容した製品というのを作ったことがないはずである。だからマニュアルやデータシートには温度や湿度などの動作要件が書かれており、その範囲内でのみの動作を保証している。実際の製品試験も動作要件の範囲内での挙動を試験するので、例えば溶鉱炉の中に車を突っ込んで試験をするなどという意味不明なことはしない。溶鉱炉に突っ込んで車が壊れたなら、明らかに溶鉱炉に突っ込んだ人が、動作要件を守らなかったのが悪い。

ソフトウェアにおけるテストコードというのはこの動作要件の範囲内での挙動を検証するための試験に相当する。では、ソフトウェアにおける動作要件とはなんなのだろうか?今回のような関数記述であるなら、それは言語処理系と関数への引数であろう。(closureも考える必要があるが、ここでは無視する。)今回、の sum.js は数値同士の加算を想定したプログラムであり、そこに数値以外を代入されることは想定していない。だけど、先の sum.jssum.test.js のどちらともに、その想定に関して一切記述していない。このままの状態では暗黙的に動作要件を設けているだけで、このプログラムを利用するものにそれが伝わらない。

どのように動作要件を伝え、守らせるか

では、どのように動作要件を利用者に伝えるべきだろうか?愚直な方法はコメントを記述する方法だろう。

sum.jdoc.js
/**
 * 2つの引数を受け取り、それらを加算します。
 * @param {number} a 1つ目の引数
 * @param {number} b 2つ目の引数
 */
function sum(a, b) {
  return a + b;
}
module.exports = sum;

せっかくなのでJDocで書いてみた。確かにこれで伝わるだろう。電子機器などの製品の部品であれば、データシートに動作要件の記載を記載しているので、ほぼ同程度に達しているように見える。開発者が片っ端からコメントを読んで、適切に守れているか検証すれば良い。(なお実際の回路設計ではCADの類にやらせることになるのだが。)

だが、膨大な数のソフトウェアコンポーネントに対してそんな作業をしていられるだろうか?不可能ではないが、とんでもなく高いコストが掛かることは目に見えている。これを可能な限り削減できることに越したことはないのは明らかである。

もう一つ実行時型を検証するという方法もありえるだろう。

sum.rtti.js
function sum(a, b) {
  if (typeof(a) != "number" || typeof(b) != "number") {
    throw new TypeError("Invalid type");
  }
  return a + b;
}
module.exports = sum;

この挙動そのものを仕様として、利用者側に渡すということである。だが、これを渡された利用者はどのようにして、自分たちのコードが sum を呼び出すときに必ず数値を与えているか検証するのだろうか?製品を外に出したあとにこの例外が発生してしまうのは大変困る以上、気合で検証をする必要がある。利用者側でもテストコードを書いて、この例外を踏み抜いていないことを確認することになるが、そのためのテストパターンを増強する必要が出てくる。その利用者側コードでは当然のように他の関数も使っているだろうから、このテストパターンは倍々ゲームで複雑化していくことになる。

型検査

そこで「型」の登場である。

sum.ts
function sum(a:number, b:number): number {
  return a + b;
}
module.exports = sum;

こう書けば、人間の目にも number 型を2つ与えればいいことが明らかな上に、言語処理系がそれを検査することができる。実行時型が number 型であることを、実行するより前、開発者の手元にある段階で保証することができる。(もちろん型検査に関する仕組みが適切に機能している前提はあるが。)こうすることで、開発者はテストパターンを用意するときにも数値型が入力に来ることを想定できるため、現実的な思考規模でテストパターンを用意することができる。あらゆる入力が来るかもしれない、なんてことを考えなくて済むのだ。

ソフトウェアに限らず大半の製品というのは開発者の手元を離れたところで活躍することで価値を生み出すことができる。そのため開発者は実行時環境というのを完全に制御することが難しい。車を太陽に突っ込むのはあまりに非現実的だが、スマートフォンを水中に入れたり、ソフトウェアに対してトンチンカンなデータを入れることはごく簡単にできてしまう。だからこそ、マニュアルを書いたり、データシートを用意したり、型を書いたりすることで、それを可能な限り制御して、その上で初めて製品仕様どおり動いているかが検証可能になる。そして、ソフトウェアの世界ではとても強力な「型」という助っ人がいる。床一面に並べられた設計図を片っ端から検算するなどということをしなくても、特定のコンポーネントに入るデータをある程度絞り込むことができる。

もちろん、型の能力の問題から完全に設計を記述、検証することはできない。(それこそ定理証明が必要である。)だが、検証コストを劇的に下げることは可能なのだ。これはテストコードでは困難である。だからテストは型の代わりにならない。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?