サバンナ便り ~ソフトウェア開発の荒野を生き抜く~

第7回テストコードの認知負荷
~テストの名前⁠構造⁠情報量を工夫する

開発の現場では、既存のテストコードから仕様を読み解く機会がよく訪れます。そのようなとき、テスト対象の仕様やテストの意図を読み解きやすいテストとそうではないテストがあることに気付きます。今回はテストコードの読み解きやすさに寄与する要素を考えます。

認知資源と認知負荷

人間は何かを読み解くときに脳のリソース(脳内のワーキングメモリ)を使います。リソースの量は有限で、個人差があります。このような脳のリソースは「認知資源」と呼ばれています。

人間が何かを読み解くときに認知資源が何にどのくらい割かれているかという概念を「認知負荷」と言います。⁠どのくらい」は状況に左右されます。たとえば、読み解く対象を知っているかどうかで認知資源が割かれる量は変化します。⁠何に」も状況に左右されます。読み解く対象が備えている複雑さに対して認知資源が割かれるのは当然ですが、ほかにもたとえば周囲のうるささ、文字の汚さ、説明の下手さに対しても割かれてしまいます。認知資源は有限なので、学習対象とは直接関係のない要素に割かれてしまうと宝の持ち腐れになってしまいます。

これらの概念を自動テストの文脈に当てはめると、テストコードの認知負荷とは、テストコードを読み解く際に読み手の認知資源が何にどのくらい割かれているかであると言えます。読み手がテスト対象の設計やテストの意図を読み解くことに認知資源を割けるようにしたいものです。そのためには、テスト対象とは本来関係ない要素に読み手が認知資源を割かなければならないような書き方を避けましょう。そこで重要なのがテストの名前、構造、情報量です。

アンチパターン1:情報が少なすぎる

実際に例を挙げてみましょう。みなさんは、コンビニのレシート発行システムの開発にあとから参加したばかりのソフトウェアエンジニアだとします。軽減税率が関わる税額の計算という複雑な対象領域の理解に取り組む過程で、既存のテストコードを読むときを想像してみてください。

test('税額計算結果が正しいこと', () => {
  addTea(2);
  addBeer(3);
  const { reduced, standard } = inv.total().tax;
  assert.equal(reduced, 19);
  assert.equal(standard, 40);
});

このコードからは、inv変数はどこから来たのか、addTeaaddBeer関数は何をしているのか、検証の期待値1940の狙いなどが読み取れません。事前状態の準備と検証内容の因果関係を読み解けないのは、準備の一部がテスト関数の外で行われているからです。テスト関数内から得られる情報が少ないため、認知負荷が高いのです。

アンチパターン2:情報が多すぎる

では、テスト関数外で行われている準備をすべてテスト関数の中に書けばよいかというと、そうでもありません。テストの意図とは直接関係ない要素がテスト関数の中に登場すると、読み手の認知資源がそれら不要な要素にも割かれてしまうからです。

次のコードでは、事前状態の準備のところにも結果の検証のところにも、税額計算と関係なさそうな要素が入っています。何がこのテストにとって大事なのか、読み手が取捨選択しなければなりません。しかもよく見てみると、テストの意図や結果の妥当性という肝心なところの情報は足りません。これでは読み手の認知負荷が不必要に高まってしまいます。

test('税額計算結果が正しいこと', () => {
  const inv = new SimplifiedInvoice({
    confirmationNumber: 'XXXX-XXXXX-XXXXX',
    paymentMethod: 'cash',
    paymentDate: new Date()
  });
  inv.add(new Item('技評茶', 130, {
    type: 'beverage',
    name: '飲料',
    tax_type: 'reduced'
  }), 2);
  inv.add(new Item('技評酒', 150, {
    type: 'liquor',
    name: '酒類',
    tax_type: 'standard'
  }), 3);
  const res = inv.total();
  assert.equal(res.total, 710);
  assert.equal(res.sub_total.reduced, 260);
  assert.equal(res.sub_total.standard, 450);
  assert.equal(res.tax_included, true);
  assert.equal(res.tax.total, 59);
  assert.equal(res.tax.reduced, 19);
  assert.equal(res.tax.standard, 40);
});

テストの名前、構造、情報量

ここまでの問題を踏まえて、テストの名前、構造、情報量に改善を加えてみましょう。

テストコードを書く際には、テストの名前が最も重要です。意図を込めて説明的に書きましょう。そうすれば、テスト実行結果のレポートは仕様の一覧となり、テスト失敗時には何が失敗しているか一目瞭然となり、テストコードを読む際には、対象のテスト関数を探し、その中身まで読みに行くべきかどうかの判断材料となります。

テスト名を「○○の結果が正しいこと」「正しく○○できていること」としてしまうのは、さまざまな現場で見てきたアンチパターンです。テストの書き手は何が正しいかを知っていますが、読み手にとっては自明ではありません。正しいとは何か、どうなると結果が正しいのかを書くのがテストです。

テスト関数の中を読みに行くべきと判断したら、次に構造を見ます。読み手はテスト関数の中を準備/実行/検証の3つのブロックに分けて読みます(Arrange/Act/AssertまたはGiven/When/Thenとも呼ばれます⁠⁠。このため、各ブロックの先頭にコードコメントが入っていると可読性が上がります。中でも特に検証のブロックは大事です。テスト名が示している意図は具体的にはどのようなゴールなのか、読み手は検証のブロックから読み取るからです。

describe('適格簡易請求書の消費税額計算', () => {
  test('税込価額を税率ごとに区分して合計した金額に対して税額を計算し、端数は切り捨てること', () => {
    // 準備(Arrange, Given)
    // 軽減税率の商品(飲料)と標準税率の商品(酒類)を
    // それぞれ複数購入している事前状態において
    const inv = createSimplifiedInvoice();
    inv.add(new Item('技評茶', 130, 飲料), 2);
    inv.add(new Item('技評酒', 150, 酒類), 3);
    
    // 実行(Act, When)
    // 合計、小計、消費税額などの計算を行った場合
    const total = inv.total();
    
    // 検証(Assert, Then)
    // 税率ごとの合計に対して1回ずつ切り捨てられること
    assert.deepEqual(total.tax, {
      reduced: 19,  // (130*2)*(8/108) => 19.25円
      standard: 40, // (150*3)*(10/110) => 40.90円
      total: 59     // 消費税額は合算するだけ
    });
  });
});

テスト関数内の情報量は、⁠テストの本体部分は、重要でない情報や紛らわしい情報は全く含まずに、テストを理解するのに必要な情報を全部含むべきである」[1]を目指します。テストの意図とは関係ない要素はヘルパ関数などに逃し(例ではcreateSimplifiedInvoice⁠、テストの意図を伝えるために必要な要素はテスト関数内に必ず登場させます。期待値の計算のところは、計算式をコメントで添えています。期待値の計算をコードで行ってしまうと「テスト対象ロジックのテストコードへの漏れ出し」になってしまい、偽陰性[2]を招いてしまうので注意してください。

おわりに

テストの名前、構造、情報量を工夫し、読み手が対象の理解に集中できるようにしましょう。

おすすめ記事

記事・ニュース一覧