27
15

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 5 years have passed since last update.

Ruby on RailsAdvent Calendar 2018

Day 4

オブジェクト指向設計実践ガイド rspec実践編

Last updated at Posted at 2018-12-03

はじめに

自分はオブジェクト指向実践ガイドを読んでからテストの書き方、コードの書き方、リファクタリングの仕方など影響を受けた部分が多く、
それまでrubyで悩んでいた部分に光明を指してくれた一冊として非常に素晴らしい本だと思っています。

ですがテストに関する項目は社内で読書会をしたときも理解してもらうのが少し難しく、実践していくのは少し難しい点は否めないと感じていて、理解の一助としてこの記事を書くことにしました。

というわけで、この記事では、テストに関する原著のコードの引用と、それに対する簡単な解説を書いています。
また、TestUnitで書かれている原著でのテストコードをrspecで自分が書いた例と、そして自分の持つTipsや考え方を書いています。

なお、今回この記事では単体テストに焦点を当てて書いています。
request specなどのE2Eテストについてもまた書く機会があれば……

この話のきっかけ

今回の話を書こうと思ったきっかけはRails Developers Meetup2018 Day3での懇親会のことでした。
(その時のレポートについてはこちらにも書いたので、興味があれば見てください。)

懇親会でオブジェクト指向設計実践ガイドを翻訳された@taiki__tさんと話していたときに、

「rspecでどういうふうに実践しているのかという実例の記事があまりないから書いてほしいな」

という話をもらいました。

筆無精なもので書くタイミングを完全に逃していたのですが、今回アドベントカレンダーという形で書いてみようと思います。

アジェンダ

  • 受信メッセージのテスト
  • 送信メッセージのテスト
  • ダックタイプのテスト

受信メッセージのテスト

オブジェクト指向設計実践ガイドで自分がポイントだと思う点の一つが受信メッセージと送信メッセージの話です。

その一つである受信メッセージはそのオブジェクトのパブリックインターフェースである、と語られています。

以下のコードはオブジェクト指向設計実践ガイドからの引用です。

class Wheel
  attr_reader :rim, :tire
  def intialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def diameter
    rim + (tire * 2)
  end
end

class Gear
  attr_reader :chainring, :cog, :rim, tire
  def initialize(args)
    @chainring = args[:chainring]
    @cog = args[:cog]
    @rim = args[:rim]
    @tire = args[:tire]
  end

  def gear_inches
    ratio * Wheel.new(rim, tire).diameter
  end

  def ratio
    chainring / cog.to_f
  end
end

Wheel#diameter 、および Gear#gear_inchesGear#ratio は各クラスのパブリックインターフェースであり、受信メッセージです。

これらのメソッドはテストされているべきであり、またとてもシンプルなテストになります。

受信メッセージのrspecによるテスト

以下はオブジェクト指向設計実践ガイドのテストをrspecで書いた(もっともシンプルな)例です。

describe Wheel do
  describe "#diameter" do
    subject { wheel.diameter }

    let(:wheel) { Wheel.new(26, 1.5) }

    it { is_expected.to eq 29 }
  end
end
describe Gear do
  describe "#gear_inches" do
    subject { gear.gear_inches }

    let(:gear} { Gear.new(chainring: 52, cog: 11, rim: 26, tire: 1.5) }

    it { is_expected.to be_within(0.01).of(137.1) }
  end
end

ここで良くない点としては、GearクラスがWheelクラスに対する外から見えない依存がある点です。
diameterの振る舞いによってgear_inchesメソッドの戻り値は容易に壊れてしまい、保守性が良いとは言えません。

隠れた依存を取り除く

そこで、Gearクラスに対するWheelクラスに対する依存を取り除きます。

class Gear
  attr_reader :chainring, :cog, :wheel
  def initialize(args)
    @chainring = args[:chainring]
    @cog = args[:cog]
    @wheel = args[:wheel]
  end

  def gear_inches
    ratio * wheel.diameter
  end

  def ratio
    chainring / cog.to_f
  end
end

ここでのポイントは、GearクラスがWheelクラスに依存するのではなく、 diameter メソッドを持つオブジェクト、すなわち Diameterizable なオブジェクトが外部から注入されることを期待するようになった点です。

この修正点により、GearクラスのテストはWheelクラスへの依存がなくなり、diameterのインターフェースに対してのみ依存するようになりました。

describe Gear do
  describe "#gear_inches" do
    subject { gear.gear_inches }

    let(:gear} { Gear.new(chainring: 52, cog: 11, wheel: wheel) }
    let(:wheel) { Wheel.new(26, 1.5) }

    it { is_expected.to be_within(0.01).of(137.1) }
  end
end

テストダブルを使う

GearクラスはもはやWheelクラスではなくDiameterizableに対して依存するようになったため、テストでWheelを使い続けるのは適切ではなくなる場合があります。
Diameterizableなクラスが複数ある場合や、またDiameterizableのコストが高い場合などです。

また、このテストコードを見た場合、テストからは「GearはDiameterizableを期待している」ことを知ることができません。

そういった場合に、Diameterizableなオブジェクトのフェイクオブジェクト、すなわち「テストダブル」を注入することで解決するという方法を取ることができます。

原著で出てくる例ではTestUnitでこのテストダブルをテストのためにRubyのクラスとして作っています。
rspecでは double を呼ぶことでシンプルに扱うことができるので、その例を紹介します。

describe Gear do
  describe "#gear_inches" do
    subject { gear.gear_inches }

    let(:gear) { Gear.new(chainring: 52, cog: 11, wheel: wheel) }
    # diameterが呼ばれたら29を返すテストダブルをwheelとして注入する
    let(:wheel) { double('wheel', diameter: 29) }

    it { is_expected.to be_within(0.01).of(137.1) }
  end
end

wheelをWheelクラスからテストダブルにすることによって得られたメリットはなんでしょうか。

まずひとつに、Gearが受け取るwheelに対するdiameterメソッドの呼び出しが明確化されました。
これによって、テストを見ればdiameterメソッドを持つオブジェクトならなんでもwheelに渡せることがわかります。

ふたつ目に、Wheelクラスのdiameterの実装に対してgear_inchesのテストが依存することがなくなりました。
Wheelクラスのdiameterメソッドの実装が変わったとしても、このテストが失敗する心配をする必要はありません。

しかし、これだけではデメリットも存在します。

インターフェースをテストする

Wheelクラスが以下のように修正された場合のことを考えてみましょう。

class Wheel
  attr_reader :rim, :tire
  def intialize(rim, tire)
    @rim = rim
    @tire = tire
  end

  def width # diameterメソッドからrenameされた
    rim + (tire * 2)
  end
end

こうした修正が行われた場合、Wheelクラスに依存したテストになっていたために失敗していたテストが、テストダブルを使うようになったために通るようになってしまいます。

これを避けるにはインターフェース(原著では ロール )、すなわちWheelクラスがDiameterizableな振る舞いをすることを明示しておかなければなりません。

rspecでは、shared_examples_for を使うことによってこの振る舞いを明示化することが可能になります。

# このファイルはspec/shared_examplesなどにおいておく
shared_examples_for "diameterizable" do
  it { expect(diameterizable).to be_respond_to(:diameter) }
end

describe Wheel do
  let(:diameterizable) { Wheel.new(rim: 26, tire: 1.5) }

  it_behaves_like "diameterizable"
end

こうすることで、Wheelクラスのテストを見れば、それがDiameterizableであることを把握することができます。

it_behaves_likeはテストをDRYにするために使われがちではありますが、「~のように振る舞う」という語句もあってこういったシンプルな振る舞いを定義するのに最も適していると考えています。

送信メッセージをテストする

オブジェクト指向設計実践ガイドでは送信メッセージは「コマンドメッセージ」と「クエリメッセージ」に分類されるとあります。

  • クエリメッセージ:他のオブジェクトへの問い合わせ。テストされるべきではない
  • コマンドメッセージ:他のオブジェクトへの副作用のあるメソッドの実行

以下も原著よりの引用コードになります。

class Gear
  attr_reader :chainring, :cog, :wheel, :observer
  def initialize(args)
    # ...
    @observer = args[:observer]
  end

  def set_cog(new_cog)
    @cog = new_cog
    changed
  end

  def set_chainring(new_chainring)
    @chainring = new_chainring
    changed
  end

  def changed
    observer.changed(chainring, cog)
  end
end

Gearに新しく責務が追加され、チェーンリングやコグに変更があった場合はobserverに通知する必要が出てきました。
この新しい責務は、「ギアインチを要求されたら戻り値として適切な値を返す」という受信メッセージとは違い、「observerメソッドの特定のメソッドを呼び出す」という責務が含まれています。

rspecでのメソッド呼び出しのテスト

rspecでは、特定のメソッドの呼び出しをテストするコード、すなわちモックは expect などを使って簡単に書くことができます。

describe Gear do
  let(:gear) { Gear.new(chainring: 52, cog: 11, observer: double('observer')

  let(:observer) { double('observer') }

  describe "#set_cog" do
    subject { gear.set_cog(27) }

    before { expect(observer).to receive(:changed).with(52, 27) }

    it { expect { subject }.to change { gear.cog }.from(11).to(27) }
  end

  describe "#set_chainring" do
    subject { gear.set_chainring(42) }

    before { expect(observer).to receive(:changed).with(42, 11) }

    it { expect { subject }.to change { gear.chainring }.from(52).to(42) }
  end
end

モックは double 以外にも使うことができますが、 double を使うことでより結合度を下げることができます。

ダックタイプをテストする

ActiveSupport::Concernを使って振る舞いを定義する

受信メッセージの項でインターフェースについて触れましたが、RailsであればConcernを使うことでよりDiameterizableな振る舞いをわかりやすく定義することができます。

module Diameterizable
  extend ActiveSupport::Concern

  included do
    def diameter
      raise NotImplementedError.new, "implmented here"
    end
  end
end

class Wheel
  include Diameterizable

  def diameter
    #...
  end
end

Concernを用意する場合、少し冗長に見えますが以下のようなメリットがあります。

  • Wheelクラスを見ることでもWheelがDiameterizableであることを把握できる
  • Diameterizableなクラスがdiameter以外にも共通のメソッドを持っている場合の処理の共通化なども可能になる

ダックタイプのテスト

このDiameterizableの振る舞いのテストは以下のように書くことができます。

shared_examples_for "diameterizable" do
  it { expect { diameterizable.diameter }.not_to raise_error }
end

describe Wheel do
  let(:diameterizable) { described_class.new }

  it_behaves_like "diameterizable"
end

shared_examplesによって、Diameterizableの振る舞いは明確化されるようになりました。
diameterメソッドに応答すること以外にもDiameterizableの振る舞いがあるのであれば、このshared_examplesに定義することができます。

テストダブルのインターフェースをテストする

しかし、この時点ではまだ問題点があります。
それは、GearのテストダブルがDiameterizableであることがテストされていない点です。

describe Gear do
  describe "#gear_inches" do
    subject { gear.gear_inches }

    let(:gear) { Gear.new(chainring: 52, cog: 11, wheel: wheel) }
    let(:wheel) { double('wheel', diameter: 29) }

    it { is_expected.to be_within(0.01).of(137.1) }
  end
end

GearのコードとしてはwheelがDiameterizableであることを期待していますが、もしDiameterizableの振る舞いが変わった場合でもテストダブルによってテストしているこのコードはパスしてしまいます。

この点も、 shared_examples_for によってテストすることができます。

describe Gear do
  describe "#gear_inches" do
    subject { gear.gear_inches }

    let(:gear) { Gear.new(chainring: 52, cog: 11, wheel: wheel) }
    let(:wheel) { double('wheel', diameter: 29) }

    let(:diameterizable) { wheel }

    it_behaves_like "diameterizable"

    it { is_expected.to be_within(0.01).of(137.1) }
  end
end

こうしておくことによって、Diameterizableの振る舞いが変わってshared_examplesが修正された場合、テストがFailするようになりました。

まとめ

オブジェクト指向設計実践ガイドのテストの項について、rspecを使った実践例をお届けしましたがいかがでしたでしょうか。
「うちではこうしている」「ここはこういう書き方が良いと思う」などあれば、じゃんじゃん投げてもらえると幸いです。

なお、書いてみたらほとんどRailsについて触れてなくてすみません……
ほんとはもう少しFactoryによるテストデータやActiveRecordによる永続化、request specなんかにも触れる予定だったんですが時間がありませんでした:cry:

あとどっかでDDD + Railsについて書いてみたいです。

最後に、素敵な本への出会いとこの記事を書くきっかけをくれた@taiki__tさんと、オブジェクト指向設計実践ガイドの原著者のSandi Metzさんに謝辞を。
ありがとうございました。

27
15
0

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
27
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?