[初心者向け]ちょっとパラメーターや結果が違うだけの似たようなテストを書く時に便利な機能と考え方

テストコードを書くときの「ちょっとパラメーターと期待する結果が違うだけなのに似たようなテストが沢山書かれているなぁ」と感じる時に便利な機能をご紹介します。難しい言葉だとパラメーター化テストと呼ばれますが、今回はカジュアルに使い所と使い方をご紹介します。
2024.04.03

こんにちは。AWS事業本部モダンアプリケーションコンサルティング部に所属している今泉(@bun76235104)です。

「ちょっとパラメーターが違うだけなのに、同じようなユニットテストを何回も書きたくないなぁ」

などと思ったことはありませんか?私はあります。

そんなときはパラメーター化テストという機能を使えば便利であり、vitest,jest,JUnitなどのテスティングフレームワークの多くにはこの機能があります。

今回はVitestというテストツールを使った具体的な実装方法と、「じゃあどういう風に使うのよ」という個人的におすすめな使い所をご紹介します。

いきなりまとめ

  • test.each または it.eachでパラメーターや期待する結果がちょっと違うだけのテストをまとめて書ける
    • パラメーター化テストという機能です
  • 何でもひとまとめにするのではなく、「テスト結果をグループに分けた場合」ごとにまとめると見やすくて、意図も伝えやすいと思います
    • 同値分割・境界値分析と呼ばれるテスト設計手法の考え方が入っていますが、今回は言葉を覚えていただく必要はありません
# テスト結果の例
 src/domain/allowance/housingAllowance.spec.ts (8)
   # 住宅手当の計算
   ✓ HouseAllowance (6)
     # 自宅からオフィスまでの距離が2km以下なら10,000円
     ✓ returns 10000 when the distance is less or equal than 20000m(distance: '2km')
     ✓ returns 10000 when the distance is less or equal than 20000m(distance: '1.5km')

     ------------------------ここで区切る

     # 自宅からオフィスまでの距離が5km以下なら5,000円
     ✓ returns 5000 when the distance is less or equal than 50000m(distance: '5km')
     ✓ returns 5000 when the distance is less or equal than 50000m(distance: '4.5km')

     ------------------------ここで区切る

     # 5kmよりも遠い場合は0円(そんなぁ)
     ✓ returns 0 when the distance is more than 5km(distance: '5.1km')
     ✓ returns 0 when the distance is more than 5km(distance: '10km')

テスト対象の機能について

今回は住宅手当の計算をテスト対象とします。住宅手当は以下のルールで計算されます。

  • 住宅手当は自宅からオフィスまでの距離によってのみ決まる
    • 直線距離で計算される
  • 自宅からの距離に応じて以下の金額が支給される
    • 2000m以下:10,000円
    • 5000m以下:5,000円
    • 5000mよりも遠い場合:0円
  • テスト対象の関数には自宅からの距離が渡されるため、その距離に応じて金額を返す

今回は「この関数に距離を渡したら正確な金額を返すか」というところにフォーカスしてテストを書いていきます。

実際のコード
import { Distance } from "../distance/distance"
import { Money } from "../money/money"

export class HouseAllowance {
  constructor(private distance: Distance) {}

  // この関数をテストする
  getAllowance(): Money {
    const meters = this.distance.toMeters()
    // 会社からの距離が2000m以下なら10,000円
    if (meters <= 2000) {
      return new Money(10000)
    }
    // 会社からの距離が5000m以下なら5,000円
    if (meters <= 5000) {
      return new Money(5000)
    }
    // それ以外は0円
    return new Money(0)
  }
}

テストケースを考えてみる

全部のパターンをテストはできない

※ 以下、あまり厳密な言葉を使わずにカジュアルなイメージで説明させていただいております。

さて、ではどのようなテストケースが必要でしょうか?

0mの場合,1mの場合,2mの場合・・・,1000mの場合,といったようにあらゆるパターンをテストすることは現実的ではありませんよね?

実際 JSTQB というテスト認定資格において「ソフトウェアテストの7原則」というものがあり、その中でわざわざ「全数テストは不可能」ということがはっきりと書かれているほどです。

全部のケースをテストする時間はないから」、「なんかいい感じに少ないテストケースで効率的にテストしたいなぁ」という発想になりますよね。

そこで、今回は「同値分割」と呼ばれる手法を使って、いい感じにテストケースを絞り込んでいきます。

難しい言葉を使っていますが、「同じふるまいをするものは一つのグループとしてまとめてみよう」というくらいのカジュアルな考え方でやってみたいと思います。

グループに分けて考えてみる

どうも「2000m以下」とか「5000m以下」の部分が怪しいですよね。

その他にも数直線的に考えてみると、0mってどうなるんだ?マイナスの数値はどうなるんだろう?そもそも1m単位でいいの?という疑問が湧いてきますが、一旦以下のようにグルーピングしてみました。

同値分割イメージ図

※ ↑の図は https://www.jasst.jp/symposium/jasst19tohoku/pdf/S4.pdf の資料を参考にさせていただき作成しております

気になるのでマイナスの数値について仕様を確認してみたところ、この関数が呼ばれる以前でマイナスの数値がエラーとなり、この関数にはマイナスの数値が渡されることはないということがわかりました。

マイナスの数値をエラーにしているコード
export class Money {
  constructor(private value: number) {
    if (value < 0) {
      throw new Error("金額は0以上でなければなりません")
    }
  }
}

よって今回はマイナスの数値については考えずにテストケースを考えることにしました。

以下のような入力値をテストすることにしました。

入力値 期待する結果 選定した理由 備考
0m 10000円 下限の境界のため (境界値) オフィスに住んでるってことかい?
1000m 10000円 グループの代表的な値だから(代表値) 特になし
2000m 10000円 上限の境界のため (境界値) 特になし
2001m 5000円 5000円グループの下限の境界のため(境界値) これで半分になるの悔しい
3000m 5000円 5000円グループの代表的な値だから(代表値) 特になし
5000m 5000円 5000円グループの上限の境界のため(境界値) 特になし
5001m 0円 0円グループの下限の境界のため(境界値) この人可哀想過ぎる
10000m 0円 0円グループの代表的な値だから(代表値) 特になし

それぞれのグループの代表っぽい値を決めつつ、グループの境界となるような値を選定しています。

なお、このような境界となる値をテストすることを「境界値分析」と呼んだりします。

あまり意識せずに、このようなテストケースを書ける人もいらっしゃると思うのですが、言葉として認識しておくことでより理解が深まり、テストケースを考える際にも役立つかと思います。

ちなみに、このような同値分割・境界値分析は特に開発者が書くテストとしては非常に重要な手法なのでこれを気に覚えておくと、ベテランのエンジニアから「おっ」と思われるかもしれません。

テストを書いてみる

ここからはvitestでテストケースを書いてみますが、ほぼそのままjestでも書けると思います。

なお、テストコードも含めて以下のリポジトリにまとめていますので、気になる方はご覧ください。

普通に書くとこうなる

さて、いい感じにテストケースを作れた気がするので、まずは一つテストを書いてみましょう。

describe("HouseAllowance", () => {
  it("returns 10000 when the distance is just 2000m", () => {
    const sutHouseAllowance = new HouseAllowance(new Killometers(2))
    expect(sutHouseAllowance.getAllowance()).toEqual(new Money(10000))
  })
})

これを実行すると、ちゃんとテストが通ります。

同じグループのテストを追加してみます。

// 上と同じテストをeachを使わずに書いた場合
describe("HouseAllowance", () => {

  // 2km以下で10000円を返す同値クラスの境界値
  it("returns 10000 when the distance is just 2000m", () => {
    const sutHouseAllowance = new HouseAllowance(new Meters(2000))
    expect(sutHouseAllowance.getAllowance()).toEqual(new Money(10000))
  })

  it("returns 10000 when the distance is 1000m", () => {
    const sutHouseAllowance = new HouseAllowance(new Meters(1000))
    expect(sutHouseAllowance.getAllowance()).toEqual(new Money(10000))
  })
})

少ない数なら良いのですが、これが増えてくるとテストの数が結構増えてしまいますね。

そこで、vitestのtest.eachを使ってみましょう。

test.eachを使って書いてみる

describe("HouseAllowance", () => {
  // 2km以下で10000円を返す同値クラス
  it.each([
    // 渡すパラメーターと期待する結果を配列内に書く
    {
      distance: new Killometers(0),
      expected: new Money(10000),
      distanceStr: "0km",
    },
    {
      distance: new Killometers(1),
      expected: new Money(10000),
      distanceStr: "1.0km",
    },
    {
      distance: new Killometers(2),
      expected: new Money(10000),
      distanceStr: "2km",
    },
  ])(
    `returns 10000 when the distance is less or equal than 20000m(distance: $distanceStr)`,
    ({ distance, expected }) => {
      const sutHouseAllowance = new HouseAllowance(distance)
      expect(sutHouseAllowance.getAllowance()).toEqual(expected)
    },
  )
})

このようにeachを使うことで、パラメーターと期待する結果がちょっと違うだけのテストをまとめて書くことができます。

結果は以下のようになります。

src/domain/allowance/housingAllowance.spec.ts x22

 ✓ src/domain/allowance/housingAllowance.spec.ts (10)
   ✓ HouseAllowance (8)
     ✓ returns 10000 when the distance is less or equal than 2000m(distance: '0m')
     ✓ returns 10000 when the distance is less or equal than 2000m(distance: '1000m')
     ✓ returns 10000 when the distance is less or equal than 2000m(distance: '2000m')

「10000円を返すグループ」というようにグループごとにまとめており、意味を持った塊を作れているので、コメントを書かないでもテストの意図が伝わりやすいのもいい感じなのではないかと考えています。

個人的にすべてのテストケースを一つのテスト関数にまとめるのはあまりおすすめしません。上のようにグループごとに分けることで意図を持ったテストを書きやすく、結果を見た時にエンジニアではない人でもわかりやすくなるのではないかと考えています。

ちなみに他のグループも同様に書き、結果を見ると以下のようになります。

 src/domain/allowance/housingAllowance.spec.ts (10)
   ✓ HouseAllowance (8)
     ✓ returns 10000 when the distance is less or equal than 2000m(distance: '0m')
     ✓ returns 10000 when the distance is less or equal than 2000m(distance: '1000m')
     ✓ returns 10000 when the distance is less or equal than 2000m(distance: '2000m')
     ✓ returns 5000 when the distance is less or equal than 5000m(distance: '2001m')
     ✓ returns 5000 when the distance is less or equal than 5000m(distance: '3000m')
     ✓ returns 5000 when the distance is less or equal than 5000m(distance: '5000m')
     ✓ returns 0 when the distance is more than 5000m(distance: '5001m')
     ✓ returns 0 when the distance is more than 5000m(distance: '10000m')

今回は一つの describe にまとめて書いていますが、グループごとに describe を分けるとより見やすくなるかもしれません。(ただしdescribe のネストが深くなるのを避けるという考えもあると思います)

最後に

今回は「ちょっとパラメーターと期待する結果が違う」だけのテストをまとめて書く方法をご紹介しました。

同じようなテストを何回も書くのを避けたり、テストコードを見たときに「(いやでも)グループを意識できる」といったメリットがあると思うので、ぜひ利用を検討されてください。

以上、今泉でした。最後までお読みいただきありがとうございました。