きちんと整備したテストコードは、元のコードの仕様書のようなものです。ふつうの仕様書は読むだけですが、テストコードは実行してみることでソフトウェアの動作をチェックできます。
OpenFlowネットワークとコントローラの保守をまかされたとしましょう。もし前任者からテストコードをもらえなければ、コントローラを何度も実行しながら苦労して解読しなければなりません。逆に、テストさえもらえればコード本体を理解しやすくなりますし、気楽にリファクタリングや修正ができます。とくにOpenFlowではスイッチとコントローラが複雑に絡み合い、しかもそれぞれがステートを持つので、ソフトウェアで自動化したテストがないとやってられません。
TremaはOpenFlowコントローラ開発のためのテストツールが充実しています。たとえばアジャイル開発者の大事な仕事道具、テスト駆動開発もTremaはサポートしています。本章ではテスト駆動を使ったコントローラの開発風景を紹介します。要点をつかみやすくするため、動作の単純なリピータハブを取り上げます。ではさっそく実際のテスト駆動開発の流れを見て行きましょう。
Note
|
テスト駆動開発とテストファーストの違いは?
テスト駆動開発やテストファーストなど似たような用語に混乱している人も多いと思います。この2つの違いは何でしょうか。 テストファーストはテスト駆動開発のステップの一部なので、テスト駆動開発のほうがより大きな概念になります。テスト駆動開発では、まずは失敗する見込みでテストを書き (このステップがテストファースト)、次にこのテストを通すためのコードを書きます。最後にコードをリファクタリングして、クリーンにします。この3ステップを数分間隔で何度も回しながら開発するのがテスト駆動開発です。 |
まずは、リピータハブがどのように動くか見て行きましょう。リピータハブにホスト 3 台をつなげた図 9-1のネットワークを考えてください。ホスト 1 からホスト 2 へパケットを送信すると、リピータハブは入ってきたパケットを複製し他のすべてのホストにばらまきます。つまり、通信に関係のないホスト 3 もホスト 2 宛のパケットを受信します。このように、リピータハブはラーニングスイッチ (7 章「すべての基本、ラーニングスイッチ」) のような MAC アドレスの学習は行わず、とにかくすべてのホストへパケットを送ってしまうので、バカハブとかダムハブとも呼びます。
これを OpenFlow で実装すると図 9-2のようになります。ホスト 1 がパケットを送信すると、スイッチからコントローラに Packet In が起こります。ここでコントローラは「今後は同様のパケットを他の全ポートへばらまけ (フラッディング)」という Flow Mod を打ちます。また、Packet In を起こしたホスト 1 からのパケットを他の全ポートへ Packet Out でフラッディングします。
おおまかな仕組みはわかったので、テストを書き始める前にテスト戦略を決めます。テスト戦略とは言い換えると「どこまでテストするか?」ということです。これは経験が必要なむずかしい問題なので、ソフトウェアテスト界の賢人達の言葉を借りることにしましょう。
テスト駆動開発の第一人者、ケント・ベックは stackoverflow.com の「どこまでテストするか?」というトピック [1] に次の投稿をしています。
私はテストコードではなく動くコードに対してお金をもらっているので、ある程度の確信が得られる最低限のテストをするというのが私の主義だ (このレベルは業界水準からすると高いのではと思うが、ただの思い上がりかもしれない)。ふつうある種のミスを自分は犯さないとわかっていれば (コンストラクタで間違った変数をセットするとか)、そのためのテストはしない。
Ruby on Rails の作者として有名な David Heinemeier Hansson 氏 (以下、DHH) は、彼の勤める Basecamp 社のブログ [2] で次のように語っています。
コードのすべての行にはコストがかかる。テストを書くのにも、更新するのにも、読んで理解するのにも時間がかかる。したがってテストを書くのに必要なコストよりも、テストから得られる利益を大きくしなければいけない。テストのやりすぎは当然ながら間違っている。
2人の言葉をまとめるとこうなります。
-
目的はテストコードではなく、コードが正しく動くこと
-
正しく動くと確信が得られる、最低限のテストコードを書こう
リピータハブのテスト戦略もこれに従いましょう。最低限のテストシナリオはこうなるはずです。
-
ホスト 1・ホスト 2・ホスト 3 をスイッチにつなげ、
-
リピータハブのコントローラを起動したとき、
-
ホスト 1 がホスト 2 へパケットを送ると、
-
ホスト 2・ホスト 3 がパケットを受け取る
それぞれのステップを順にテストコードに起こしていきます。
では、リピータハブの動作を Cucumber の受け入れテストにしていきます。最初のテストシナリオを思い出してください。
-
ホスト 1・ホスト 2・ホスト 3 をスイッチにつなげ、
-
リピータハブのコントローラを起動したとき、
-
ホスト 1 がホスト 2 へパケットを送ると、
-
ホスト 2・ホスト 3 がパケットを受け取る
テストシナリオを Cucumber の受け入れテストに置き換えるには、シナリオの各ステップをGiven(前提条件)・When(〜したとき)・Then(こうなる)の3つに分類します。
-
Given: ホスト 1・ホスト 2・ホスト 3 をスイッチにつなげ、リピータハブのコントローラを起動したとき、
-
When: ホスト 1 がホスト 2 へパケットを送ると、
-
Then: ホスト 2・ホスト 3 がパケットを受け取る。
では、まずは最初の Given ステップを Cucumber のコードに直します。
シナリオの前提条件 (Given) には、まずはコントローラにつなげるスイッチとホスト 3 台のネットワーク構成 (図 9-1) を記述します。Cucumber のテストファイル features/repeater_hub.feature
はこうなります:
Given a file named "trema.conf" with:
"""
vswitch('repeater_hub') { datapath_id 0xabc }
vhost('host1') {
ip '192.168.0.1'
promisc true
}
vhost('host2') {
ip '192.168.0.2'
promisc true
}
vhost('host3') {
ip '192.168.0.3'
promisc true
}
link 'repeater_hub', 'host1'
link 'repeater_hub', 'host2'
link 'repeater_hub', 'host3'
"""
最初の行 Given a file named "trema.conf" with: …
は、「… という内容のファイル trema.conf
があったとき、」を表すテストステップです。このように、Cucumber では英語 (自然言語) でテストステップを記述できます。
それぞれの仮想ホストで promisc
オプション (プロミスキャスモード。自分宛でないパケットも受け取れるようにするモード) を true
にしていることに注意してください。リピータハブはパケットをすべてのポートにばらまくので、こうすることでホストがどんなパケットでも受信できるようにしておきます。
続いて、この仮想ネットワーク上でコントローラを起動する Given ステップを次のように書きます。
And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"
これは、シェル上で次のコマンドを実行するのと同じです。
$ ./bin/trema run lib/repeater_hub.rb -c trema.conf -d
Given が書けたところですぐに実行してみます。まだ lib/repeater_hub.rb
ファイルを作っていないのでエラーになることはわかりきっていますが、エラーを確認するためにあえて実行します。次のコマンドを実行すると、受け入れテストファイル features/repeater_hub.feature
を実行しテスト結果を表示します。
$ ./bin/cucumber features/repeater_hub.feature
Feature: Repeater Hub example
@sudo
Scenario: Run
Given a file named "trema.conf" with:
"""
vswitch('repeater_hub') { datapath_id 0xabc }
vhost('host1') {
ip '192.168.0.1'
promisc true
}
vhost('host2') {
ip '192.168.0.2'
promisc true
}
vhost('host3') {
ip '192.168.0.3'
promisc true
}
link 'repeater_hub', 'host1'
link 'repeater_hub', 'host2'
link 'repeater_hub', 'host3'
"""
<<-STDERR
/home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/lib/trema/command.rb:40:in `load': cannot load such file -- ../../lib/repeater_hub.rb (LoadError)
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/lib/trema/command.rb:40:in `run'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:54:in `block (2 levels) in <module:App>'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/command_support.rb:126:in `call'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/command_support.rb:126:in `execute'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:296:in `block in call_command'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:309:in `call'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:309:in `call_command'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/gli-2.13.2/lib/gli/app_support.rb:83:in `run'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:252:in `<module:App>'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:14:in `<module:Trema>'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/gems/trema-0.7.1/bin/trema:12:in `<top (required)>'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/trema:23:in `load'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/trema:23:in `<main>'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `eval'
from /home/yasuhito/.rvm/gems/ruby-2.2.0/bin/ruby_executable_hooks:15:in `<main>'
STDERR
And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"
expected "trema run ../../lib/repeater_hub.rb -c trema.conf -d" to be successfully executed (RSpec::Expectations::ExpectationNotMetError)
./features/step_definitions/trema_steps.rb:41:in `/^I trema run "([^"]*)"( interactively)? with the configuration "([^"]*)"$/'
features/repeater_hub.feature:27:in `And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"'
Failing Scenarios:
cucumber features/repeater_hub.feature:5 # Scenario: Run as a daemon
1 scenario (1 failed)
3 steps (1 failed, 1 skipped, 1 passed)
0m8.113s
予想通り、trema run
の箇所でエラーになりました。エラーメッセージによると lib/repeater_hub.rb
というファイルが無いと言っています。このエラーを直すために、とりあえず空のファイルを作ります。
$ mkdir lib
$ touch lib/repeater_hub.rb
$ ./bin/cucumber features/repeater_hub.feature
再びテストを実行すると、今度は次のエラーメッセージが出ます。
$ ./bin/cucumber features/repeater_hub.feature
(中略)
<<-STDERR
error: No controller class is defined.
STDERR
And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf" # features/step_definitions/trema_steps.rb:30
expected "trema run ../../lib/repeater_hub.rb -c trema.conf -d" to be successfully executed (RSpec::Expectations::ExpectationNotMetError)
./features/step_definitions/trema_steps.rb:41:in `/^I trema run "([^"]*)"( interactively)? with the configuration "([^"]*)"$/'
features/repeater_hub.feature:27:in `And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"'
repeater_hub.rb にコントローラクラスが定義されていない、というエラーです。エラーを修正するために、RepeaterHub
クラスの定義を追加してみます。エラーを修正できればいいので、クラスの中身はまだ書きません。
class RepeaterHub < Trema::Controller
end
再びテストを実行してみます。今度はパスするはずです。
$ ./bin/cucumber features/repeater_hub.feature
(中略)
1 scenario (1 passed)
3 steps (3 passed)
0m18.207s
やりました! これで Given ステップは動作しました。
このようにテスト駆動開発では、最初にテストを書き、わざとエラーを起こしてからそれを直すためのコードをちょっとだけ追加します。テスト実行結果からのフィードバックを得ながら「テストを書き、コードを直す」を何度もくりかえしつつ最終的な完成形に近づけていくのです。
When には「〜したとき」というきっかけになる動作を記述します。ここでは、Given で定義したホスト host1 から host2 にパケットを送る処理を書きます。パケットを送るコマンドは、trema send_packets でした。Cucumber (Aruba) では、実行したいコマンドを次のように I run …
で直接書けます。
When I run `trema send_packets --source host1 --dest host2`
テストを一行追加しただけですが、念のため実行しておきます。
$ ./bin/cucumber features/repeater_hub.feature
(中略)
1 scenario (1 passed)
4 steps (4 passed)
0m21.910s
問題なくテストが通りました。次は Then に進みます。
Then には「最終的にこうなるはず」というテストを書きます。ここでは、「ホスト 2・ホスト 3 がパケットを受け取るはず」というステップを書けばよいですね。これは次のように書けます。
Then the number of packets received by "host2" should be:
| source | #packets |
| 192.168.0.1 | 1 |
And the number of packets received by "host3" should be:
| source | #packets |
| 192.168.0.1 | 1 |
このステップはテーブル形式をしており、ホスト 2・ホスト 3 それぞれについて、送信元 IP アドレス 192.168.0.1 からパケットを 1 つ受信するはず、ということを表しています。
ではさっそく実行してみます。
$ ./bin/cucumber features/repeater_hub.feature
(中略)
When I run `trema send_packets --source host1 --dest host2`
<<-STDERR
STDERR
Then the number of packets received by "host2" should be:
| source | #packets |
| 192.168.0.1 | 1 |
expected: 1
got: 0
(compared using ==)
(RSpec::Expectations::ExpectationNotMetError)
./features/step_definitions/show_stats_steps.rb:52:in `block (2 levels) in <top (required)>'
./features/step_definitions/show_stats_steps.rb:50:in `each'
./features/step_definitions/show_stats_steps.rb:50:in `/^the number of packets received by "(.*?)" should be:$/'
features/repeater_hub.feature:30:in `Then the number of packets received by "host2" should be:'
And the number of packets received by "host3" should be:
| source | #packets |
| 192.168.0.1 | 1 |
Failing Scenarios:
cucumber features/repeater_hub.feature:5 # Scenario: Run as a daemon
1 scenario (1 failed)
6 steps (1 failed, 1 skipped, 4 passed)
0m20.198s
host2 に 1 つ届くはずだったパケットが届いておらず、失敗しています。RepeaterHub
クラスはまだ何も機能を実装していないので当然です。
フラッディングをする Flow Mod を打ち込むコードを RepeaterHub
クラスに追加して、もう一度テストしてみます。
class RepeaterHub < Trema::Controller
def packet_in(datapath_id, packet_in)
send_flow_mod_add(
datapath_id,
match: ExactMatch.new(packet_in),
actions: SendOutPort.new(:flood)
)
end
end
$ ./bin/cucumber features/repeater_hub.feature
(中略)
Then the number of packets received by "host2" should be:
| source | #packets |
| 192.168.0.1 | 1 |
expected: 1
got: 0
失敗してしまいました。まだ host2 がパケットを受信できていません。そういえば、Flow Modしただけではパケットは送信されないので、明示的に Packet Out してやらないといけないのでした。そこで次のように Packet Out を追加します。
class RepeaterHub < Trema::Controller
def packet_in(datapath_id, packet_in)
send_flow_mod_add(
datapath_id,
match: ExactMatch.new(packet_in),
actions: SendOutPort.new(:flood)
)
send_packet_out(
datapath_id,
raw_data: packet_in.raw_data,
actions: SendOutPort.new(:flood)
)
end
end
再び実行してみます。
$ bundle exec cucumber features/repeater_hub.feature
Rack is disabled
Feature: "Repeater Hub" example
@sudo
Scenario: Run as a daemon
Given a file named "trema.conf" with:
"""
vswitch('repeater_hub') { datapath_id 0xabc }
vhost('host1') {
ip '192.168.0.1'
promisc true
}
vhost('host2') {
ip '192.168.0.2'
promisc true
}
vhost('host3') {
ip '192.168.0.3'
promisc true
}
link 'repeater_hub', 'host1'
link 'repeater_hub', 'host2'
link 'repeater_hub', 'host3'
"""
And I trema run "lib/repeater_hub.rb" with the configuration "trema.conf"
When I run `trema send_packets --source host1 --dest host2`
Then the number of packets received by "host2" should be:
| source | #packets |
| 192.168.0.1 | 1 |
And the number of packets received by "host3" should be:
| source | #packets |
| 192.168.0.1 | 1 |
1 scenario (1 passed)
6 steps (6 passed)
0m20.976s
すべてのテストに通りました! 次はテスト駆動開発で欠かせないステップであるリファクタリングに進みます。
リファクタリングとは、テストコードによってソフトウェアの振る舞いを保ちつつ、理解や修正が簡単になるようにソースコードを改善することです。Rubyにはリファクタリング用の便利なツールがたくさんあります。中でもよく使うツールは次の 4 つです。
RepeaterHub
クラスは十分簡潔ですが、念のためこの 4 つを使ってチェックしておきます。
$ ./bin/reek lib/repeater_hub.rb $ ./bin/flog lib/repeater_hub.rb 9.0: flog total 4.5: flog/method average 5.6: RepeaterHub#packet_in lib/repeater_hub.rb:7 $ ./bin/flay lib/repeater_hub.rb Total score (lower is better) = 0 $ ./bin/rubocop lib/repeater_hub.rb Inspecting 1 file . 1 file inspected, no offenses detected
reek
・flog
・flay
・rubocop
コマンドすべてで、エラーメッセージは出ていません。ただし flog
は複雑度を表示するだけなので、リファクタリングするかどうかは自分で判断する必要があります。今回のように、目安として複雑度が10ポイント以下であれば、リファクタリングの必要はありません。
もしもここでエラーメッセージが出た場合には、コントローラをリファクタリングします。エラーメッセージには修正のヒントが入っているので、それに従えば機械的に修正できます。動くテストコードがあるので、リファクタリングの最中に誤ってコードを壊してしまっても、すぐにミスしたことがわかります。
以上でコントローラとテストコードの一式が完成しました!
Tremaのユニットテストフレームワークを使ってリピータハブを作り、コントローラをテスト駆動開発する方法を学びました。今回学んだことは次の2つです。
-
Cucumber・Aruba・trema/cucumber_step_definitionsを使うと、コントローラを起動して仮想ホストの受信パケット数などをテストできる
-
テストをGiven・When・Thenの3ステップに分けて分析し設計する方法を学んだ。それぞれのステップをCucumberのテストコードに置き換えることで、テストコードが完成する
-
テストが通ったら必ずリファクタリングすること。
reek
・flog
・flay
・rubocop
を使うと、コードの問題点を客観的に洗い出してくれる
本書で紹介するすべてのサンプルコードには、テストコード (features/
以下) が付属しています。本格的にテストコードを書く人は、参考にしてください。
-
『テスト駆動開発入門』(Kent Beck著/ピアソン・エデュケーション) テスト駆動開発のバイブルです。もったいないことに日本語版は訳がまずく、意味の通らないところがたくさんあります。もし英語が苦でなければ、原著の英語版で読むことをおすすめします。
-
『リファクタリング』(Martin Fowler著/ピアソン・エデュケーション) この本の最大の功績は、コードのまずい兆候を「コードの臭い」と表現したことです。粗相をした赤ちゃんのおむつのように臭うコードには改善が必要で、この本にはそのためのレシピがそろっています。この本はJavaですが、Ruby版(『リファクタリング:Rubyエディション』Jay Fields、Shane Harvie、Martin Fowler、Kent Beck著/アスキー・メディアワークス)もあります。