🥸

unittestのあるあるとその対策をゆるく書きたい~~

2024/12/21に公開

はじめに

QAエンジニアとしてテストコードをレビューしたり、テストコードを書くことがあるので、ユニットテストに関するあるあるを書いたいと思います。
対策は具体的ではなくふわっとしているものが多いです。「あるある」という気持ちで見ていただけると嬉しいです。

この記事では、RSpecを用いた例を中心に解説します。

あるある1: context深くなりがち

内容

contextはテスト条件を表すことが多いです。複雑な仕様になればなるほどcontextが深くなりやすくなりがちです。

サンプルコード

サンプル: プロダクションコード

class MovieTicketPrice
  BASE_PRICES = {
    child: 500,   # 12歳以下
    teenager: 1000, # 13歳〜18歳
    adult: 1500,  # 19歳〜64歳
    senior: 1200  # 65歳以上
  }.freeze

  LADIES_DAY_WEEKDAY = :wednesday

  def calculate_fee(customers, entry_hour, entry_date)
    total_fee = customers.reduce(0) do |sum, customer|
      age = customer[:age]
      gender = customer[:gender]
      base_price = case age
                   when 0..12 then BASE_PRICES[:child]
                   when 13..18 then BASE_PRICES[:teenager]
                   when 19..64 then BASE_PRICES[:adult]
                   else BASE_PRICES[:senior]
                   end

      # レディースデー割引
      base_price *= 0.9 if apply_ladies_day?(entry_date, gender)

      # 17時以降割引
      base_price *= 0.8 if entry_hour >= 17

      sum + base_price.to_i 
    end

    # 団体割引
    total_fee *= 0.8 if customers.size >= 7
    total_fee.to_i
  end

  private

  def weekday_to_wday(weekday)
    { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 }[weekday]
  end

  def apply_ladies_day?(entry_date, gender)
    entry_date.wday == weekday_to_wday(LADIES_DAY_WEEKDAY) && gender == :female
  end
end


サンプル: contextが深い例

require_relative 'movie_ticket_price'

require "date"

RSpec.describe MovieTicketPrice do
  describe '#calculate_fee' do
    subject do
      movie_ticket_price = MovieTicketPrice.new
      movie_ticket_price.calculate_fee(customers, entry_hour, entry_date)
    end

    let(:wednesday) { Date.new(2024, 12, 11) }
    let(:tuesday) { Date.new(2024, 12, 10) }
    let(:customers) { customers_group_discount_not_applied }
    let(:customers_group_discount_not_applied) { [
      { age: 12, gender: :female },
      { age: 13, gender: :male },
      { age: 18, gender: :female },
      { age: 19, gender: :male },
      { age: 64, gender: :female },
      { age: 65, gender: :male },
    ] }
    let(:customers_group_discount_applied) {
      customers_group_discount_not_applied.push({ age: 50, gender: :female })
    }


    context 'when entry time is before 16:00' do
      let(:entry_hour) { 16 }

      context 'When entry_date is wednesday' do
        let(:entry_date) { wednesday }

        context 'When group discount is applied' do
          let(:customers) { customers_group_discount_applied }
          it { is_expected.to eq(6200) }
        end

        context 'When group discount is not applied' do
          it { is_expected.to eq(6400) }
        end
      end

      context 'When entry_date is not wednesday' do
        let(:entry_date) { tuesday }

        context 'When group discount is applied' do
          let(:customers) { customers_group_discount_applied }
          it { is_expected.to eq(6560) }
        end

        context 'When group discount is not applied' do
          it { is_expected.to eq(6700) }
        end
      end
    end

    context 'when entry time is after 17:00' do
      let(:entry_hour) { 18 }

      context 'when it is wednesday' do
        let(:entry_date) { wednesday }

        context 'When group discount is applied' do
          let(:customers) { customers_group_discount_applied }
          it { is_expected.to eq(4960) }
        end

        context 'When group discount is not applied' do
          it { is_expected.to eq(5120) }
        end
      end

      context 'when it is not wednesday' do
        let(:entry_date) { tuesday }

        context 'When group discount is applied' do
          let(:customers) { customers_group_discount_applied }
          it { is_expected.to eq(5248) }
        end

        context 'When group discount is not applied' do
          it { is_expected.to eq(5360) }
        end
      end
    end
  end
end

対策

RuboCopのRSpec/NestedGroupsで最大階層をきめるたり、深い階層を作りすぎないようにするために、RSpec::Parameterizedを使用するといいかもしれません。****

contextを浅くした例
require_relative 'movie_ticket_price'

require "date"
require 'rspec-parameterized'

RSpec.describe MovieTicketPrice do
  using RSpec::Parameterized::TableSyntax

  describe '#calculate_fee' do
    subject do
      movie_ticket_price = MovieTicketPrice.new
      movie_ticket_price.calculate_fee(customers, entry_hour, entry_date)
    end

    let(:wednesday) { Date.new(2024, 12, 11) }
    let(:tuesday) { Date.new(2024, 12, 10) }
    let(:customers_group_discount_not_applied) { [
      { age: 12, gender: :female },
      { age: 13, gender: :male },
      { age: 18, gender: :female },
      { age: 19, gender: :male },
      { age: 64, gender: :female },
      { age: 65, gender: :male },
    ] }
    let(:customers_group_discount_applied) {
      customers_group_discount_not_applied.push({ age: 50, gender: :female })
    }

    where(:customers, :entry_hour, :entry_date, :expected_fee, :test_case_name) do
      ref(:customers_group_discount_applied) | 16 | ref(:wednesday) | 6200 | 'When entry time is before 16:00 and entry_date is wednesday and group discount is applied'
      ref(:customers_group_discount_not_applied) | 16 | ref(:wednesday) | 6400 | 'When entry time is before 16:00 and entry_date is wednesday and group discount is not applied'
      ref(:customers_group_discount_applied) | 16 | ref(:tuesday) | 6560 | 'When entry time is before 16:00 and entry_date is not wednesday and group discount is applied'
      ref(:customers_group_discount_not_applied) | 16 | ref(:tuesday) | 6700 | 'When entry time is before 16:00 and entry_date is not wednesday and group discount is not applied'
      ref(:customers_group_discount_applied) | 17 | ref(:wednesday) | 4960 | 'When entry time is after 17:00 and entry_date is wednesday and group discount is applied'
      ref(:customers_group_discount_not_applied) | 17 | ref(:wednesday) | 5120 | 'When entry time is after 17:00 and entry_date is wednesday and group discount is not applied'
      ref(:customers_group_discount_applied) | 17 | ref(:tuesday) | 5248 | 'When entry time is after 17:00 and entry_date is not wednesday and group discount is applied'
      ref(:customers_group_discount_not_applied) | 17 | ref(:tuesday) | 5360 | 'When entry time is after 17:00 and entry_date is not wednesday and group discount is not applied'
    end

    with_them do
      it { is_expected.to eq(expected_fee) }
    end
  end
end

あるある2: テストコードの品質基準がカバレッジだけになりがち

詳細

カバレッジの数値が高くても、実際にテストコードの品質が良いかどうかはわかりません。
しかし、カバレッジは簡単に定量的に計測できるため、「カバレッジが高いかどうか?」だけでテストコードの品質を議論してしまうことがよくあります。

『単体テストの考え方/使い方』p24-25では、次のように述べられています。

●網羅率 (coverage) はテスト・スイートの質が悪いことを評価できるが、良いことを評価できない。つまり、網羅率が低ければ、計測対象のテスト・スイートは十分なテストを行っていないことを示すことになるが、その逆に、網羅率が高かったとしても、テスト・スイートの質が良いと自動 的に評価されるわけではない。
●分岐網羅率(branch coverage)はテスト・スイートの網羅性についてコード網羅率(code coverage)より正確な結果を返すが、テスト・スイートの質が高いことを評価するものにはならない。こうなる理由は、分岐網羅率はテスト・ケースが何も確認していない場合であっても高い網羅率を出せるからであり、さらに、使われているライブラリの中身が計測対象から外れているからである。

またp21には以下の様に述べられています。

たとえば、病院に入院している患者のことを想像してください。そして、その患者の体温を計測した際、もし、計測した結果の体温が高ければ、その患者が発熱していることが分かります。これは適切な体温の観察です。しかしながら、このとき、病院が患者の体温を特定の数値にすることを目標にしていると、間違った治療が行われることになります。たとえば、患者の横にクーラーを設置して、皮膚上の温度が目的の温度になるまで冷たい風を浴びせ続けることで、目標の体温にするようなことです。しかも、もし、特定の体温にすることが目標であるのならば、この方法は迅速で効果的な治療として見ることができます。しかしながら、この方法はもちろん間違っており、患者を治療していることにはなりません。
このことから、カバレッジは「足りない部分」を発見するための指標としては有用です。しかし、カバレッジの数値だけにこだわりすぎると、開発全体に悪影響を及ぼすこともあります。
つまり、カバレッジの数値そのものを目的化してしまうと、本来の目標である「テストの質を向上させること」から外れてしまうのです。

また、『単体テストの考え方/使い方 p20』ではさらにこう述べています。

テスト・スイートの質がいかに優れているかを自動的に評価できる方法はない。

はて、どうした者かなという感じですね。

対策

カバレッジ以外にテストコードの品質を定量的に判断することができないので、正直これと言って良い対策のアイデアは現時点で浮かんでいません。
「カバレッジが低ければ高くするように努力していこう、カバレッジが高ければ、高くても問題がある可能性がある」点をつたえること、以下の教科書的な解答を進めていく、とかですかね...

  • テストすることが開発サイクルの中に組み込まれている
  • コードベースの特に重要な部分のみがテスト対象となっている
    • ビジネスロジックを含む部分(ドメインモデル)が最も効果的なテスト
    • 第2部で、ドメインモデルをアプリケーションの他の関心ごとから隔離する方法を説明する
  • 最小限の保守コストで最大限の価値を生み出すようになっている
    • 価値があるテストケースを認識できること
    • 価値があるテストケースを作成できること

あるある3: テストコードの設計や上手な書き方は軽視されがち

詳細

プロダクションコードは設計や上手な書き方を熟考すると思います。そのための技術書やWebの記事も多いと思います。
しかし、テストコードの設計はあまり検討されないのではないでしょうか?どちらかというと、テストとして成り立っているかどうかまでの確認で終わりが多いかな~と思っています。
テストコードもコードである以上、品質も大事ではないでしょうか。

対策

良い書き方としてAAA(Arrenge-Act-Assert)は意識してみるといいかもしれません。簡単なサンプルです。

サンプルコード

サンプル: プロダクションコード

class ShoppingCart
  def initialize
    @items = []
  end

  def add_item(item, price)
    @items << { item: item, price: price }
  end

  def total_price
    @items.sum { |item| item[:price] }
  end

  def discounted_price(discount_rate)
    total_price * (1 - discount_rate)
  end
end

サンプル: 悪い例

RSpec.describe ShoppingCart do
  subject(:cart) { ShoppingCart.new }

  it 'calculates the discounted_price' do
    cart.add_item('Apple', 100)
    expect(cart.discounted_price(0.5)).to eq(50)
    cart.add_item('Banana', 200)
    expect(cart.discounted_price(0.5)).to eq(150)
  end
end

サンプル: 良い例

RSpec.describe ShoppingCart do
  subject(:cart) { ShoppingCart.new }

  describe '#discounted_price' do
    context 'when items are added to the cart' do
      it 'returns the discounted_price of all items' do
        # Arrange: テストデータを準備
        cart.add_item('Apple', 100)
        cart.add_item('Banana', 200)

        # Act: 検証対象のメソッドを実行
        result = order.discounted_price(0.5)

        # Assert: 結果を検証
        expect(result).to eq(150)
      end
    end
  end
end

AAA以外は、私自身も良いテストコードの書き方が自分もわかってないです。
JSTQB AL テスト自動化エンジニアのシラバスの24ページには、SOLIDの原則も意識しましょう、といったこと書いてます。
しかし、これを上手く使ってテストコードの設計を検討できるイメージを持てていません。もしだれかイメージがあれば教えてほしいです。

あるある4: ユニットテスト(単体テスト)とインテグレーションテスト(結合テスト)の境界があいまいのまま

詳細

単体テストの定義は1クラス?それとも1つふるまい?
データベースに接続するのは単体テスト?結合テスト?
コントローラーや複数のクラスを管理するようなサービスクラスは単体テスト?結合テスト?
などなど、あいまいになってませんか?

そして、モックなどのテストダブルを使いすぎて偽陰性が発生してたり、
多くのテストテスト観点をコントローラーレベルでテストしようとして処理時間が遅なったりしてませんか?

対策

世界共通で「これだ!」と決まったテストレベルはないと思うので、「俺たちのチームが考えるテストレベルはこうだ!」というのを決めると良いかもしれません。
またはGoogleのテストサイズという考え方を参考にして見るのもいいかもしれません。

あるある5: テストデータの作り方が雑になりがち

詳細

テストデータの変数名に1~5といった数字を使いがちではないですか?例えばuser1、user2、user3といった感じです。
プロダクションコードでは変数名にこのような数字を使うことはほとんどないのに、テストコードでは使われがちです。

さらに、作成されたテストデータも、ほぼ同じ内容で差異がほとんどなかったり、実際のユーザーが作りそうなデータとかけ離れていることも少なくありませんか?
たとえば、退会済みのユーザーや特殊な条件のユーザーを意識せず、全てログイン済みのアクティブなユーザーを前提にしている場合などです。

対策

どういったテストデータを作るのか、あまり考えていないからかもしれないですね。
そのため、変数名でデータの意味を表現することで、テストコードを読む人が直感的にデータの役割を理解できるといいかもしれません。
例:

ログイン済みのユーザー: active_user
退会済みのユーザー: deleted_user
管理者ユーザー: admin_user

また、テストデータを実際に使われそうなデータにすることで、変数名で表現しやすくなるかもしれません。
そうして書いた方がテストコードの期待値だけを見てわかりやすくなるメリットもあると思います。

Discussion