functional test をめぐる冒険

趣旨

自動テストは重要だ。この主張に真っ向から異を唱えるひとは多くないだろう。

だが実際には、テストを書くのはいささか面倒だ。特にコントローラのテスト(functional test) に苦手意識を持っている人は多いのではないか。post やら get やらいろんなメソッドが前触れもなく出てくるコントローラのテストは、モデルのテスト(unit test) に比べると「ワケわからん」感が強い。そこで今日は、functional test の仕組みについてしつこく探究してみる。仕組みを理解しないと、コントローラのテストは書きづらいと思うからだ。

functional test の例

簡単なブログエンジンを作るとしよう。 Entry(ブログ記事) というモデルを考えて、それを CRUD するコントローラとビューの足場(scaffold) を作る。

% rails -d sqlite3 blog_test
% cd blog_test
% ruby script/generate model entry title:string body:text
% rake db:migrate
% ruby script/generate scaffold entry

ここで、test/functional/entries_controller_test.rb という EntriesController に対するテスト(functional test)が自動生成される。
その一部を抜粋すると以下のようになる。

esakai@charlie:~/dev/rails/blog_test/test/functional$ cat entries_controller_test.rb
require File.dirname(__FILE__) + '/../test_helper'
require 'entries_controller'

# Re-raise errors caught by the controller.
class EntriesController; def rescue_action(e) raise e end; end

class EntriesControllerTest < Test::Unit::TestCase
  fixtures :entries

  def setup
    @controller = EntriesController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new

    @first_id = entries(:first).id
  end

  def test_index
    get :index                #(1)
    assert_response :success  #(2)
    assert_template 'list'    #(3)
  end

#(中略)

  def test_update
    post :update, :id => @first_id  #(1)
    assert_response :redirect       #(2)
    assert_redirected_to :action => 'show', :id => @first_id #(3)
  end
 
# (以下略)
end
#($RAISAPP/test/functional/entries_controller_test.rb)

コントローラのテストは、あるシナリオに従ってコントローラにリクエストを投げ、期待するレスポンスが返ってくるかどうか調べるものだ。具体例として、test_index() を見てみよう。

(1) get :index

url_for(:action => :index) で生成されるパス(= /entries/index) を引数に GET リクエストを送る。

(2) assert_response :success

すると、レスポンスコード 200(= :sucess) が返ってくるはず。

(3) assert_template 'list'

そして、list.rhtml というテンプレートを使って HTML を生成して返すはず。

assert_xxx の assert というのは、「・・・のはず」と訳すとわかりやすいかもしれない。このシナリオに沿わない場合、テストが失敗するわけである。たとえば (2) でレスポンスコードが 200 でなくて 304(= :redirect) であったり、(3) で list.rhtml のかわりに show.rhtml がテンプレートとして使用されるような場合である。

もう一つ。test_update() を見てみる。

(1) post :update, :id => @first_id

url_for(:action => :update :id => @first_id) で生成されるパス(= /entries/update/#{@first_id}) を引数に POST リクエストを送る。

(2) assert_response :redirect

すると、レスポンスコード 304(= :redirect) が返ってくるはず。

(3) assert_redirected_to :action => 'show', :id => @first_id

リダイレクト先は url_for(:action => 'show, :id => @first_id) = /entries/show/#{@first_id} のはず。

ここでは、POST リクエストや 304 Redirect レスポンスコードが登場している。

そもそも GET / POST リクエストって何よ

GET リクエストは HTTP (hypertext transfer protocol) で使用されるクライアントからサーバへの指令である。HTTP に基づいて動くサーバ (HTTP サーバ)の動作原理は単純だ。基本的にはリクエストされたパスにあるファイルの内容を返すだけのファイルサーバとして機能している。この動きを直感的につかむ場合、シェルで次のようなコマンドを打ち込んでみるといい。

% telnet www.asahi.com 80
Trying 202.239.162.248...
Connected to www.asahi.com.
Escape character is '^]'.
GET / HTTP/1.1
host: www.example.com
(空行)

すると次のような出力が得られるはずだ。

HTTP/1.1 200 OK
Date: Tue, 24 Jul 2007 10:49:17 GMT
Server: Apache/2
Accept-Ranges: bytes
Content-Length: 47113
Content-Type: text/html

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html lang="ja">
<head>
...(以下、大量の出力)

ここでは www.asahi.com というサーバに接続してそのトップページを取得している。

"GET /" がまさに GET リクエストである。"GET /" は「サーバのドキュメントルートにあるファイルの内容を出力してほしい」というリクエストである。
POST リクエストというものもある。POST は副作用のある(サーバ側の状態を変更するような)リクエストのために使われる。

"HTTP/1.1 200 OK" がレスポンスコードである。レスポンスコードには、200 OK の他に 304 Redirect や 404 Not Found など、いろいろある。

ここまでのまとめ。

コントローラのテストコードは、基本的にクライアントの動作をエミュレートしたものである。コントローラはサーバの動作をエミュレートしており、テストコード=クライアントからのリクエストを受けて、それを処理し、レスポンスを返す。assert_XXX メソッドによって、レスポンスが「○○のはずだ」という主張を行い、それに合致しない場合、テストが失敗することになる。

get / post メソッドはどこで定義されているのか?

私が、コントローラのテストがなんとも苦手だったのは、テストメソッドのなかでいきなり get や post というメソッドがレシーバなしに現れるからだった。いったいこいつらはどこから来たのだ?Rails はさまざまなコードの記述量を最小化するために、まるで魔法のようなことをよくやる。get :index という書き方は簡潔ですばらしいが、その一方で何がどう動いているのかわからない気持ち悪さがある。

get / post メソッドの出自を調べるためには、Ruby のリフレクションの機能に助けを借りよう。私が以前書いたエントリ「現在のコンテキストで使用可能なメソッドの一覧を参考にして、上の EntriesControllerTest に次のようなメソッドを加えて実行してみる。

def test_reflection
  puts "methods = \n" + self.methods.collect { |m| self.method(m).inspect }.sort.join("\n")
#($RAISAPP/test/functional/entries_controller_test.rb)
end

(実はこのまま

% cd $RAILSAPP/test/functional
% ruby entries_controller_test.rb 

とやるとエラーになってしまう。まずは fixture をきちんと書かないといけない。

% vi $RAILSAPP/test/fixtures/entries.yml
first:
 id: 1
 title: title
 body: body

とする)

実行結果は長いので、関係ある部分だけを抜き出しておく。

#
...
#
...

() で囲まれた部分でメソッドが定義されている。get / post はどうやら Test::Unit::TestCase で定義されているらしい。オリジナルの Test::Unit::TestCase に get / post があるとは思えないので、Rails がどこかで追加したメソッドだろう。Controller 関係のファイルで定義されているだろうとアタリをつけて、action_controller の下を検索すると案の定$GEMSHOME/actionpack-1.13.3/lib/action_controller/test_process.rb に次のような箇所が見つかった。

module Test
  module Unit
    class TestCase #:nodoc:
      include ActionController::TestProcess
    end
  end
end
#($GEMSHOME/actionpack-1.13.3/lib/action_controller/test_process.rb)

ここで Test::Unit::TestCase というクラスが再定義されている。ActionController::TestProcess というモジュールが mix-in されている。このモジュールについてしばらく追いかけてみる。

module TestProcess
  def self.included(base)
    # execute the request simulating a specific http method and set/volley the response
    %w( get post put delete head ).each do |method|
      base.class_eval <<-EOV, __FILE__, __LINE__
        def #{method}(action, parameters = nil, session = nil, flash = nil)
          @request.env['REQUEST_METHOD'] = "#{method.upcase}" if defined?(@request)
          process(action, parameters, session, flash)
        end
      EOV
    end
  end
  ...
end
#($GEMSHOME/actionpack-1.13.3/lib/action_controller/test_process.rb)

TestProcess が include されるときに上のメソッドが実行される。ここで get や post というメソッドが動的に定義される。*1 たとえば get の場合、次のようなメソッドが定義されていると考えてよい。

def get(action, parameters = nil, session = nil, flash = nil)
  @request.env['REQUEST_METHOD'] = "GET" if defined?(@request)
  process(action, parameters, session, flash)
end
#($GEMSHOME/actionpack-1.13.3/lib/action_controller/test_process.rb)

実際の処理は process() に委譲されている。では process() はどうなっているのか?

def process(action, parameters = nil, session = nil, flash = nil)
  # Sanity check for required instance variables so we can give an
  # understandable error message.
  %w(@controller @request @response).each do |iv_name|
    if !instance_variables.include?(iv_name) || instance_variable_get(iv_name).nil?
      raise "#{iv_name} is nil: make sure you set it in your test's setup method."
    end
  end

  @request.recycle!

  @html_document = nil
  @request.env['REQUEST_METHOD'] ||= "GET"  #(1)
  @request.action = action.to_s             #(2)

  parameters ||= {}
  @request.assign_parameters(@controller.class.controller_path, action.to_s, parameters)     #(3)

  @request.session = ActionController::TestSession.new(session) unless session.nil?          #(4)
  @request.session["flash"] = ActionController::Flash::FlashHash.new.update(flash) if flash  #(5)
  build_request_uri(action, parameters)     #(6)
  @controller.process(@request, @response)  #(7)
end
#($GEMSHOME/actionpack-1.13.3/lib/action_controller/test_process.rb)

まず確認しておかなければならないのは、

  • process() は Test::Unit::TestCase のインスタンス・メソッドとして機能する。
  • コントローラテストクラス EntriesControllerTest は Test::Unit::TestCase のサブクラスである。

という点である。process() には @request, @response, @controller といったインスタンス変数が登場するが、これは EntriesControllerTest#setup で定義されているインスタンス変数を指している。

  def setup
    @controller = EntriesController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new

    @first_id = entries(:first).id
  end
#($RAISAPP/test/functional/entries_controller_test.rb) 再掲

このことを頭に入れて process() を眺めてみる。get() から呼び出したという前提で考えて、重要な部分を抜き出すと、

  1. @request.env['REQUEST_METHOD'] は 'GET' のまま。
  2. @request.action に 'get' を代入。
  3. @request に リクエストパラメータを設定。
  4. @request に セッションを設定。
  5. @request に flash を設定。(flash は特殊なセッション変数である)
  6. リクエスト URL を設定。
  7. コントローラにリクエストを投げて、@response に結果を取得する。

一言で言えば、@request を初期化し、@controller に対して処理を要求し、@response に結果を取得するということだ。最後の @controller.process(@request, @response) がまさしく処理の核心である。(この詳細には、立ち入らないことにする・・・しかしいつか追究してみたい)

結び

もっとしつこくコードを追っかけて説明したかったが、体力の限界を迎えた。ここまで説明すれば各自ソースコードが読めるだろう。この test_process.rb には、このほか TestRequest, TestResponse, TestSession というコントローラテスト用のモッククラスが定義されている。ちなみに assert_response, assert_template, assert_redirected_to などのコントローラテストの assert_XXX 系メソッドは、$GEMSHOME/actionpack-1.13.3/lib/action_controller/assertions/response_assertions.rb で定義されている。参照してほしい。

*1:get post put delete head と5種類しかリクエストがないのだから普通に def でメソッド定義してもよさそうなものだが、潔癖なまでに繰り返しを嫌う Rails の特徴がよく現れている