テストを書くことの重要性にはいろいろな場所で言及されていますが、「どのようなテストケースでテストを実装すればいいか」といった、テストケースに焦点を当てた情報はあまり見ない気がします。 そこでこの記事では、サービス開発におけるテストケースの洗い出し方について解説します。

対象となる読者

この記事で想定している読者は、「Ruby on Railsを用い、GitHub上のプルリクエストでWebサービスを開発している」ような方です。 RSpecでテストを実装する際、どのようなテストケースであれば十分か、といったテーマで解説します。

開発当初からほとんどの仕様が決まっているようなプロダクトは、テストケースもはじめの段階で洗い出せると思いますが、この記事では「1つのプルリクエストで実装したコードに対するテストケースの洗い出し方」について解説しています。

テストの種類

まず、洗い出すテストの種類ですが、大きく分けて次の2つがあります。

  1. ユニットテスト
    • メソッドが仕様どおりに機能しているかをテストする
    • 例:Modelのpublicメソッドに対するテスト
  2. インテグレーションテスト
    • 一連の処理が仕様どおりに行なわれているかをテストする
    • 例:商品ページの閲覧〜購入までが問題なく行なえるかどうかのテスト

ユニットテストは、主にドメイン層(Model/Service/Callbackなどの各責務)のpublicメソッドに対して行ないます。

アプリケーション層(Controllerなど)に対するテストも必要ですが、アプリケーション層は基本的にドメイン層の各メソッドを利用しているに過ぎないので、ドメイン層に対して重点的に書いていきます。

もちろんControllerに対するリクエストの検証などは、必要に応じて行ないます。

インテグレーションテストは、ユーザのユースケースに基いて実装します。 サービスのユースケースの中で、とりわけ決済機能や管理画面といった繊細なページを含むケースに対して重点的に書いていきます。

テストケースの考え方

たとえばあるメソッドに対するテストケースを考える際、次の2つの観点から考えるとよいです。

1. 正常系と異常系

正常系とは、「その対象が想定している入力に対して期待どおりの出力を行なうかどうか」という考え方です。

対して異常系は、「その対象が想定していない入力に対してきちんと対処できるかどうか」という考え方です。

2. 同値分割と境界値分析

同値分割とは、「入力を意味のあるグループ(同値クラス)に分け、各グループから代表値を選ぶ方法」です。 同じような意味を持つ入力値によるテストを避けることができたり、意味のある入力値によるテストを見逃さないというメリットがあります。

境界値分析とは、「同値クラス間の境界の値を入力とする方法」です。 メソッドの実装ミスは、一般的に境界値で生じやすいため、境界値分析を行なうことでミスを防ぐことができます。

手順

以上の観点を踏まえて、ユニットテスト・インテグレーションテストの両方について、次のような手順でテストケースを洗い出します。

  1. 正常系はどういうケースが考えられるか
  2. 異常系はどういうケースが考えられるか
  3. 1-2について、同値分割するとどのような入力値が考えられるか
  4. 1-2について、境界値分析するとどのような入力値が考えられるか
  5. 1-4を踏まえ、サービスが要求する品質と工数を考慮してテストケースとする

テストケースを洗い出す際は、正常系よりも異常系をしっかりと考えることで、安定したアプリケーションを構築することができます。

テストケースの粒度

前章で「サービスが要求する品質と工数を考慮して……」と示しました。

たとえば、開発中のサービスが顧客から費用をもらう性質のプロダクトの場合、可能な限りテストケースを網羅すべきでしょう。 決済機能といったクリティカルな部分であれば、なおさらです。

この場合、ドメイン/アプリケーション層だけでなく、インタフェース層(Viewなど)までも十分にテストを行なうことも検討した方がよいかもしれません。

一方で、広告だけで成り立ち、かつ機密情報を扱わないtoCサービスの場合、テストの実装に時間を割くよりも、ユーザのために機能開発にコストをかけた方がよいかもしれません。

テストは書こうと思えばいくらでも書けますが、その粒度はサービスの性質に応じて柔軟に決めます。

よいテストケースとは

テストケースを考えるにあたって、以下の条件を満たすように洗い出すことで、しっかりとしたテストが実装できます。

  • 必要のないテストがない
    • すべての同値クラスを網羅すること
  • 必要最低限のテストだけある
    • 1つの同値クラスで1つの値を入力値として採用すること
  • テスト同士が疎結合である
    • 実行するテストの順番を変えても同じ結果が得られること
  • 環境に依存しない
    • 開発環境だけでなく、本番環境などのシステム要件を考慮していること
  • 繰り返し実行できる
    • 何度実行しても結果が変わらないこと

テストケースと実装の例

ここでは、例として商品(Product)の値段(price)を値引きするメソッド(#discount_price)について考えます。

最初の実装

まず、単に値引きを行なうメソッドをなにも考えずに実装すると、以下のようになります。

class Product
  def initialize(price: 0)
    @price = price
  end

  def discount_price(percent: 0)
    @price * (100 - percent) / 100
  end
end

product = Product.new(price: 100)
product.discount_price(percent: 20) #=> 80

テストケースを考える

便宜的にpriceは整数であることが担保されているとすると、#discount_priceの入力:percentについて、次のことが考えられます。

  • 正常系を考えると、「0以上100以下」の入力をとったときに、それに対応した値段が返る
  • 異常系を考えると、「100より大きい」「0未満」「Numeric以外の型」の入力をとったときに、適切な処理がなされる
  • 同じく異常系を考えると、値段が小数になることはあり得ないので、結果が小数になった場合にも適切な値段が返る
  • 同値分割により、同値クラスは「100より大きい」「0以上100以下」「0未満」の3グループについてテストする
  • 境界値分析により、「101」「99」「1」「-1」の4つについてテストをする

以上を踏まえ、このメソッドのテストケースは次のようになります。

  • 前提
    • 元の値段は¥100である
  • テストケース
    • 値引率が20%のとき、値段は¥80になる
    • 値引率が101%のとき、例外を返す
    • 値引率が-1%のとき、例外を返す
    • 値引率として文字列を渡すと、例外を返す
    • 値引率が1.5%のとき、値段は¥99になる

RSpecによる実装例

これをRSpecで実装した例を以下に示します。

describe Product do
  let(:product) { Product.new(price: 100) }

  describe '#discount_price' do
    it '入力が正常な場合に正しく計算される' do
      expect(product.discount_price(percent: 20)).to eq 80
    end

    it '入力が不正な場合に例外を返す' do
      [101, -1, 'percent'].each do |percent|
        expect { product.discount_price(percent: percent) }.to raise_error(Product::ArgumentError)
      end
    end

    it '結果が小数の場合に切り上げる' do
      expect(product.discount_price(percent: 1.5)).to eq 99
    end
  end
end

最終的な実装

このテストをパスするには、次のような実装になると思います。

class Product
  class ArgumentError < StandardError; end

  # 省略

  def discount_price(percent: 0)
    if !percent.is_a?(Numeric) || !percent.between?(0, 100)
      raise ArgumentError
    end

    price = @price * (100 - percent) / 100
    price.ceil
  end
end

おわりに

適切なテストケースをもとにテストを実装することで、安定したシステムを小さいコストで構築することができます。 テストケース作成の入門記事として、ぜひ参考にしてみてください。