株式会社クリアコード > ククログ

ククログ


Test::Unit 2.0.1リリース

Test::Unit 2.0.1が RubyForge上でリリースされました。RubyGemsも提供されているの で以下のようにインストールできます。

% sudo gem install test-unit

経緯

Test::UnitはRuby 1.8.xに標準添付されている単体テストフレーム ワークです。しかし、Ruby 1.9.1からは miniunitが標準 添付され、Test::UnitはRubyForgeで開発が継続されることになりま した。これからもTest::Unitを使うときはRubyGemsでインストール することになるでしょう。

Ruby 1.8.xに標準添付されているTest::Unitは互換性のために、 Test::Unit 1.2.3としてリリースされています。Ruby 1.9.1でも Ruby 1.8.xに標準添付されているTest::Unitと同じTest::Unitを使 用したい場合は以下のようにします。

% sudo gem install test-unit --version '= 1.2.3'

テストファイル内(変更前):

1
2
require 'test/unit'
...

テストファイル内(変更後):

1
2
3
4
require 'rubygems'
gem 'test-unit', '= 1.2.3'
require 'test/unit'
...

余談ですが、Ruby 1.9.1でTest::Unitが標準添付から外れ、 miniunitが標準添付になったのはTest::Unitのソースがメンテナン スしづらくなっていたのが主な理由です。

Test::Unit 2.x

Ruby 1.8.xに標準添付されているTest::Unitは長い間メンテナンス はされていましたが、特に機能拡張などは行われていませんでした。 しかし、その間にもテスト環境を便利にするライブラリが公開され てきました。例えば、RSpecのような BDD用のフレームワークや、 expectations のような軽量の単体テストフレームワーク、 Shoulda/ test/spec/ Mochaのように Test::Unitを拡張するライブラリなどです。Test::Unitは少し時代 遅れになってしまったのです。

最近のテスト用のフレームワークは DSL化の方向に向かっているようにも見えます。 これはRSpecの影響が大きいのでしょう。expectationsやShouldaもテスト用の DSLを提供します。

しかし、Test::UnitはDSLを提供しません。テストを「英語らしく」 ではなく「Rubyプログラムらしく」書きます。好みにもよりますが、 これはTest::Unitのメリットの1つと言えます。

Test::Unitに他のフレームワークやライブラリの機能を、 Test::Unitの「Rubyプログラムらしく」テストを書ける特性を活か したまま追加すれば、Test::Unitはもっと便利で使いやすいテスト フレームワークになるでしょう。Test::Unit 2.x系列はRuby 1.8.x に標準添付されていた頃とは違い、そのような方針の元で活発に開 発されていく系列になります。

例えば、以下のような機能が他のフレームワークやライブラリから 移植されています。

  • 差分表示
  • ネストしたテスト定義
  • 色付け
  • C-cでテスト中断時にもテスト結果を表示
  • 複数のsetup/teardown
  • ...

ここでは「差分表示」と「ネストしたテスト定義」だけ紹介します。 *1

差分表示

RSpecでは比較結果が異なった場合に差分を表示して違いをわかり やすく表示してくれます。

diff_spec.rb:

1
2
3
4
5
6
7
8
require 'rubygems'
require 'spec'

describe String do
  it do
    ["I", "am", "a", "boy"].join("\n").should == ["I", "was", "a", "boy"].join("\n")
  end
end

実行結果(差分表示部分のみ):

% ruby diff_spec.rb -D
...
Diff:
@@ -1,5 +1,5 @@
 I
-was
+am
 a
 boy
...

同様の機能がTest::Unit 2.0.1にもあります。

test_diff.rb:

1
2
3
4
5
6
7
8
9
10
require 'rubygems'
gem 'test-unit'
require 'test/unit'

class TestDiff < Test::Unit::TestCase
  def test_diff
    assert_equal(["I", "am", "a", "boy"].join("\n"),
                 ["I", "was", "a", "boy"].join("\n"))
  end
end

実行結果(差分表示部分のみ):

% ruby test_diff.rb
...
diff:
  I
- am
+ was
  a
  boy
...

この例では、ほとんど同じ差分表示ですが、Test::Unit 2.0.1の 差分表示がRSpecの差分表示よりも便利なこともあります。

今度は"\n"ではなく" "でjoinして1行の文字列として比較します。

test_diff.rb:

1
2
3
4
5
6
7
8
9
10
require 'rubygems'
gem 'test-unit'
require 'test/unit'

class TestDiff < Test::Unit::TestCase
  def test_diff
    assert_equal(["I", "am", "a", "boy"].join(" "),
                 ["I", "was", "a", "boy"].join(" "))
  end
end

実行結果(差分表示部分のみ):

% ruby test_diff.rb
...
diff:
- I am a boy
?    ^
+ I was a boy
?   + ^
...

Test::Unit 2.0.1では必要なら同じ行のうち、どの列が異なってい るのかも表示します。RSpecでは行単位の差分までで列単位までの 差分は表示しません。

余談ですが、この差分表示形式はPythondifflib ライブラリで使われている形式です。

ネストしたテスト定義

Shouldaではcontextをネストさせることにより、便利にテストを書 くことができます。以下はShouldaのページからの引用です。 *2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class UserTest < Test::Unit::TestCase
  context "A User instance" do
    setup do
      @user = User.find(:first)
    end

    should "return its full name" do
      assert_equal 'John Doe', @user.full_name
    end

    context "with a profile" do
      setup do
        @user.profile = Profile.find(:first)
      end

      should "return true when sent #has_profile?" do
        assert @user.has_profile?
      end
    end
  end
end

ネストされた"with a profile"のcontext内では上位の"A User instance"のcontext内のsetupが実行された後に実行されます。つ まり、以下のような実行順序になります。

  • setup: "A User instance" context
    • should: "return its full name"
  • setup: "A User instance" context
    • setup: "with a profile" context
      • should: "return true when sent #has_profile?"

実行されるフィクスチャ(setup)がネストで自然に表現されていま す。

Test::Unit 2.0.1では以下のように書きます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class UserTest < Test::Unit::TestCase
  def setup
    @user = User.find(:first)
  end

  def test_full_name
    assert_equal('John Doe', @user.full_name)
  end

  class ProfileTest < UserTest
    def setup
      super
      @user.profile = Profile.find(:first)
    end

    def test_profile
      assert_true(@user.has_profile?)
    end
  end
end

これは以下のように実行されます。

  • UserTest#setup
    • UserTest#test_full_name
  • UserTest#setup
    • UserTest::ProfileTest#setup
      • UserTest::ProfileTest#test_profile

実行されるフィクスチャ(setup)がネストとクラス階層で自然に表現 されています。

まとめ

Test::UnitはRuby 1.9.1からは標準添付ではなくなりましたが、 Test::Unit 2.xとして活発に開発が続けられています。既存の他の フレームワークやライブラリのよいところは積極的に導入している ため、Ruby 1.8.xに標準添付されているTest::Unitよりもはるかに 使いやすくなっています。

今回は紹介しませんでしたが、他のフレームワークやライブラリに はないTest::Unit 2.x独自の便利な機能もあります。Test::Unitの 「Rubyプログラムらしい」テストの書き方が好きな場合はこれから もTest::Unitを使ってみてはいかがでしょうか。

*1 「C-cでテスト中断時にもテスト結果を表示」は開発が進んでい き、たくさんのテストがある場合には地味ですがとても便利な機能 なのです。RSpecにも実装されています。

*2 ただし、Test::UnitはTest::Unit::TestCaseに修正してある。

タグ: Ruby | テスト
2008-11-10

tDiaryのSubversionバックエンド

みなさんはtDiaryの日記データをどのようにバックアップしているのでしょうか。cronでアーカイブしていたり、dbi_ioでデータベースに保存して、データベースの内容をアーカイブしていたりしているのでしょうか。

開発者の人なら日記のデータもソースコードと同じようにバックアップしたいですよね。つまり、バージョン管理をして、レポジトリの内容をアーカイブするバックアップです。そんな人はこのようなSubversionIOはいかがでしょうか。保存する毎にSubversionリポジトリに日記データをコミットします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
require 'tdiary/defaultio'

module TDiary
        class SubversionIO < DefaultIO
                def transaction( date, &block )
                        dirty = TDiaryBase::DIRTY_NONE
                        result = super( date ) do |diaries|
                                dirty = block.call( diaries )
                                diaries = diaries.reject {|_, diary| /\A\s*\Z/ =~ diary.to_src}
                                dirty
                        end
                        unless (dirty & TDiaryBase::DIRTY_DIARY).zero?
                                run( "svn", "add", File.dirname( @dfile ) )
                                run( "svn", "add", @dfile )
                                Dir.chdir( @data_path ) do
                                        run( "svn", "ci", "-m", "update #{date.strftime('%Y-%m-%d')}" )
                                end
                        end
                        result
                end

                private
                def run( *command )
                        command = command.collect {|arg| escape_arg( arg )}.join(' ')
                        result = `#{command} 2>&1`
                        unless $?.success?
                                raise "Failed to run #{command}: #{result}"
                        end
                        result
                end

                def escape_arg( arg )
                        "'#{arg.gsub( /'/, '\\\'' )}'"
                end
        end
end

# Local Variables:
# ruby-indent-level: 3
# tab-width: 3
# indent-tabs-mode: t
# End:

使い方は以下の2ステップです。

  1. tdiary.confで@io_classに指定する
  2. 日記のデータディレクトリをSubversionのワーキングコピーにする

tdiary.confの設定

まず、上記のソースコードをsubversionio.rbとしてどこかに保存してください。ここでは/home/tdiary/lib/以下に保存したとします。

次にtdiary.confに以下の内容を追記します。

1
2
3
subversion_io_dir = "/home/tdiary/lib" # <- 保存した場所にあわせて変更
require "#{subversion_io_dir}/subversionio"
@io_class = TDiary::SubversionIO

データディレクトリの設定

データディレクトリ(tdiary.conf内の@data_pathで指定したディレクトリ)は/home/tdiary/data/として進めます。また、作業しているユーザはtDiaryのCGIを動かすユーザとします。(ここではtdiaryユーザ)

まず、レポジトリに日記データ用のパスを作ります。既存のSubversionリポジトリを利用する場合は、例えばこのようになります。

[tdiary]% svn mkdir -m 'create tDiary data path' https://.../repos/tdiary-data

ローカルに新しくSubversionのリポジトリを作成して、そこに日記データをコミットするようにする場合はこのようになります。新しく作成するリポジトリは/home/tdiary/repos/に作ることにします。

[tdiary]% svnadmin create /home/tdiary/repos

Subversionリポジトリから日記データ保存用のパスを、tDiaryのデータディレクトリにチェックアウトします。今すでにあるtDiaryのデータディレクトリはどこかによけておきます。

[tdiary]% mv /home/tdiary/data /home/tdiary/data.bak
[tdiary]% svn co file:///home/tdiary/repos /home/tdiary/data

既存のデータをワーキングコピーに移動し、日記データだけをレポジトリにコミットします。

[tdiary]% cd /home/tdiary/data
[tdiary]% cp -rp ../data.bak/* ./
[tdiary]% svn add 200*
[tdiary]% svn add 199* # <- もし2000年より前のデータがあるなら
[tdiary]% svn ci -m 'import'

完了

以上で設定は完了です。日記を保存するとリポジトリにコミットされます。

制限

日記本文しか対応していません。ツッコミや画像には対応していません。

svnコマンドをインストールしている必要があります。

tDiary本体のコーディングスタイルに合わせているためタブインデントになっています。

ライセンス

AGPL3あるいは3以降の新しいバージョンのAGPL

タグ: Ruby
2008-11-13

UxU(UnitTest.XUL)を利用したFirefoxアドオンのデバッグの例

Firefoxアドオン開発者向け自動テストツールのUxUは、新たに発見したバグの修正にも活用することができます。本日リリースされたXUL/Migemo バージョン0.11.7で行われた修正の場合を例に、実際のデバッグ作業の流れを解説します。

状況

XUL/Migemoは、Firefoxで表示しているページ内のテキストを検索する機能を提供するアドオンですが、検索を開始する際に、「現在のスクロール位置から検索を開始する」という処理を含んでいます。0.11.6以前では、この機能を使用している時に、ページ先頭から検索が始まるべき場面で、先頭以外の場所から検索が始まってしまうことがあるという問題が起こっていました。

再現条件の特定

いくつか条件を変えて調査した結果、スクロールが発生しているページでは期待通りの結果になっているのに対して、スクロールが全く発生していないページ(ページ全体がウィンドウの現在の大きさの中に収まっているページ)では期待と異なる結果になっていることが判明しました。

原因箇所の特定

前述の処理の肝となっているのは、pXMigemoFindクラスfindFirstVisibleNode()というメソッドです。このメソッドは、渡されたフレーム(DOMWindow)において現在のスクロール位置で見えている最初の要素を検索して返す物です。このメソッドの戻り値を確認した所、前述の条件下では戻り値が期待と異なっている事が判明しました。

このことから、今回主な修正対象になるのはこのfindFirstVisibleNode()というメソッドであるということになります。

テストケースの作成

上記メソッドの実装を見直す前に、UxU用のテストケースを作成します。これにより、これから行う修正で目指すべきゴールが明確になります。つまり、このテストが成功する状況まで持って行くことが、今回の修正のゴールとなります。

テストケースはJavaScriptのコードだけで完結する場合もありますが、今回のような場合は実際のWebページを使ってテストを行う必要があります。そこで、問題が発生する条件と発生しない条件の両方の事例としてHTMLドキュメントを用意します。

これらのドキュメントを使い、shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する、という条件の下でテストを行うテストケースをこれから作成することになります。

ところで、現在の実装で問題が起こっている場合だけでなく、すでに正常に動いている場合の事例も同時に作成していることに気がついたでしょうか? 両方の場合を常にテストすることで、「ある問題を修正したら、今度は、今までは正常に動いていた物が動かなくなった」という状況、いわゆる後退バグを未然に防ぐことができます。 *1

それではテストケースを作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
utils.include('pXMigemoClasses.inc.js');

var findModule;

function setUp()
{
  yield utils.setUpTestWindow();
  findModule = new pXMigemoFind();
  findModule.target = utils.getTestWindow().gBrowser;
}

function tearDown()
{
  findModule.destroy();
  findModule = null;
  utils.tearDownTestWindow();
}

function testFindFirstVisibleNode()
{
  // ここに実際のテスト内容を記述する
}

pXMigemoFindクラスの単体テストはまだ作成されていなかったので、今回はsetUpとtearDownから作成します。すでに作成済みのテストケースがあり、それにテスト項目を追加する場合、この作業は不要となります。

pXMigemoFindクラスはtabbrowser要素を用いて初期化する必要があるため、setUpでテスト用のFirefoxウィンドウを開き、そのウィンドウのtabbrowser要素で初期化します。また、tearDownではsetUpで開いたテスト用のFirefoxウィンドウを閉じて、pXMigemoFindクラスのインスタンスを破棄します。UxUは関数名を見て自動的にその種類を判別するため、これだけで、これらの関数はテストの初期化処理と終了処理として認識されるようになります。

なお、インクルードしているpXMigemoClasses.inc.jsというファイルは、pXMigemoFindクラスやそのクラスが依存しているすべての関連クラスの定義を読み込む物です。

次に、テストの内容を作成していきます。

1
2
3
4
5
6
7
8
function testFindFirstVisibleNode()
{
  var win = utils.getTestWindow();
  win.resizeTo(500, 500);
  assert.compare(200, '<', utils.contentWindow.innerHeight);

  // ここに実際のテスト内容を記述する
}

関数名を「test」で始めると、その関数はテストの内容として自動的に認識されます。

最初に、ウィンドウの大きさを調整して、「shortPage.htmlではスクロールが発生せずlongPage.htmlではスクロールが発生する」という条件を整えておきます。ここでは、テスト自体が期待通りの条件下で実行されていることを確認するために、assert.compare()でテスト用フレームの大きさを調べています。

1
2
3
4
5
6
7
8
9
  yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true });

  var frame = utils.contentWindow;
  var node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(utils.contentDocument.documentElement, node);

  item = frame.document.getElementById('p3');
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

テスト用のドキュメントを新しいタブで開き、findFirstVisibleNode()メソッドの返り値が期待通りかどうかを検証します。1つ目の検証は前方検索、2つ目は後方検索です。

同様にして、スクロールが発生する場合のテストも作成します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  yield utils.addTab(baseURL+'../res/longPage.html', { selected : true });

  frame = utils.contentWindow;
  node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(utils.contentDocument.documentElement, node);

  item = frame.document.getElementById('p10');
  frame.scrollTo(0, item.offsetTop);
  node = findModule.findFirstVisibleNode(findModule.FIND_DEFAULT, frame);
  assert.equals(item, node);

  frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight);
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

  item = frame.document.getElementById('p21');
  frame.scrollTo(0, item.offsetTop - frame.innerHeight + item.offsetHeight);
  node = findModule.findFirstVisibleNode(findModule.FIND_BACK, frame);
  assert.equals(item, node);

スクロールされていない時、ページ途中までスクロールされている時、ページの最後までスクロールされている時の各ケースで前方検索と後方検索を行い、結果を検証します。

ここで、かなりの部分のコードが重複していることに気がついたでしょうか。このような場合、それぞれの検証の前で重複しているコードと検証とをひとまとめにして実行する関数(カスタムアサーション)を定義しておくと、テスト項目の追加が簡単になります。以下は、カスタムアサーションを使ってここまでのテスト内容を書き直した物です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function testFindFirstVisibleNode()
{
  var win = utils.getTestWindow();
  win.resizeTo(500, 500);
  assert.compare(200, '<', utils.contentWindow.innerHeight);

  function assertScrollAndFind(aIdOrNode, aFindFlag)
  {
    var frame = utils.contentWindow;
    var item = typeof aIdOrNode == 'string' ? frame.document.getElementById(aIdOrNode) : aIdOrNode ;
    frame.scrollTo(
      0,
      (aFindFlag & findModule.FIND_BACK ?
        item.offsetTop - frame.innerHeight + item.offsetHeight :
        item.offsetTop
      )
    );
    var node = findModule.findFirstVisibleNode(aFindFlag, frame);
    assert.equals(item, node);
  }

  yield utils.addTab(baseURL+'../res/shortPage.html', { selected : true });
  assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT);
  assertScrollAndFind('p3', findModule.FIND_BACK);

  yield utils.addTab(baseURL+'../res/longPage.html', { selected : true });
  assertScrollAndFind(utils.contentDocument.documentElement, findModule.FIND_DEFAULT);
  assertScrollAndFind('p10', findModule.FIND_DEFAULT);
  assertScrollAndFind('p10', findModule.FIND_BACK);
  assertScrollAndFind('p21', findModule.FIND_BACK);
}

テストの実行

テストケースが完成したら、テストを実行してみましょう。実装の修正前なので、当然、このテストは「失敗」という結果が出ます。ですが、この段階では問題ありません。これから、このテストの結果が「成功」になることを目指して実装を修正していきます。

実装の修正

実装の修正内容については省略します。良いアイディアを思いついたら、それを実装に反映して、再度テストを実行してみましょう。テストに成功しないようであれば、まだ修正が必要です。

何度テストを実行しても結果が「成功」になるようになれば、実装の修正はひとまず完了です。修正内容をリポジトリにコミットするなり、修正済みの新しいバージョンとしてリリースするなりしましょう。

新たな問題が発覚した時や、仕様が変わった時は

以上で、今回発見された問題の修正は完了しました。

しかし、上記のテストだけではテストしきれないような、より複雑な条件でだけ発生するバグが新たに見つかるかもしれません。そのような場合は、テストを新たに追加して、それらがすべて「成功」するようになるまで修正してやりましょう。その時はもちろん、他のテストも同時に実行することを忘れないようにしましょう。

また、開発を進める中で、他の部分に加えた変更の影響を受けて上記のテストが失敗するようになることがあるかもしれません。そのような場合、再びテストが通るようになるように実装を修正する必要があります。

実装の仕様を変更した時にも、ここで作成したテストケースは「成功」しなくなる場合があります。このような場合は「ゴール」自体が変わったということになりますので、実装ではなくテストケースの側を修正しなくてはなりません。

まとめ

自動テストを使った開発では、メンテナンスする必要があるコードが「実装」と「テストケース」の2つになるため、一見すると、手間だけが倍増するように思えるかもしれません。

しかし、一連のテスト手順を自動化しておくことで、人の手によるテストでは見落としてしまいかねない思わぬ後退バグの発生に迅速に気づけるようになります。後退バグの発生に日々頭を悩ませている人は、是非、自動テストを開発に取り入れてみてください。

*1 もちろん、後退バグの発生自体は未然には防ぎきれません。しかし、後退バグの発生にすぐ気がつくことができれば、コミットやリリースの前にその後退バグを修正できるため、他の共同開発者やユーザには後退バグの影響を与えずに済むようになります。

タグ: Mozilla | テスト | UxU
2008-11-17

«前月 最新記事 翌月»
タグ:
年・日ごとに見る
2008|05|06|07|08|09|10|11|12|
2009|01|02|03|04|05|06|07|08|09|10|11|12|
2010|01|02|03|04|05|06|07|08|09|10|11|12|
2011|01|02|03|04|05|06|07|08|09|10|11|12|
2012|01|02|03|04|05|06|07|08|09|10|11|12|
2013|01|02|03|04|05|06|07|08|09|10|11|12|
2014|01|02|03|04|05|06|07|08|09|10|11|12|
2015|01|02|03|04|05|06|07|08|09|10|11|12|
2016|01|02|03|04|05|06|07|08|09|10|11|12|
2017|01|02|03|04|05|06|07|08|09|10|11|12|
2018|01|02|03|04|05|06|07|08|09|10|11|12|
2019|01|02|03|04|05|06|07|08|09|10|11|12|
2020|01|02|03|04|05|06|07|08|09|10|11|12|