テストの期待値が実装と同じ場所から来ても良いのか
きっかけ
同じ振る舞いを検証する二つのテストがあります。
test('税込価格が正しく計算される', () => {
const price = 1000;
const result = calculatePriceWithTax(price);
expect(result).toBe(1100);
});
import { TAX_RATE } from './config';
test('税込価格が正しく計算される', () => {
const price = 1000;
const result = calculatePriceWithTax(price);
expect(result).toBe(price * (1 + TAX_RATE));
});
どちらも同じことを検証しているのに、期待値の出どころがまったく違います。前者は 1100 をベタ書きし、後者はconfigからimportしています。この違いに意味はあるのか。調べていくうちに、もう少し根本的な問いに行き着いたので、整理してみます。正しい答えが出せているかは分かりませんが、考える材料にはなるかもしれません。
テストオラクルという考え方
1978年、William E. Howdenが「テストオラクル」という概念を提唱しています。Howdenはその後1981年の著作で、この考え方をより明確に述べています。
The use of testing requires the existence of an external mechanism which can be used to check test output for correctness. This mechanism is referred to as the test oracle.
(テストの使用には、テスト出力の正しさをチェックするために使用できる外部メカニズムの存在が必要である。このメカニズムをテストオラクルと呼ぶ。)
ここでの「外部」という言葉が重要なようです。テストにおいて正しさを判定する情報は、プログラム自身以外の何かから供給されなければならない、ということです。
この考え方はその後、Elaine Weyukerによる「テスト不可能性」の定義や、Cem Kanerによるオラクル問題のテスト教育における重要性の強調、James BachとMichael Boltonによるオラクルの一貫性ヒューリスティクスなど、さまざまな形で発展しています。
Boltonは特に、オラクルの目的は「正しさの証明」ではなく「問題の発見」であると述べています。この区別は、今回の問いに直接関わってきます。
configをimportするテスト
冒頭の二つ目の例、configからimportする書き方をもう少し詳しく見てみます。
import { TAX_RATE } from './config';
test('税込価格が正しく計算される', () => {
const price = 1000;
const result = calculatePriceWithTax(price);
expect(result).toBe(price * (1 + TAX_RATE));
});
このテストは合理的に見えます。DRYの原則にも沿っていますし、値の変更が一箇所で済みます。
ただ、Howdenの原則に照らすと、このテストには問題がありそうです。テストの期待値が実装と同じ config から来ています。もし誰かが TAX_RATE を誤って 0.01 に変えたとしても、テストは通り続けます。テストが実装の鏡になっていて、鏡は元の姿が歪んでいても同じ歪みを映すだけ、ということになるかと思います。
ベタ書きすれば解決するのか
では、冒頭の一つ目の例のように 1100 をベタ書きすればいいのかというと、そう単純でもなさそうです。
test('税込価格が正しく計算される', () => {
const price = 1000;
const result = calculatePriceWithTax(price);
expect(result).toBe(1100); // 税率10%として
});
テストに 1100 とベタ書きすることで、期待値は実装から独立します。TAX_RATE が誤って変更されればテストは失敗し、問題を検知できます。
ただ、これはこれで別の問題があります。この 1100 はハードコードされた値であり、TAX_RATE が意図的に変更されたときにもテストが失敗します。それは「問題の発見」ではなく「テストのメンテナンス漏れ」でしかありません。
さらに、この 1100 の根拠が「configファイルに0.1と書いてあるから、1000 × 1.1 = 1100」というだけであれば、configの値を手で計算しただけであり、ハードコードの二重管理と本質的に同じです。
一方、「法律で消費税率が10%と定められているから」が根拠であれば、それは実装とは独立した外部の事実に基づいており、Howdenの言う「プログラム自身以外の情報源」から来ていることになります。
つまり、同じ 1100 というベタ書きでも、期待値の根拠をコードを見ずに説明できるかどうかで、ハードコードの複製なのか独立したオラクルなのかが変わるようです。もっと良い基準があるかもしれませんが、自分が調べた範囲ではここに行き着きました。
DRYとの関係を考え直す
ここまでの話を整理すると、configをテストからimportする書き方はDRYに従った結果であり、ベタ書きはDRYに違反するように見えます。DRYに従えばオラクルの独立性が損なわれ、オラクルの独立性を守ればDRYに違反する。二つの原則が衝突しているように見えます。
ただ、DRYの原典に立ち返ってみると、実は衝突していない可能性もありそうです。
Andy HuntとDave Thomasは「The Pragmatic Programmer」の20周年記念版で、DRYの誤解について明確に述べています。
DRY is about the duplication of knowledge, of intent. It's about expressing the same thing in two different places, possibly in two totally different ways.
(DRYとは知識の重複、意図の重複のことである。同じことを二つの異なる場所で、おそらく二つの全く異なる方法で表現することである。)
そして、「Not All Code Duplication is Knowledge Duplication(全てのコード重複が知識の重複ではない)」という節で、年齢のバリデーションと数量のバリデーションのコードが同一でも、それは異なる知識を表現しているのであり、DRY違反ではない(偶然の一致である)と述べています。
これをテストの期待値に当てはめてみます。プロダクションコードの TAX_RATE = 0.1 は「このシステムは税率0.1を使って動く」という知識です。テストの expect(result).toBe(1100) は「税率10%のとき1000円の税込価格は1100円であるべきだ」という知識です。
この二つは、たまたま同じ 0.1 という値を含んでいますが、表現している知識は異なります。前者はシステムの設定であり、後者はビジネスルールの検証です。Pragmatic Programmerの定義に厳密に従えば、テストで 0.1 をベタ書きすることはDRY違反ではなく、「異なる知識がたまたま同じ値を持っている」だけ、ということになりそうです。
つまり、DRYとオラクルの独立性は、DRYの本来の定義に立ち返ればそもそも衝突しないのかもしれません。衝突しているように見えるのは、DRYを「コードの重複を排除せよ」と狭く解釈した場合だけ、ということになります。
もちろん、この解釈が正しいかどうかは自分には断言できません。ただ、原典の定義からすると、テストの期待値をベタ書きすることをDRY違反と呼ぶのは少し違うのかもしれない、とは思いました。
設定値の共有が危険になるとき
話をconfigの共有に戻します。ベタ書きの根拠がある場合はそれでよいとして、問題はもう少し構造的なところにもありそうです。
全てのユニットが同じconfigの定数を参照する構造では、configの値が間違っていたとき、その値を参照する全てのユニットが同じ間違いを共有します。テストも同じconfigを参照していれば、テストも含めて誰も気づけません。これはマイクロサービスであれモノリスであれ関係なく、単一の情報源への依存が生むリスクです。
これをユニット単位に細分化して考えると、各ユニットのテストが入力値を「正しいはず」と信じてそのまま使っている限り、その値の誤りはどの層のテストにも引っかからないまま伝搬していくことになります。ちょっと過激な見方かもしれませんが、論理的にはそうなるはずです。
値そのものではなく性質を検証する
とはいえ、全ての設定値にベタ書きの期待値を用意するのは現実的に難しいです。税率には法律という根拠がありましたが、たとえばリトライ回数はどうでしょうか。「3回」の外部根拠を求めるのは無理がある場合もあります。こういった値に対しては、独立したオラクルを見つけること自体が困難です。
そこで有効かもしれないのが、値そのものではなく、値の性質や関係性を検証するというアプローチです。
test('リトライ回数が妥当な範囲にある', () => {
expect(MAX_RETRY).toBeGreaterThan(0);
expect(MAX_RETRY).toBeLessThanOrEqual(10);
});
test('リトライ回数とタイムアウトが矛盾しない', () => {
expect(MAX_RETRY * RETRY_INTERVAL).toBeLessThanOrEqual(TIMEOUT);
});
このようなテストであれば、値を変更してもテスト自体を直す必要はありません。そして、「意図しない壊れ方」だけを検知できます。制約には設計意図という外部根拠があるため、Howdenの原則にも沿っているのではないかと思います。
結局はトレードオフ
Howdenの原則は理論的に正しいです。全ての期待値は実装から独立すべきだという主張も、論理的には正しいです。ただ、全ての設定値に対して独立した検証をかけるのは現実的ではありません。
だからこれはトレードオフなのだと思います。
重要なのは、トレードオフしていることを自覚しているかどうかではないかと思います。configを共有するにしても、「本来は独立すべきだが、コストとの兼ね合いでここは共有する」と判断しているのと、何も考えずに共有しているのでは意味がまったく違います。
最初の問いに戻ると、期待値をベタ書きするかconfigからimportするかは、どちらが正しいという話ではなく、その選択が意識的になされているかどうかが重要なのだと思います。
テストの価値はテストの形ではなく、その背後にある判断の質で決まるのかもしれません。見当違いなことを言っているかもしれませんが、調べていてそう感じました。
参考
- William E. Howden, "Theoretical and Empirical Studies of Program Testing", IEEE Transactions on Software Engineering, 1978
- William E. Howden, "A Survey of Dynamic Analysis Methods", in Software Validation and Testing Techniques, IEEE Computer Society, 1981
- Andy Hunt, Dave Thomas, "The Pragmatic Programmer: Your Journey to Mastery, 20th Anniversary Edition", Addison-Wesley, 2019
- Earl T. Barr, Mark Harman, Phil McMinn, Muzammil Shahbaz, Shin Yoo, "The Oracle Problem in Software Testing: A Survey", IEEE Transactions on Software Engineering, 2015
- The Oracle Problem - YLD Blog
- The Oracle Problem and the Teaching of Software Testing - Cem Kaner
- Oracles Are About Problems, Not Correctness - DevelopSense
- The DRY Principle and Incidental Duplication - Anthony Sciamanna
Discussion