テストをいっぱい書くために知っておきたいこと

エクストーンの豊田です。皆さん、テスト書いてますか?

最近、コードレビューでテストの不備や改善等をコメントすることが多くなってきていて、どういう観点でコメントしているかをちょっとまとめたいなと思い、この記事を書くことにしました。

テストピラミッドについて

テストはユニットテスト、結合テスト、e2eテストと、関与するコンポーネントが増えれば増えるほど複雑化し、テストの工数が上がります。なのでテストの大部分はコストの低いユニットテストでカバーし、そこで賄えない最小限のテストを結合テスト、e2eテストでカバーし、結果的にテストの数が「ユニットテスト>結合テスト>e2eテスト」になるという考え方です。過去の記事(https://design-tech.xtone.co.jp/entry/2023/03/24/151555)で書いていますので、興味がある方はこちらも見てください。

仕様をテストに落としていくために、まずはユニットテストで大半の仕様がカバーできるかどうかを考えることを最初にやるべきです。そのためにはテストコードだけでなく、テスト対象のコードが関与するコンポーネントの大小がキーになってきます。

副作用

プログラミングでは、ある関数を実行した際に返り値以外に影響があるようなものを副作用といいます。本来、数学的な意味での関数は引数が一致すれば必ず返り値が一致します。一方でプログラミングの関数では、そのスコープ外のメモリ領域を操作することが可能になっており、結果的に関数を実行したコンテキストによって結果を変えることが出来ます。

例えば以下のようなコードは実行するたびに返り値が変わるコードになります。

let i=0
const addFunc = (num) => {
  i = i + num
} 
addFunc(2)
console.log(i)

上記のコードのテストコードを書こうとした際、 addFunc 関数だけでなく、変数 i についても考慮したテストコードを書く必要があり、テストコードを書く難易度が上がります。一方で以下のように関数から副作用を追い出すようにすることでテストコードが書きやすくなります。

let i=0
const addFunc = (num, current) => {
  return num + current
}
i = addFunc(2, i)
console.log(i)

この辺りは利用している言語の特性を考えつつ、実装方法を考えてみてください。例えばオブジェクト指向のカプセル化と競合するんじゃないかと考える方もいると思いますが、オブジェクト指向のメソッドと関数は結構似て非なるものであると私は思っています。隠ぺい化されたプロパティ(privateなインスタンス変数等)に直接テストコードからアクセスするテストヘルパーを使う、あるいはその値を取得するための公開メソッド経由で結果を確認する、など言語に沿ったやり方があると思います。

大事なのは、「その関数(メソッド)、簡単にテストコード書ける?」という観点で設計をすることかと思います。そのためには以下のようなことを心がけるといいかと思います。

  • ひとつの処理の中で複数のことをやっていないか?
  • コンテキスト(変数やミドルウェア等)によって処理結果が変わるか?
    • 変わる場合、そのコンテキストは容易にテストコードで再現できるか?
  • 実装前にその処理のふるまいの定義が出来るか
    • パッとできない場合、処理内容が複雑すぎる可能性がある

境界値

プログラミングにおいて処理を実装する際に、引数に与えた値が一定の範囲を超えていたら振る舞いを変えるというケースはよくあるかと思います。

例えば折れ線グラフを作るようなプログラムを考えてみます。CPU使用率(0~100%)を10分単位でサンプリングしたものをグラフにする際、80%以上のものは赤い点で表示する、それ以外は青で表示するようにするとします。

一般にUIについてはテストが書きづらいと言われていますが、条件によって表示が切り替わるような箇所はその条件が正しく適用されているかどうかをテストコードで表現したい場所でもあります。この場合は、その条件を判断するべき部分を関数に切り出し、その部分に対してテストを書くことで解決します。

例えば、CPU率使用率を受け取り、ポイントの色を返す getPointColor という関数を定義します。

const getPointColor: (cpuUsage) => {
  if (cpuUsage >= 80) {
    return "#FF0000"
  } else {
    return "#0000FF"
  }
}

上記関数のテストコードは以下のようになります。

describe("getPointColor", () => {
  it("cpuUsageが82%の場合は赤(#FF0000)を返す", () => {
    const actual = getPointColor(82)
    expect(actual).toEqual("#FF0000")
  })

  it("cpuUsageが30%の場合は青(#0000FF)を返す", () => {
    const actual = getPointColor(30)
    expect(actual).toEqual("#0000FF")
  })
})

さて、上記のテストコードはいくつか問題があります。仕様は「80%以上の場合は赤、それ以外は青」でした。このコードが仕様を満たしているかどうかを確認するのに、上記のテストコードは十分でしょうか?例えばコードに不具合があり、81%の場合は青が返される場合や60%の場合でも赤が返されてしまうような問題は、上記のテストコードから検出することが出来ません。

ここで「境界値」という考え方を使います。返り値のパターンが変化する境界はどこでしょうか?80%ですね。この返り値のパターンが変化する値と、その直前の値を利用してテストを行うことを「境界値を利用してテストを行う」と表現します。

describe("getPointColor", () => {
  it("cpuUsageが80%の場合は赤(#FF0000)を返す", () => {
    const actual = getPointColor(80)
    expect(actual).toEqual("#FF0000")
  })

  it("cpuUsageが79%の場合は青(#0000FF)を返す", () => {
    const actual = getPointColor(79)
    expect(actual).toEqual("#0000FF")
  })
})

テストのパラメータ化

JavaScriptやTypeScriptのテストではjestを利用する方が多いと思います。jestでは入力値を期待値をリストにして渡すことで、様々な値を利用したテストの量産が楽になる記法があるので、テストを記述する労力の軽減や可読性の向上が見込めるようなら積極的に取り入れましょう。

describe("getPointColor", () => {
  it.each([
    // [CPU使用率, 期待値]
    [80, "#FF0000"],
    [79, "#0000FF"]
  ])("cpuUsageが%i%%の場合は%sを返す", (cpuUsage, expected) => {
    const actual = getPointColor(cpuUsage)
    expect(actual).toEqual(expected)
  })
});

例えば、「30%以下の場合はポイントの色を黒にしたい」という仕様が追加された場合、この条件を満たすテストコードは以下のようになります。

describe("getPointColor", () => {
  it.each([
    // [CPU使用率, 期待値]
    [80, "#FF0000"],
    [79, "#0000FF"],
    [31, "#0000FF"],
    [30, "#000000"]
  ])("cpuUsageが%i%%の場合は%sを返す", (cpuUsage, expected) => {
    const actual = getPointColor(cpuUsage)
    expect(actual).toEqual(expected)
  })
});

80/79という「赤/青」の境界値に加え、31/30という「青/黒」の境界値がテストコードに追加されました。境界値の考え方をテストコードに取り入れることで、テストコード自体がこの関数に仕様を表現できることに気付くと思います。いくつかのテストフレームワークになんとかspecという名前が付けられているのも、テストコードで仕様を表現すべきという思想の現れかと思います。

また、上記のようにテストコードをパラメータ化することによって、仕様追加の際に追記するテストコードがわずかで済むことが分かっていただけるかと思います。他の言語でも似たような機能が提供されていることが多いので、ぜひ使ってみてください。

カバレッジレート

カバレッジレートとはテストコードがテスト対象のコードのどれだけをカバーしているかという指標で、100%であればすべてのコードがテストコードによって実行されていることが保証されています。プロジェクトのテストコードがどれだけ充実しているかを表す指標になっているかと思います。

品質の観点でカバレッジレートの目標値みたいなお話もよくされるのですが、ここでは触れないことにします。

先ほど紹介した境界値を利用したテストコードとカバレッジレートの考え方を組み合わせることで、境界値を利用した最小限のテストであらゆる値に対してのテストが十分であることを保証することが出来るので、テストが十分かどうかの観点でカバレッジレートを計測するといいかと思います。

具体的なお話として、先ほどのグラフのポイントの色の話をします。凄く意地悪な質問として「-30%が黒になることは保証されているのか?」「なんか急に100%だけ色が白くなってしまったりすることがないことは断言できるのか?」というものが思いつきます。-30や100のテストケースは記述してないですもんね。

その際、テストを実行した結果のこの関数のカバレッジレートを見ることで保証はできないものの、現実的には起こりえないことが推察できます。

例えばすべての条件を網羅しているかどうかを表すC2カバレッジという指標があるのですが、これが100%であればテストコードで示したパターンでプログラム中のすべての条件をテストできていることが保証できます。その場合、「-30%が黒にならない」ケースにおいては境界値テストの観点からいくと別の境界値、すなわちプログラム上での条件分岐がない限りは実現不可能です。

const getPointColor: (cpuUsage) => {
  if (cpuUsage === 100) {
    // 100%の場合のみ色が白になってしまう不具合
    // こういうケースが混在している場合、前述のテストではカバレッジレートが100にならない
    return "#FFFFFF"
  } else if (cpuUsage >= 80) {
    return "#FF0000"
  } else if (cpuUsage <= 30) {
    return "#000000"
  } else {
    return "#000000"
  }
}

このようにカバレッジレートは境界値を利用したテストの正しさを裏付けるものであるとともに、テストケースが十分かどうかを測る指標となるわけです。

カバレッジレートをやみくもに100%にする目標を立てることはあまりお勧めしませんが、これを高く保つための書き方が出来るようになると、より質の高いテストコードが書けるようになると思います。

おわりに

テストコードをいっぱい書くために知っておきたいこととして、以下のことについてお話しさせていただきました。

  • テストピラミッドの考え方
  • 副作用のあるコードはテストが書きづらい
  • 境界値でテストする
  • テストケースをパラメータ化することで量産する
  • カバレッジレートを計測する

テストコードはやみくもに書けばいいというわけではなく、長く運用していく中で仕様が変わってしまった場合にそのテストコードのメンテナンスはできるのか?という観点も大事になってきます。その際、テストコードや対象のコードがシンプルであれば、そのテストコードの再利用が容易になったり、あるいは利用しないものであるという判断もしやすくなると思います。

シンプルなコード、シンプルなテストで、皆様素敵なテストコードライフを!