はじめに
自分はオブジェクト指向実践ガイドを読んでからテストの書き方、コードの書き方、リファクタリングの仕方など影響を受けた部分が多く、
それまで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_inches
、 Gear#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なんかにも触れる予定だったんですが時間がありませんでした
あとどっかでDDD + Railsについて書いてみたいです。
最後に、素敵な本への出会いとこの記事を書くきっかけをくれた@taiki__tさんと、オブジェクト指向設計実践ガイドの原著者のSandi Metzさんに謝辞を。
ありがとうございました。