20
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

テストにおけるリーダブルとDRYの両立(あるいは妥協案)

Last updated at Posted at 2023-02-06

更新

  • 2023/02/12
    • rspec-parameterized gemを使用したコードサンプルを追加
    • 完全なコードベースのリンクを追加

Tl;DR

  • テストにおいて、可読性は最重要
  • 一方でDRYに書かないと保守性が悪い場合もある
  • 折衷案として、コメントやインデントを活用するといいかも・・!
  • rspec-parameterized gemを利用するとより良い

前提

  • 本記事では、言語/テストフレームワークとしてRuby/RSpecを使用します
  • あくまで筆者の主観/経験に基づく一意見であることに留意ください

テストにおいて、可読性は最重要

テストにおいて、可読性は最も重要だと考えます。例えば以下のような関数をテストしたい場合、

  # 半角数字7桁の文字列かどうかを判定する
  def self.valid_postcode?(postcode)
    return false unless postcode.is_a?(String)

    /\A\d{7}\z/.match?(postcode)
  end

以下のようなテストコードはDRYですが、一見した時に意図が読み取りづらいです。

  describe '.valid_postcode?'do

    context "Dryだけどリーダブルではないテスト" do
      [
        ['1234567', true],
        ['12345678', false],
        [1234567, false],
      ].each do |input, expected|
        it "#{input}の場合、#{expected}を返すこと"do
          expect(described_class.valid_postcode?(input)).to eq expected
        end
      end
    end
  end

以下のように素直に記述することで、一読して意図が伝わりやすくなります。

    context '上記をリーダブルに改めたテスト'do
      it "半角数字7桁の文字列の場合、trueを返すこと" do
        expect(described_class.valid_postcode?('1234567')).to eq true
      end

      it "半角数字8桁の文字列の場合、falseを返すこと" do
        expect(described_class.valid_postcode?('12345678')).to eq false
      end

      it "7桁の数値の場合、falseを返すこと" do
        expect(described_class.valid_postcode?(1234567)).to eq false
      end
    end

 一般的にはテストコードは、可読性を意識して記載するべきです。
それこそテスト仕様書を書いているつもりで、上から順番に読めばテストしたい内容が一目でわかるような作りが望ましいでしょう。

一方でDRYに書かないと保守性が悪い場合もある

では、テストしたいパターンが大量にある場合はどうでしょう?
例えば以下のようなケース。

    context 'リーダブルではないテストの長いバージョン'do
      [
        ['1234567', true],
        ['0123456', true],
        ['123 4567', false],
        ['123456', false],
        ['12345678', false],
        ['123.456', false],
        ['123456a', false],
        [1234567, false],
      ].each do |input, expected|
        it "#{input}の場合、#{expected}を返すこと"do
          expect(described_class.valid_postcode?(input)).to eq expected
        end
      end
    end

これを先ほどのリーダブルな表現に改めると、以下のようになります。

    context 'リーダブルにすると冗長になってしまう' do
      it "半角数字7桁の文字列の場合、trueを返すこと" do
        expect(described_class.valid_postcode?('1234567')).to eq true
      end

      it "0始まりの半角数字7桁の文字列の場合、trueを返すこと" do
        expect(described_class.valid_postcode?('0123456')).to eq true
      end

      it "間にスペースが含まれる文字列の場合、falseを返すこと" do
        expect(described_class.valid_postcode?('123 4567')).to eq false
      end

      it "半角数字6桁の文字列の場合、falseを返すこと" do
        expect(described_class.valid_postcode?('123456')).to eq false
      end

      it "半角数字8桁の文字列の場合、falseを返すこと" do
        expect(described_class.valid_postcode?('12345678')).to eq false
      end

      it "小数として扱える文字列の場合、falseを返すこと" do
        expect(described_class.valid_postcode?('123.456')).to eq false
      end

      it "半角数字以外が含まれる文字列の場合、falseを返すこと" do
        expect(described_class.valid_postcode?('123456a')).to eq false
      end

      it "7桁の数値の場合、falseを返すこと" do
        expect(described_class.valid_postcode?(1234567)).to eq false
      end
    end

同じようなテストが縦に間伸びして並んでしまいます。
このくらいのケース数なら大きな問題にならないかも知れませんが、極端な話これが100個並んだ場合、とても見通しが悪いコードになります。
また、テストの観点が1つ増えた場合、100個のケース全てを修正しないといけなくなります。

これを改善する方法を考えてみましょう。

折衷案として、コメントやインデントを活用するといいかも・・!

以下が、私なりに考える修正案です。

    context "コメントやインデントを活用した改善案" do
      shared_examples 'test valid_postcode?' do |input, expected, description|
        it "#{description}の場合、#{expected}を返すこと"do
          expect(described_class.valid_postcode?(input)).to eq expected
        end
      end

      testcases =
        [
        # [入力値,       期待値,  説明]
          ['1234567',   true,  '半角数字7桁の文字列'],
          ['0123456',   true,  '0始まりの半角数字7桁の文字列'],
          ['123 4567',  false, '間にスペースが含まれる文字列'],
          ['123456',    false, '半角数字6桁の文字列'],
          ['12345678',  false, '半角数字8桁の文字列'],
          ['123.456',   false, '小数として扱える文字列'],
          ['123456a',   false, '半角数字以外が含まれる文字列'],
          [1234567,     false, '7桁の数値'],
        ]

      testcases.each { it_behaves_like "test valid_postcode?", _1, _2, _3 }
    end

上記のように配列にコメントを入れたり、インデントを入れて整形することで、testcasesの中身を見るだけで、テストケースが一目で理解できるようになったのではないでしょうか?
また、仮に観点が1つ増えた場合でも、shared_examplesのみを修正すればいいので、保守性も同時に保たれていると考えます。

rspec-parameterized gemを利用するとより良い

ところで、このように入力と期待値を一行ずつ列挙するテスト手法をパラメタライズドテストというそうです。
(教えていただいた@jnchitoさんありがとうございます!)

日本語で直接的にこの手法を説明する記事がなかったので、以下に英語版Wikiのリンクを載せておきます。
Parameterized (Data-driven) testing Wiki

Rspecには、まさにこの手法をサポートするための便利なgemがあります。
rspec-parameterized

最後に、そちらを利用したコードサンプルを以下に例示します。

    context "rspec-parameterizedを用いた改善案" do
      using RSpec::Parameterized::TableSyntax

      where(:input, :expected, :description) do
      # 入力値      | 期待値 | 説明
        '1234567'  | true  | '半角数字7桁の文字列'
        '0123456'  | true  | '0始まりの半角数字7桁の文字列'
        '123 4567' | false | '間にスペースが含まれる文字列'
        '123456'   | false | '半角数字6桁の文字列'
        '12345678' | false | '半角数字8桁の文字列'
        '123.456'  | false | '小数として扱える文字列'
        '123456a'  | false | '半角数字以外が含まれる文字列'
        1234567    | false | '7桁の数値'
      end

      with_them do
        it "#{params[:description]}の場合、#{params[:expected]}を返すこと" do
          expect(described_class.valid_postcode?(input)).to eq expected
        end
      end
    end

いかがでしょうか?gemを利用することでより簡潔に書くことができました。

また、gemの記法に従うことで、自前でパラメタライズドテストを実装するよりも、チーム内での書きぶりのズレを防ぐことができるのではないでしょうか?
(私も早速こちらのgemをチーム内で布教したいと考えています笑)

まとめ

このようにわかりやすいケースは実はあまり多くないかも知れませんが、状況によってはある程度リーダブルとDRYの両立を考えるべき場面はあるのではないかと考えます。

もちろん大原則は可読性を高め、一見してテストの意図が読みとれるようなテストを書くことですが、例えば今回のようなケースではこのテクニックが役立つのではないでしょうか?

以上となります。
誰かのテストコードの参考になったら嬉しいです!

最後に

今回使用したコードサンプルの完全なコードベースは以下となります。

20
9
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?