Infrastructure as Codeにおけるテスト駆動開発実践入門(下)
Infrastructure as Codeにおけるコード開発にテスト駆動開発を導入する具体的な方法を全二回に分けて説明します。(下)ではテスト駆動開発の具体的なやり方やIaCコードの開発への適用方法を見ていきます。
Infrastructure as Codeにおけるテスト駆動開発実践入門(上)はこちら
テスト駆動開発のやり方
ではここからはテスト駆動開発のやり方を具体的に見ていきましょう。一般的にテスト駆動開発は以下のように進めていきます。
- コーディングに際して、必要なタスクをTODOリストにまとめる
- TODOリストから一つの項目をピックアップして、まずテストを実装する
- このテストが対象のコードに対して「想定したとおりに」失敗することを確認する
- テストが成功するために必要最低限の実装を持ったコードを書く
- 2. で書いたテストが対象のコードに対して成功することを確認する
- リファクタリングが必要か判断し、必要ならリファクタリングをかける
- 2.-6.をTODOリストから項目がなくなるまで繰り返す
上記のやり方において、注意点をいくつか述べます。まず2. 4.における「一つの項目」、「必要最低限の実装」であることが重要です。これにより機能の作りこみすぎを防いでくれます。もし作りこみすぎてしまうと以下のデメリットがあります。
- そもそも使われるであろうと考えた90%の機能は使われません。つまりその機能の実装を後で読み取る開発者の工数、ドキュメントの占める領域が全て無駄になります
- コードを素早く実装するため・バグを減らすため、最適な方法は「あまりコードを書かないこと」ですが、作りこむと必要以上のコードを書いてしまうことになります
また3.における「想定した通りに」が重要です。想定したとおり以外の失敗にモジュールのロードエラーなどがあります。このような失敗のときやテストが成功してしまったとき、そのテストはテストとして機能していないので、テストコードの修正が必要になります。
一方で、このやり方をIaCコード開発に適用した場合、どう変わるかを見ていきましょう。その前にIaCコードのテストとは何かについて考えておきます。インフラにおけるテスト自動化ツールといえばServerspecがありますが、Serverspecはあくまでインフラの構成をテストする製品であり、IaCコードをテストする製品ではありません。つまりServerspecなどのインフラテスト自動化ツールのコードを直接、JUnitやRspecなどのアプリケーションに対するテストコードと同一視することはできません。しかし、IaCコードがインフラの構成を表していることを思い出すと、インフラテスト自動化ツールのコードをIaCコードのテストコードとして見ることができるようになります。要するにIaCコードのテストとはインフラテスト自動化ツールの適用ということができます。このようなロジックを経て、IaCコードとインフラテスト自動化ツールのコードの関係は、アプリケーションとそのテストコードの関係と同じになり、テスト駆動開発にインフラテスト自動化ツールを組み込むことが可能になります。
以上の議論からテスト駆動開発をIaCコード開発に適用した場合のやり方は以下のようになります。
- コーディングに際して、必要なタスクをTODOリストにまとめる
- TODOリストから一つの項目をピックアップして、まずテストを実装する。
- このテストが対象のコードに対して「想定したとおりに」失敗することを確認する
- 前のテストで利用したテスト用インフラがあれば破棄する
- テスト用インフラを作成する
- 現状のIaCコードをテスト用インフラに適用する
- テストをテスト用インフラに対して実施し、「想定したとおりに」失敗することを確認する
- テストが成功するために必要最低限の実装を持ったコードを書く
- 2. で書いたテストが対象のコードに対して成功することを確認する
- 前のテストで利用したテスト用インフラがあれば破棄する
- テスト用インフラを作成する
- 現状のIaCコードをテスト用インフラに適用する
- テストをテスト用インフラに対して実施し、成功することを確認する
- リファクタリングが必要か判断し、必要ならリファクタリングをかける
- 2.-6.をTODOリストから項目がなくなるまで繰り返す
特に構成管理ツールにAnsible、インフラ自動化テストツールにServerspec、さらに構成管理対象インフラにサーバを採用した場合は以下のようになります。
- コーディングに際して、必要なタスクをTODOリストにまとめる
- TODOリストから一つの項目をピックアップして、まずServerspecを実装する。
- このServerspecが対象のAnsibleのPlaybookに対して「想定したとおりに」失敗することを確認する
- 前のテストで利用したテスト用仮想マシンがあれば破棄する(スナップショットを戻すでも可)
- テスト用仮想マシンを作成する(スナップショットを戻すでも可)
- 現状のPlaybookをテスト用仮想マシンに適用する
- Serverspecをテスト用仮想マシンに対して実施し、「想定したとおりに」失敗することを確認する
- Serverspecが成功するために必要最低限の実装を持ったPlaybookを書く
- 2.で書いたServerspecが対象のPlaybookに対して成功することを確認する
- 前のテストで利用したテスト用仮想マシンがあれば破棄する(スナップショットを戻すでも可)
- テスト用仮想マシンを作成する(スナップショットを戻すでも可)
- 現状のPlaybookをテスト用仮想マシンに適用する
- Serverspecをテスト用仮想マシンに対して実施し、成功することを確認する
- リファクタリングが必要か判断し、必要ならリファクタリングをかける
- 2.-6.をTODOリストから項目がなくなるまで繰り返す
3.と5.の手順が詳細化されていることが一般的なテスト駆動開発のやり方との違いになります。アプリケーション開発の場合は3.,5.のステップにおいて作成したコードを直接テストすることができますが、IaCコード開発においてはインフラテスト自動化ツールのテスト対象があくまでインフラの構成であるため、IaCコードのテストをするためにはまず、テスト用インフラを用意し(1.,2.)、IaCコードをテスト用インフラに適用して(3.)、コードに記載されたインフラの構成をテスト用インフラに移す作業が必要になります。
さて、ここである問題に気が付きます。概念的にはこのようにテスト駆動開発をやることがベストなのですが、実際にこのやり方で開発を行うと開発リズム・スピードが非常に悪いことに気が付きます。その理由は現状、テスト用インフラを再作成するのに時間がかかるからです。このやり方ですと、例えばサーバにhttpdパッケージを導入する機能を追加するのに一回仮想マシンを再作成し、次にhttpd-develパッケージを導入する機能を追加するのにも再び仮想マシンを再作成しなければなりません。アプリケーション開発においてクラスからテスト用インスタンスを生成するのは一瞬で終わりますが、仮想マシンの再作成となるとさすがに一瞬では終わらず、開発のストレスがたまります。
現状、この問題の回避策を以下のように考えています。基本的にはテスト用仮想マシンの再作成は必要最低限にしようという回避策になります。
- IaCコードの再適用によってテスト用仮想マシンの状態に依存せず、コードに記載されているサーバの構成になることが期待されるのであれば、テスト用仮想マシンの再作成の手順をスキップする。つまり、IaCコードの処理において冪等性が保持されており、かつ不可逆な設定項目に対する設定後変更がない場合に、テスト用仮想マシンの再作成をスキップする
- 不可逆な設定項目において複数の設定値をテストをしたいときは、その設定値の数分テスト用仮想マシンを作成しておく。これにより、不可逆な設定項目に対する設定後変更が生じないようにし、1. と合わせてテスト用仮想マシンの再作成をスキップすることができる
- TODOリストの項目がなくなったとき、テスト用仮想マシンの再作成および全テストを行い、IaCコードに対する全てのテストが成功することを確認する
この他にもテスト用仮想マシンの再作成がテストを成功させるために必要であれば、そのタイミングで再作成を行います。ただし、開発のリズムを崩さないために再作成は必要最小限にすることを意識します。
Ansible・Serverspecを用いたテスト駆動開発の実践
テスト駆動開発のやり方がわかったところで、実際にAnsible、Serverspecを用いて簡単なテスト駆動開発をやってみましょう! お手元に以下をご用意ください。
- Ansible、Serverspecがインストールされたマシン(以降、コントロールマシン)
- 上記マシンと疎通可能でインターネットにつながっている仮想マシン(以降、ターゲットマシン)
なお、環境は以下の通りとします。
- コントロールマシン
- CentOS 7.2.1511
- ansible 2.2.1.0
- serverspec 2.38.0
- ターゲットマシン
- CentOS 7.2.1511
- IPアドレス: 192.168.1.101
- 接続ユーザ: centos(無条件sudo許可)
今回はサーバを以下の構成に設定可能なプレイブックの開発を行いたいと思います。
- Apache HTTP ServerがServerName X、TCP Y番ポートで稼働し、システム起動と同時に自動起動する
では、ステップ・バイ・ステップでテスト駆動開発をしていきましょう。
コーディングに際して、必要なタスクをTODOリストにまとめる
まずは開発するPlaybookが担うべきタスクをリストにまとめます。今回でいうとこんな感じでしょうか。
- Apache HTTP Serverをインストールする
- 設定ファイルhttpd.conf中のディレクティブServerNameをXに設定する
- 設定ファイルhttpd.conf中のディレクティブListenをYに設定する
- サービスhttpdを起動する
- サービスhttpdの自動起動をONにする
TODOリストから一つの項目をピックアップして、まずServerspecを実装する。
次にTODOリストから一つの項目をピックアップします。リストの順番通り以下をピックアップしていきましょう。
- Apache HTTP Serverをインストールする
では早速Serverspecを実装します。コントロールマシンにログインし、空のディレクトリを作ってください。作ったディレクトリに移動し、以下のコマンドを叩きます。本コマンドによりServerspecを実行するためのサンプル環境が自動的に作成されます。
[tdd_controller@workcent01 tdd]$ serverspec-init
Select OS type:
1) UN*X
2) Windows
Select number: 1
Select a backend type:
1) SSH
2) Exec (local)
Select number: 1
Vagrant instance y/n: n
Input target host name: 192.168.1.101
+ spec/
+ spec/192.168.1.101/
+ spec/192.168.1.101/sample_spec.rb
+ spec/spec_helper.rb
+ Rakefile
+ .rspec
[tdd_controller@workcent01 tdd]$
serverspec-init
コマンドにより、spec/192.168.1.101/sample_spec.rb
というApache HTTP Serverに対するサンプルテストコードが自動的に作成されますが、今回テストコードは一から作りますので、こちらは削除します。
[tdd_controller@workcent01 tdd]$ rm spec/192.168.1.101/sample_spec.rb
テストコードの作成準備が整いましたので、テストコードを作っていきましょう。以下の内容のファイルspec/192.168.1.101/httpd_spec.rb
を作成します。
require 'spec_helper'
describe package('httpd') do
it { should be_installed }
end
ご覧の通り、パッケージhttpdがインストールされていることを確認するだけのテストコードです。やり方に忠実に従って、まずこれだけを実装します。
このServerspecが対象のAnsibleのPlaybookに対して「想定したとおりに」失敗することを確認する
次は前の手順で実装したServerspecを実行し、「想定したとおりに」失敗することを確認します。今のテストコードにおける「想定したとおり」の失敗は「パッケージhttpdがインストールされていないことが原因での失敗」になります。では実行してみましょう。
[tdd_controller@workcent01 tdd]$ rake spec
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb
Package "httpd"
tdd_controller@192.168.1.101's password:
tdd_controller@192.168.1.101's password:
tdd_controller@192.168.1.101's password:
should be installed (FAILED - 1)
Failures:
1) Package "httpd" should be installed
On host `192.168.1.101'
Failure/Error: it { should be_installed }
Net::SSH::AuthenticationFailed:
Authentication failed for user tdd_controller@192.168.1.101
# ./spec/192.168.1.101/httpd_spec.rb:4:in `block (2 levels) in <top (required)>'
Finished in 16.48 seconds (files took 0.61266 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/192.168.1.101/httpd_spec.rb:4 # Package "httpd" should be installed
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb failed
ユーザtdd_controller
のパスワードが聞かれ、認証ミスによる失敗をしました。これは「想定したとおり」の失敗ではありません。テストコードを修正する必要があります。
今回ターゲットサーバに接続するユーザはcentos
ユーザなので、このユーザでログインできるように、spec/spec_helper.rb
を修正します。
[tdd_controller@workcent01 tdd]$ diff spec/spec_helper.rb spec/spec_helper.rb.org
21c21
< options[:user] = ENV['TARGET_USER'] || Etc.getlogin
---
> options[:user] ||= Etc.getlogin
ついでに、パスワードも環境変数で指定できるようにしましょう。
[tdd_controller@workcent01 tdd]$ diff spec/spec_helper.rb spec/spec_helper.rb.org
23d22
< options[:password] = ENV['TARGET_PASS']
では気を取り直して、接続ユーザとパスワードを指定し再度Serverspecを実行します。
[tdd_controller@workcent01 tdd]$ export TARGET_USER=centos
[tdd_controller@workcent01 tdd]$ export TARGET_PASS=[centosユーザのパスワード]
[tdd_controller@workcent01 tdd]$ rake spec
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb
Package "httpd"
Please write "set :request_pty, true" in your spec_helper.rb or other appropriate file.
Finished in 3.62 seconds (files took 0.62021 seconds to load)
1 example, 0 failures
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb failed
今度はspec_helper.rbにset :request_pty, true
の記述がないというメッセージを吐いて失敗しました。Serverspecの言う通り、この記述を追加してあげましょう。1
[tdd_controller@workcent01 tdd]$ diff spec/spec_helper.rb spec/spec_helper.rb.org
5d4
< set :request_pty, true
三度になりますが、再度Serverspecを実行しましょう。
[tdd_controller@workcent01 tdd]$ rake spec
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb
Package "httpd"
should be installed (FAILED - 1)
Failures:
1) Package "httpd" should be installed
On host `192.168.1.101'
Failure/Error: it { should be_installed }
expected Package "httpd" to be installed
sudo -p 'Password: ' /bin/sh -c rpm\ -q\ httpd
package httpd is not installed
# ./spec/192.168.1.101/httpd_spec.rb:4:in `block (2 levels) in <top (required)>'
Finished in 3.18 seconds (files took 0.57427 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/192.168.1.101/httpd_spec.rb:4 # Package "httpd" should be installed
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb failed
おめでとうございます! 今度は見事、パッケージhttpdが存在しないことが原因でServerspecが失敗しました! これでようやくプレイブックの開発に移ることができます!
Serverspecが成功するために必要最低限の実装を持ったPlaybookを書く
次はいよいよAnsibleのプレイブックを書いていきます。とその前に、プレイブックの実行にはインベントリファイルが必要なので、ここで作っておきましょう。以下の内容のファイルinventory
を作成します。
192.168.1.101 ansible_user=centos ansible_ssh_pass=[centosユーザのパスワード]
ではプレイブックsetting_httpd.yml
を書いていきます。必要最低限の実装なので、これで十分でしょう。
---
- hosts: 192.168.1.101
become: yes
tasks:
- name: install httpd
yum:
name: httpd
state: installed
Serverspecが対象のPlaybookに対して成功することを確認する
次は実装した機能のテストをします。ターゲットサーバはまだ設定を一度も行っていないきれいな状態なので、再作成の必要はありません。Ansibleの実行をしていきましょう。
Ansibleの実行ですが、手動で実行するのではなくServerspecにキックさせます。その理由はテスト駆動開発をリズムよく行うためです。設定値が変わるごとに手動でAnsibleの実行、Serverspecの実行を行っていたのでは開発のリズムが悪くなります。ですので、設定値の変更とAnsibleの実行もServerspecに任せることにするのです。具体的にはテストコードを以下のように修正します。
require 'spec_helper'
describe ('case01')
before :all do
system("ansible-playbook -i inventory setting_httpd.yml")
end
describe package('httpd') do
it { should be_installed }
end
end
修正後、Serverspecを実行してみてください。
[tdd_controller@workcent01 tdd]$ rake spec
/home/tdd_controller/.rbenv/versions/2.2.4/bin/ruby -I/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-support-3.4.1/lib:/home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/lib /home/tdd_controller/.rbenv/versions/2.2.4/lib/ruby/gems/2.2.0/gems/rspec-core-3.4.4/exe/rspec --pattern spec/192.168.1.101/\*_spec.rb
case01
PLAY [192.168.1.101] ***********************************************************
TASK [setup] *******************************************************************
ok: [192.168.1.101]
TASK [install httpd] ***********************************************************
changed: [192.168.1.101]
PLAY RECAP *********************************************************************
192.168.1.101 : ok=2 changed=1 unreachable=0 failed=0
Package "httpd"
should be installed
Finished in 4.14 seconds (files took 19.16 seconds to load)
1 example, 0 failures
[tdd_controller@workcent01 tdd]$
見事テストが成功しました!
リファクタリングが必要か判断し、必要ならリファクタリングをかける
現状だとリファクタリングの必要はないので、この手順はスキップします。
TODOリストから項目がなくなるまで繰り返す
これでパッケージhttpdをインストールする機能の実装は完了しました。あとは同様にしてTODOリストから残りのタスクを一つずつピックアップし、開発を続けていきます。最終的に今回のTODOリストに従うと、以下のようなテストコードとプレイブックになりました。
require 'spec_helper'
describe ("case01") do
before :all do
system("cp host_vars/192.168.1.101_01.yml host_vars/192.168.1.101.yml")
system("ansible-playbook -i inventory setting_httpd.yml")
end
describe package('httpd') do
it { should be_installed }
end
describe file('/etc/httpd/conf/httpd.conf') do
its(:content) { should match /^ServerName tdd_target.example.com:8080$/ }
its(:content) { should match /^Listen 8080$/ }
end
describe service('httpd') do
it { should be_running }
it { should be_enabled }
end
end
describe ("case02") do
before :all do
system("cp host_vars/192.168.1.101_02.yml host_vars/192.168.1.101.yml")
system("ansible-playbook -i inventory setting_httpd.yml")
end
describe file('/etc/httpd/conf/httpd.conf') do
its(:content) { should match /^ServerName www.example.com:80$/ }
its(:content) { should match /^Listen 80$/ }
end
describe service('httpd') do
it { should be_running }
end
end
---
- hosts: 192.168.1.101
become: yes
tasks:
- name: install httpd
yum:
name: httpd
state: installed
- name: set ServerName
lineinfile:
dest: /etc/httpd/conf/httpd.conf
regexp: "#?ServerName .*"
line: "ServerName {{ ServerName }}"
- name: set Listen
lineinfile:
dest: /etc/httpd/conf/httpd.conf
regexp: "#?Listen .*"
line: "Listen {{ Listen }}"
- name: start httpd
service:
name: httpd
state: started
enabled: yes
ちなみにこのプレイブックですと、ServerNameやListenを変更したときにサービスhttpdが再起動されませんので、二回目以降のAnsible実行を考えたときにはhandlersを実装する必要があります。しかし、今回のTODOリストではhandlersを実装するためのタスクがなかったので、この機能が必要であれば、TODOリストにタスクを追加し、開発を続ける形になるかと思います。
おわりに
いかがでしたでしょうか。簡単にですがIaCにおけるテスト駆動開発について、説明させていただきました。IaCコードの開発手法やテストについてはアプリケーション開発のそれほど議論されていない状況ですので、本記事をきっかけに考えていただける方が増えると嬉しく思います。
Infrastructure as Codeにおけるテスト駆動開発実践入門(上)はこちら
参考文献・URL
-
この記述はターゲットサーバに非rootユーザで接続する際、Serverspecがsudoを叩くために必要となるものです ↩