todo をサンプルに UT ってか TDD のマネ

何度も todo のプロジェクト作成しとります。今度は AWDwR を見つつ、unit test を作成してみる事に。

プロジェクト作成

大体は以下のような感じで準備を。

$ ruby rails/railties/bin/rails todo
$ cp rails todo/vendor/ -r
$ cp scriptaculous/lib/*.js todo/public/javascripts/.
$ cp scriptaculous/src/*.js todo/public/javascripts/.
$ wget http://rails2u.com/tmp/active_record_ja.rb.txt
$ cp active_record_ja.rb.txt todo/lib/active_record_ja.rb

で、AR の validate メセジの日本語化、というナニでやんないとイカン事を以下に。

  • active_record_ja.rb をオとしてきて lib/ にコピる
  • config/environment.rb に require 'lib/active_record_ja.rb' を追加
  • config/environment.rb に $KCODE = 'u' を追加 (先頭??)

データベースの用意

DB 作って database.yml 修正して model 作って migrate という流れ。
DB はサーバが稼動しているマシンにて。

$ mysql -u root <<EOF
> create database todo_dev default character set utf8 ;
> grant all privileges on todo_dev.* to xxx@172.16.100.4 identified by 'xxx';
> EOF
$

database.yml の一部。

development:
 adapter: mysql
 database: todo_dev
 username: xxx
 password: xxx
 host: 192.168.0.1
test:
 adapter: mysql
 database: todo_dev
 username: xxx
 password: xxx
 host: 192.168.0.1

model 作成

$ script/generate model list
    exists  app/models/
    exists  test/unit/
    exists  test/fixtures/
    create  app/models/list.rb
    create  test/unit/list_test.rb
    create  test/fixtures/lists.yml
    create  db/migrate
    create  db/migrate/001_create_lists.rb
$ script/generate model item
    exists  app/models/
    exists  test/unit/
    exists  test/fixtures/
    create  app/models/item.rb
    create  test/unit/item_test.rb
    create  test/fixtures/items.yml
    exists  db/migrate
    create  db/migrate/002_create_items.rb
$
||

db/migrate 配下を以下の通り修正

db/migrate/001_create_lists.rb
>||
 def self.up
  create_table :lists do |t|
    # t.column :name, :string
    t.column :title, :string
    t.column :created_on, :time
    t.column :updated_on, :time
  end
 end

 def self.down
  drop_table :lists
 end
end

db/migrate/002_create_items.rb

class CreateItems < ActiveRecord::Migration
 def self.up
  create_table :items do |t|
    # t.column :name, :string
    t.column :list_id, :integer
    t.column :note, :string
    t.column :completion, :boolean, :default => false
    t.column :position, :int
    t.column :created_on, :time
    t.column :updated_on, :time
  end
 end

 def self.down
  drop_table :items
 end
end

修正後、テーブルを作成

$ rake migrate

生成結果の確認は略。

model の仕様と試験項目

以前のエントリで情報投入してますが、再度まとめておきます。

  • list
    • 主キーは id
    • 0 個以上の item を保持
    • 削除時には保持している item も同時に削除
    • 従属する item はその position 列順に取り出す事が可能
    • title 列は必須入力
  • item
    • 主キーは id
    • list に従属
    • list に対する item リストとして動作
    • note 列は必須入力
    • position 列はシステム側で対応

若干微妙。エントリ投下した後で修正する可能性大。

あと、AWDwR より model な UT の観点を以下に列挙。

  • CRUD の試験
  • validation
  • ビジネスルール

これに乗っかって試験項目を羅列してみる。controller 作成時に後ヅケで試験はするはずな項目も付けておく。

  • list
    • Create な確認
      • List.new(:title => 'title) して save
    • 主キーが dup な Create の確認
    • Read な確認
      • List.find.all したものを順に取り出してみる
      • List.find('title')
      • List.find(1)
    • 存在しない ID で Read
    • Update な確認
      • find して代入して save
      • List.update(1, :title => 'title2')
    • 存在しない ID で Update
    • Delete な確認
      • List.find(1).destroy
    • 存在しない ID で Delete (無理??)
    • title ナシで Create
      • validate なメセジはどうやって確認??
    • 削除時の動作確認
    • items の取り出し
  • item
    • CRD は略 (上記と同様)
    • Update な確認
      • toggle! の動作確認
    • note ナシで Create
    • Create 時の position の値の確認
    • Delete 時の position の値の確認

test の記述

まず fixtures から、てコトで test/fixtures 見るとスデにファイルあり。

$ cat test/fixtures/lists.yml
# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
first:
 id: 1
another:
 id: 2
$

という事で、とりあえず作成を。

test/fixtures/lists.yml

# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
first:
 id: 1
 title: number-1
another:
 id: 2
 title: number-2

test/fixtures/items.yml

# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
first:
 id: 1
 list_id: 1
 note: aaa
 completion: true
 position: 1
second:
 id: 2
 list_id: 1
 note: bbb
 completion: false
 position: 2
third:
 id: 3
 list_id: 1
 note: ccc
 completion: false
 position: 3
another:
 id: 4
 list_id: 2
 note: ddd
 completion: false
 position: 1

なんかテキトー感満載だが、とりあえずこれでヤッてみる。AWDwR のナニを真似て list_test.rb を修正してみる。(実は database.yml の test: を未修正でオコラれてます)

test/unit/list_test.rb

require File.dirname(__FILE__) + '/../test_helper'

class ListTest < Test::Unit::TestCase
 fixtures :lists

 def setup
   @list = List.find(1)
 end

 # Replace this with your real tests.
 def test_truth
   assert_kind_of List, @list
 end
end

で、実行。

$ ruby test/unit/list_test.rb
Loaded suite test/unit/list_test
Started
.
Finished in 1.739894 seconds.

1 tests, 1 assertions, 0 failures, 0 errors
$

いやはや。
DB を確認してみると、fixture でナニしたデータが格納されたままになっているが、試験を動かす度に reload されている模様 (データ変えて確認してみました)。これって DB を複数メンツで共用している場合に微妙だなぁ。基本的には結合まで (development と test のナニまで?) は localhost な DB を用意して、という形になるのかなぁ。

どんどん追加。まず Create なナニを。

 def test_create
   assert_equal 1, @list.id
   assert_equal "number-1", @list.title
 end

で、実行。

$ ruby test/unit/list_test.rb
Loaded suite test/unit/list_test
Started
..
Finished in 0.429548 seconds.

2 tests, 3 assertions, 0 failures, 0 errors
$

で試しに以下のような試験を作ってみたのだが

 def test_new
   tmp = List.new(:title => 'number-3')
   tmp.save
   tmp = List.find(3)
   assert_equal 3, tmp.id
   assert_equal "number-3", tmp.title
 end

駄目な模様。AWDwR にも new して save なナニの記述がなく、これは別途調べてみた方が良いかもしれない。上記のナニはとりあえずスルーして、AWDwR の作法をトレースしてみる事に。

と、いう事は次は Update ですな。

 def test_update
   assert_equal "number-1", @list.title
   @list.title = "number-1.R"
   assert @list.save, @list.errors.full_messages.join("; ")
   @list.reload
   assert_equal "number-1.R", @list.title
 end

上記を追加後、実行。

$ ruby test/unit/list_test.rb
Loaded suite test/unit/list_test
Started
...
Finished in 0.144762 seconds.

3 tests, 6 assertions, 0 failures, 0 errors

OK ですな。だんだんコマンド打つのが面倒になりつつある (とは言え、C-p なんですが) ので rake でなんとかならんのか、と google 先生に聞いてみると

というのを教えてくれた。
基本的には rake --tasks で確認可能との事。

若干、この行

   assert @list.save, @list.errors.full_messages.join("; ")

が微妙だったので調査。
assert は一番目の引数が真ならパスって意味なのか (こら)。てコトは AR の save メソドは boolean を返すんですね。
で、assert した場合は、@list.errors.full_messages.join("; ") を出力、と。

  • errors は ActiveRecord::Errors というクラスとの事。
  • full_messages は_Returns all the full error messages in an array. (Rails API ドキュメントより引用)_との事にてセミコロンでつなげたナニを出力、ってコトですか。

Read な確認がアレだな、と思い以下のような試験を書いた。

 def test_read
   lists = List.find_all
   idx = 1
   lists.each do | x, |
     assert_equal x.id, idx
     idx += 1
   end
 end

# 少々コードが臭いがご勘弁下さいまし。
で、実行したら WARNING が。

$ ruby test/unit/list_test.rb
Loaded suite test/unit/list_test
Started
.WARNING: find_all is deprecated and will be removed from the next
Rails release (find_all at
/home/rms/todo/config/../vendor/rails/activerecord/lib/../../activesupport/lib/active_support/deprecation.rb:40)
...
Finished in 0.85223 seconds.

4 tests, 8 assertions, 0 failures, 0 errors
$

find_all って無くなるのか。代替なナニはどれなんだろうか。ここまで指摘してくれるとは有り難い限りだなぁ。Rails API ドキュメントによると find(:all) で良いとの事。
しかし何も考えずに each で取り出しゃ id 順で取れるっしょ、と決めつけてたんですがその通りに動いたな (こら)。

あと、_存在しない ID で Read_という試験 (List.find(5) みたいな)をしてみたら

ActiveRecord::RecordNotFound: Couldn't find List with ID=5

と怒られた。こんな試験はしなくても良いとゆー事か。(本当??

どんどん作る。List.update(1, :title => 'title2') な試験も書いてみた。

 def test_update2
   assert List.update(1, :title => 'number-1.R')
   assert_equal "number-1.R", List.find(1).title
 end

OK でした。アプデイトしても DB 上では変更かかってない、ってコトは各自が fixture を持ってれば DB 環境の共有って可能なのかなぁ。これはヤッてみないと分からんな。

こんどは destroy。

 def test_destroy
   @list.destroy
   assert_raise(ActiveRecord::RecordNotFound) {List.find(@list.id)}
 end

む。ちょと上で書いた_存在しない ID で試験_はこうすりゃ良いのか。ってか、find 失敗したら例外になるのかよ。
を、てコトは存在しない id でアプデイトな試験って書けるな。

   assert_raise(ActiveRecord::RecordNotFound) {List.update(5, :title => 'xx')}

次です。CRUD は終了ってコトで、validate を AWDwR 流に。errors.add してないんで assert を一つ略。

 def test_validate
   assert_equal "number-1", @list.title
   @list.title = ""
   assert !@list.save
   assert_equal 1, @list.errors.count
 end

試験書いたので早速実行したみたら

$ ruby test/unit/list_test.rb
Loaded suite test/unit/list_test
Started
......F
Finished in 1.087787 seconds.

 1) Failure:
test_validate(ListTest) [test/unit/list_test.rb:52]:
<false> is not true.

7 tests, 14 assertions, 1 failures, 0 errors

なにー、と言いつつ app/model/list.rb 見たら空だし。ってか、ここまでコードが空っぽでも通ってしまうあたりもナニげに凄い。
もう一点。AWDwR の Keepint Tests Flexible を試してみたんですが、@lists も @first も参照できんかった。別途調査が必要。

もう少し。このアプリにおいては_ビジネスルール_なんてのはないんで少々寂しいんですが (本当か?)、とりあえずもう少し試験を書く必要あり。要件としては

  • list が削除されたら item 削除されているか
  • items を取得して each でバラしたら position 順に読むか

ですので、試験を書いてみる。

 def test_items
   tmp = List.find(1)
   pos = 1
   tmp.items.each do | i |
     assert_equal i, tmp.position
     i += 1
   end

   tmp = List.find(2)
   tmp.destroy
   assert_raise(ActiveRecord::RecordNotFound) {List.find(2)}
   assert_raise(ActiveRecord::RecordNotFound) {Item.find(4)}
 end

あたりまえですが、動きません。とりあえず上で書いた試験項目なナニは満足しているはずなので、モデルのコードを書いてみます。

app/models/list.rb

class List < ActiveRecord::Base
 has_many :items, :order => 'position', :dependent => true
 set_field_names :title => 'タイトル'
 validates_presence_of :title
end

app/models/item.rb

class Item < ActiveRecord::Base
 belongs_to :list
 set_field_names :note => 'やること'
 acts_as_list :scope => :list
 validates_presence_of :note
end

で、試験を動かしてみるとどうなるか。
て、エラー出てるし試験なコードバグってるし。以下のように修正。

 def test_items
   tmp = List.find(1)
   pos = 1
   tmp.items.each do | i |
     assert_equal pos, i.position
     pos += 1
   end

とほほ。ダメダメっす。試験書きながら思っていたんですが、UT なコードの妥当性は誰が検証できるんでしょうか。てか、だからこそ、のペアプロなんでしょうね。

とりあえず list の試験はデキた。
ただ、Create がデキぬ、という部分が少々ひっカカるなあ。
item 分は冗長なので略。ただ、AWDwR 的には Controller の試験も含めて色々なワザが載っているようなので試してみて log を残しておきたいかな、と。

追記

自宅環境に持ち帰って様子を見ようとしてハマった事項を以下に。

  • DB 作ってない
  • config/database.yml なホストの IP が修正されてない
  • chroot 環境、/proc が mount されてなかった (原因不明

追記 2

item 方面なナニも以下に。

require File.dirname(__FILE__) + '/../test_helper'

class ItemTest < Test::Unit::TestCase
  fixtures :items

  def setup
    @item = Item.find(1)
  end

  # Replace this with your real tests.
  def test_truth
    assert_kind_of Item, @item
  end

  def test_create
    assert_equal 1, @item.id
    assert_equal "aaa", @item.note
    assert_equal true, @item.completion
    assert_equal 1, @item.position
  end

  def test_update
    assert_equal "aaa", @item.note
    @item.note = "bbb"
    assert @item.save, @item.errors.full_messages.join("; ")
    @item.reload
    assert_equal "bbb", @item.note
  end

  def test_read
    items = Item.find(:all)
    idx = 1
    items.each do | itm |
      assert_equal itm.id, idx
      idx += 1
    end
  end

  def test_update2
    assert Item.update(1, :note => 'bbb')
    assert_equal "bbb", Item.find(1).note

    assert_raise(ActiveRecord::RecordNotFound) {Item.update(5, :title => 'xx')}

    assert_equal true, @item.completion
    assert (@item.toggle! :completion)
    assert_equal false, @item.completion
  end

  def test_destroy
    @item.destroy
    assert_raise(ActiveRecord::RecordNotFound) {Item.find(@item.id)}
  end

  def test_validate
    assert_equal "aaa", @item.note
    @item.note = ""
    assert !@item.save
    assert_equal 1, @item.errors.count
  end

  def test_destroy2
    Item.find(2).destroy
    assert_equal 2, Item.find(3).position
  end
  
end