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

ククログ

タグ:

Cutterで画像を使ったテストを書くには

はじめに

今回は弊社が中心となって開発しているCutterという、書きやすさ・デバッグのしやすさを重視したC言語・C++言語用のテスティングフレームワークを使って、画像を使ったテストを簡単に書く方法を紹介します。

画像を使ったテストを書くとき

たとえば、画像を生成するアプリケーションを開発していて、機能追加によってその機能が壊れていないことを保証するにはどうすればよいでしょうか。 これは、以前生成した画像と比較するテストで検証できれば良いですね。

Cutterによるテストでは、テスティングフレームワークの機能のひとつとして画像差分をサポートしています。 そのため、画像だからといって特別なことをせず、フレームワークの枠内で簡単にテストを書くことができます。

では、実際にどんな風に簡単にテストを書くことができるのかみてみましょう。

Cutterをインストール

まずは、Cutterをインストールしましょう。 各種ディストリビューション向けのインストールのドキュメントがあります。そちらを参考にしてください。

Ubuntuの場合には、以下の手順で必要なパッケージをインストールすることができます。

% sudo apt-get -y install software-properties-common
% sudo add-apt-repository -y universe
% sudo add-apt-repository -y ppa:cutter-testing-framework/ppa
% sudo apt-get update
% sudo apt-get -y install cutter-testing-framework

普通のCutterのテストを書く

Cutterでテストを書くには、test_XXXという名前の関数を定義します。

CUT_EXPORT void test_equal(void)
{
}

このようにすると、Cutterがテストを実行するときに、定義されたテストをtest_XXX自動検出して実行します。

画像を比較するassertionを追加する

画像の場合、Cutterのテストに画像を比較するためのassertionを追加するだけです。 gdkcut_pixbuf_assert_equalというのがそのためのassertionです。*1

CUT_EXPORT void test_equal(void)
{
  GdkPixbuf *expected, *actual;
  expected = load_fixture_image("base.png");
  actual = load_fixture_image("copy.png");

  gdkcut_pixbuf_assert_equal(expected, actual, 0);
}

ここでのポイントはGdkPixbuf*で比較していることです。gdk-pixbufは幅広い画像形式をサポートしているので、読み込みさえできてしまえば、あとはとても簡単ですね。*2 期待する結果をexpectedに、実際の処理で得られた結果をactualとして比較することができます。

画像を含むテストを実行する

テストを定義したので実行してみましょう。以下はカレントディレクトリ以下にある test_equalという名前のテストを実行しています。

% cutter . -n '/test_equal/'
.

Finished in 0.020999 seconds (total: 0.001533 seconds)

1 test(s), 3 assertion(s), 0 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s)
100% passed

無事テストが通りました。 *3

画像差分機能を使って確認する

では、画像に差分がでたときにどう表示されるのでしょうか。 次のような、失敗するテストを書いてみます。

CUT_EXPORT void test_diff(void)
{
  GdkPixbuf *expected, *actual;
  expected = load_fixture_image("OK.png");
  actual = load_fixture_image("NG.png");

  gdkcut_pixbuf_assert_equal(expected, actual, 0);
}

実行してみましょう。

% cutter . -n '/test_diff/'
F
===============================================================================
Failure: test_diff
<expected == actual> (0)
  expected: <#<GdkPixbuf:0x155f680 colorspace=<#<GdkColorspace: rgb(GDK_COLORSPACE_RGB:0)>>, n-channels=<3>, has-alpha=<FALSE>, bits-per-sample=<8>, width=<100>, height=<100>, rowstride=<300>, pixels=<((gpointer) 0x156fef0)>>>
    actual: <#<GdkPixbuf:0x155f6d0 colorspace=<#<GdkColorspace: rgb(GDK_COLORSPACE_RGB:0)>>, n-channels=<3>, has-alpha=<FALSE>, bits-per-sample=<8>, width=<100>, height=<100>, rowstride=<300>, pixels=<((gpointer) 0x1578210)>>>
 threshold: <0>
diff image: <test-sample.c-45.png>
test-sample.c:45: test_diff(): gdkcut_pixbuf_assert_equal(expected, actual, 0)
===============================================================================


Finished in 0.184523 seconds (total: 0.014664 seconds)

1 test(s), 2 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s)
0% passed

確かに失敗しましたが、これだけだとわかりにくいですね。ここでのポイントは diff image: <test-sample.c-45.png> という行です。 これが、テストに失敗したときに生成される差分画像です。

Cutterによる差分画像の例

差分画像を表示すると、4分割されているのがわかります。上半分がテストに使用した画像です。左が期待する結果、右が実際の結果の画像です。 下半分が違いを示す差分です。重ね合わせた違いがわかりますね。

gdkcut_pixbuf_assert_equalの第三引数は何なのか

ここまでで、Cutterで画像を使ったテストを書けるようになりました。でもgdkcut_pixbuf_assert_equalには第三引数があります。 これは何なのでしょうか。

リファレンスマニュアルを見てみましょう。gdk-pixbufサポート付きの検証には、「ピクセルの違いを検出するために使われるしきい値」とあります。

ピクセル値の違いが指定した範囲に収まれば、それは同じ画像であるとみなすということです。

では、thresholdをうまく使ってだいだい同じ画像とみなせるならテストを通るようにする、というのをやってみましょう。

例として、元画像を gdk-pixbuf-scale-simpleを使って縮小した画像同士を比較してみることにします。

gdk-pixbuf-scale-simpleは画像を縮小する方法をいくつか選択することができます。ここでは、GDK_INTERP_BILINEARGDK_INTERP_HYPERでそれぞれ縮小したサンプルでテストします。

まずは、thresholdを0にしてテストしてみましょう。

% cutter . -n '/test_bilinear_and_hyper/'
F
===============================================================================
Failure: test_bilinear_and_hyper
<expected == actual> (0)
  expected: <#<GdkPixbuf:0xa40280 colorspace=<#<GdkColorspace: rgb(GDK_COLORSPACE_RGB:0)>>, n-channels=<3>, has-alpha=<FALSE>, bits-per-sample=<8>, width=<80>, height=<80>, rowstride=<240>, pixels=<((gpointer) 0xa50eb0)>>>
    actual: <#<GdkPixbuf:0xa402d0 colorspace=<#<GdkColorspace: rgb(GDK_COLORSPACE_RGB:0)>>, n-channels=<3>, has-alpha=<FALSE>, bits-per-sample=<8>, width=<80>, height=<80>, rowstride=<240>, pixels=<((gpointer) 0xa56a70)>>>
 threshold: <0>
diff image: <test-sample.c-81.png>
test-sample.c:81: test_bilinear_and_hyper(): gdkcut_pixbuf_assert_equal(expected, actual, 0)
===============================================================================
.

Finished in 0.022689 seconds (total: 0.007011 seconds)

2 test(s), 5 assertion(s), 1 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s)
50% passed

縮小方法が違うので、テストが失敗しました。差分画像を見てみましょう。

BILINEARとHYPERの差分画像

これくらいなら、まぁ許容範囲だなぁという気もするので、thresholdを調整します。

CUT_EXPORT void test_bilinear_and_hyper_threshold(void)
{
  GdkPixbuf *expected, *actual;
  expected = load_fixture_image("NG-bilinear.png");
  actual = load_fixture_image("NG-hyper.png");

  gdkcut_pixbuf_assert_equal(expected, actual, 30);
}

では、thresholdを30にしたテストを実行してみます。

% cutter . -n '/test_bilinear_and_hyper_threshold/'
.

Finished in 0.016168 seconds (total: 0.002432 seconds)

1 test(s), 3 assertion(s), 0 failure(s), 0 error(s), 0 pending(s), 0 omission(s), 0 notification(s)
100% passed

テストが通りましたね。これで、NG-bilinear.pngNG-hyper.png程度の違いは許容するテストを書くことができました。

まとめ

今回はCutterというテスティングフレームワークの画像差分機能を使って簡単にテストを書く方法を紹介しました。 Cutterにはほかにもテスト環境を便利にする機能があります。まだCutterを使ったことがない人はチュートリアルからはじめるとよいでしょう。詳しく知りたい人はリファレンスマニュアルを参照してください。

GitHubに今回使用した サンプルのリポジトリ を置いてあるので、git cloneして手元でも動かして試すことができます。

git clone https://github.com/kenhys/cutter-with-image-test.git

動作を試すには、次のコマンドを実行してください。

% ./autogen.sh
% ./configure
% make
% cutter .

*1 load_fixture_imageという画像を読みこんでGdkPixbuf*として返す関数を定義してあることにします。本質的でないので詳細は割愛します。

*2  gdk-pixbuf-query-loadersを実行するとサポートされている画像形式を確認できます。

*3 期待する結果と、実際の結果にファイル名だけ違う画像を使ったのであたりまえなのですが。

タグ: Cutter | テスト
2014-09-30

test-unitならRSpec 3のComposable Matchers相当のことをどう書くか

RSpec 3の新機能であるComposable Matchersの使い方の例をtest-unitならどう書くか紹介します。リンク先のコードを示し、それのtest-unitバージョンを示す、という流れを繰り返します。

テスト対象

テスト対象は次のコードです。

1
2
3
4
5
6
7
8
9
10
11
class BackgroundWorker
  attr_reader :queue

  def initialize
    @queue = []
  end

  def enqueue(job_data)
    queue << job_data.merge(:enqueued_at => Time.now)
  end
end

キューの中身をチェック

RSpec 3ではComposable Matchersを使ってこう書くそうです。(リンク先から引用。以下同様。)

1
2
3
4
5
6
7
8
9
10
11
12
describe BackgroundWorker do
  it 'puts enqueued jobs onto the queue in order' do
    worker = BackgroundWorker.new
    worker.enqueue(:klass => "Class1", :id => 37)
    worker.enqueue(:klass => "Class2", :id => 42)

    expect(worker.queue).to match [
      a_hash_including(:klass => "Class1", :id => 37),
      a_hash_including(:klass => "Class2", :id => 42)
    ]
  end
end

test-unitではこう書きます。RSpecは「フレームワーク側が」必要な値だけ比較するという方針ですが、test-unitは「テストを書く側が」必要な値だけ取り出して比較するという方針です。

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
class BackgroundWorkerTest < Test::Unit::TestCase
  class EnqueueTest < self
    def test_order
      worker = BackgroundWorker.new
      worker.enqueue(:klass => "Class1", :id => 37)
      worker.enqueue(:klass => "Class2", :id => 42)

      assert_equal([
                     {:klass => "Class1", :id => 37},
                     {:klass => "Class2", :id => 42}
                   ],
                   normalize_queue(worker.queue))
    end

    private
    def normalize_queue(queue)
      queue.collect do |job_data|
        {
          :klass => job_data[:klass],
          :id    => job_data[:id],
        }
      end
    end
  end
end

リンク先ではテスト結果の失敗時にどのように報告するかについても触れています。enqueueの実装がコメントアウトされていたときを例にしています。

1
2
3
4
5
6
class BackgroundWorker
  # ...
  def enqueue(job_data)
    # queue << job_data.merge(:enqueued_at => Time.now)
  end
end

RSpec 3の場合は次のようになって読みやすい、ということです。英語で読みくだせるのがポイントですね。

1) BackgroundWorker puts enqueued jobs onto the queue in order
   Failure/Error: expect(worker.queue).to match [
     expected [] to match [(a hash including {:klass => "Class1", :id => 37}), (a hash including {:klass => "Class2", :id => 42})]
     Diff:
     @@ -1,3 +1,2 @@
     -[(a hash including {:klass => "Class1", :id => 37}),
     - (a hash including {:klass => "Class2", :id => 42})]
     +[]

   # ./spec/background_worker_spec.rb:19:in `block (2 levels) in <top (required)>'

test-unitの場合は次のようになります。RSpecとは対照的に、英語を極力排除して実際のプログラムとデータを見せる方に注力しています。

Failure:
test_order(BackgroundWorkerTest::EnqueueTest)
test-worker.rb:22:in `test_order'
     19:       worker.enqueue(:klass => "Class1", :id => 37)
     20:       worker.enqueue(:klass => "Class2", :id => 42)
     21: 
  => 22:       assert_equal([
     23:                      {:klass => "Class1", :id => 37},
     24:                      {:klass => "Class2", :id => 42}
     25:                    ],
<[{:id=>37, :klass=>"Class1"}, {:id=>42, :klass=>"Class2"}]> expected but was
<[]>

diff:
? [{:id=>37, :klass=>"Class1"}, {:id=>42, :klass=>"Class2"}]

Compound Matcher Expressions

Compound Matcher Expressionsという機能は次のように書ける機能ということです。これまではstart_withのチェックとend_withのチェックを別に書かなれければいけなかったのに、一緒に書けるようになったということです。

1
expect(alphabet).to start_with("a").and end_with("z")

test-unitではこう書きます。期待するパターンなら特定の文字列に置換します。一回で比較するという方針は同じです。

1
2
3
alphabet = "abcxyz"
a_z = "a...z"
assert_equal(a_z, alphabet.gsub(/\Aa.*z\z/, a_z))

次のように期待したパターンでない場合は元の文字列が変わらないので失敗します。

1
2
3
alphabet = "abcxyZ"
a_z = "a...z"
assert_equal(a_z, alphabet.gsub(/\Aa.*z\z/, a_z))

失敗時のメッセージにはちゃんと元の文字列がでるので、実際の値はなんだったのか、という情報が失われることはありません。

Failure:
test_a_z(AlphabetTest)
test-alphabet.rb:7:in `test_a_z'
     4:   def test_a_z
     5:     alphabet = "abcxyZ"
     6:     a_z = "a...z"
  => 7:     assert_equal(a_z, alphabet.gsub(/\Aa.*z\z/, a_z))
     8:   end
     9: end
<"a...z"> expected but was
<"abcxyZ">

diff:
? a...z 
?  bcxyZ

まとめ

RSpec 3の例はもっとたくさんありますが、2個だけ紹介しました。

値の比較の仕方に方針の違いがでていました。

  • RSpecは「フレームワーク側が」必要な値だけ比較するという方針
  • test-unitは「テストを書く側が」必要な値だけ取り出して比較するという方針

RSpec 3はより英語らしく読み書きできるようになりそうですね。

test-unitはあいかわらずRubyらしく読み書きできるテスティングフレームワークですね。

つづき: 2015-01-07
タグ: Ruby | テスト
2014-03-19

インターンシップで学んだこと4:何をテストするか

前回は3日目に3つ学んだことの中の2つめ「テストを整理する方法」についてまとめました。今回は3日目に学んだことの最後、3つめである「何をテストするか」についてまとめます。

このまとめはインターンシップ時に書いたメモを読み返しながら書いています。数日前にこのURLのパスを変えました。当初は「/2013/...」と開催年を使っていたのですが、「/2/...」と通算何回目のインターンシップかを使うことにしました。理由は2013年に2回インターンシップを実施することになったため、開催年がインターンシップを識別するユニークな情報ではなくなったからです。

どこまでテストするか

テストを書いた方がよいことはわかった、テストを整理する方法もわかった、どんどんテストを書いていける。そんな状態でテストを書き始めると、たくさんテストを書いてしまいます。テストがたくさんあることはよいことのように聞こえますが、必ずしもよいこととは限りません。テストがたくさんあると以下のようなことが起きます。

  • テストが遅くなる。
  • テストのメンテナンスが大変になる。

どちらも「テストはいらない」と感じる原因になります。

テストが遅くなるとテストを実行することが面倒になります。面倒になるとテストを実行しなくなります。テストを実行しないと「テストがあっても意味ないね」と思うようになり、「テストはいらない」と感じるようになります。

APIの変更などでテストを変更する必要があったとき、テストが多いとテストの修正が大変になります。テストのメンテナンスが大変になるということです。テストのメンテナンスが大変になるとテストの作成・変更が開発の足をひっぱるようになり、機能の追加や修正作業に影響がでます。そうすると、自分達は「機能の追加や修正をしたいはずなのにテストばかりに時間を使っている、これでは本末転倒じゃないか」と思うようになり、「テストはいらない」と感じるようになります。

補足しておくと、どちらもテストがたくさんあることだけが原因ではありません。原因の1つというだけです。例えば、データベースに接続しているために遅くなる場合もありますし、テストが整理されていないためにメンテナンスが大変になる場合もあります。データベースに接続して遅くなっているなら、スタブを使って速くすることができますし、テストを整理することでメンテナンスを簡単にすることもできます。ここでは、テストの多さに注目するというだけです。

せっかくテストの恩恵を得るためにたくさんテストを書いたのに、「テストいらないかも…」と感じるようになってはもったいありません。そうならないために、適切な量のテストだけ書きましょう。

適切な量のテスト

適切な量のテストとは「実質的に同じこと」を含まないテストです。「同じこと」ではなく「実質的に同じこと」です。

例えば、以下の2つのアサーションは「同じこと」を確認しています。

1
2
assert_equal(11, 2 + 9)
assert_equal(11, 2 + 9)

以下の2つのアサーションは「実質的に同じこと」を確認しています。

1
2
assert_equal(11, 2 + 9)
assert_equal(12, 3 + 9)

以下ようにすると「実質的に違うこと」を確認しています。

1
2
assert_equal(11, 2 + 9)
assert_equal(-7, 2 + -9)

実質的に同じかどうかを判断するポイントは、入力値がどの分類に属しているかです。入力値が同じ分類なら「実質的に同じ」です。

2 + 9」を「正の整数 + 正の整数」と考えると、「2」も「3」もどちらも同じ「正の整数」という分類に入るので、「2 + 9」も「3 + 9」も実質的に同じです。

一方、「-9」は「負の整数」という分類になるので、「2 + 9」と「2 + -9」は実質的に違います。

分類をどう考えるかは「何を基準にするか」で変わってきます。たとえば、偶数か奇数かという基準にすれば「2」と「3」は実質的に違います。

どうやって基準を見つけるか

どうやって適切な基準を見つければよいか、そのやり方はまだうまくまとめられていません。テストを書いているときは、基準を考えて、分類し、実質的に違うことだけ確認しようとしているので、何かしらやり方をもっているような気がしますが、それを他の人に説明するところまではいっていません。「境界値を見つける」など説明できることはありますが、それだけではない気がしています。もっと何か別のやり方を持っている気がします。それらについてもうまく説明できるようになることは今後の課題です。

ただ、具体的にこの場合はどうするか?ということには答えることができます。1つ紹介します。

以下のようなテストがありました。

1
2
3
4
5
6
7
8
9
10
def test_title
  epub_book_doc_all = EPUB::Parser.parse(fixture_path('empty_contributors_single_spine.epub'))
  @document_all = EPUBSearcher::EPUBDocument.new(epub_book_doc_all)

  epub_book_doc_11_12 = EPUB::Parser.parse(fixture_path('single_contributors_multi_spine.epub'))
  @document_11_12 = EPUBSearcher::EPUBDocument.new(epub_book_doc_11_12)

  assert_equal("groongaについて", @document_all.title)
  assert_equal("groongaについて", @document_11_12.title)
end

@document_all@document_11_12の違いはコントリビューターの数とspineの数です。タイトルは同じです。

このときは、テスト対象の「タイトル」を基準に分類します。タイトルに違いがないなら、コントリビューターの数が違ってもspineの数が違っても同じ分類と考えます。同じ分類なら実質的に同じです。

書いてみて気づきましたが、テストで何に注目しているかを考えることが基準を見つけるやり方のひとつのような気がしますね。まとめてよかったです。

まとめ

インターンシップで説明した何をテストするかについてまとめました。インターンシップのときはうまく説明できなかったのですが、こうしてまとめてみたら整理された気がします。よかったです。

2013-09-05

インターンシップで学んだこと3:テストを整理する方法

インターン募集を開始したのが半年前の2月で、6月に開催されたRubyKaigi 2013までは1件も応募がありませんでした。RubyKaigi 2013に参加したところ1件応募があり、6月後半から7月にかけて実施しました。ここ最近まとめているインターンシップで学んだことはこの時期に実施したインターンシップで学んだことです。

その後、さらに2件の応募がありました。クリアコードのインターンシップは学生に限定せず働いている人でも働いていない人でも対象としていますが、3件ともすべて学生の方です。学生の方からの応募は夏休みの時期の方が多いようです*1

2件の応募のうち、1件は「インターンシップ実施企業と学生が話をできる場を提供するイベント」がきっかけでした。もう1件はリーダブルコードの解説を読んでクリアコードを知ったことがきっかけだということでした。解説を書いてよかったです*2

なお、この2件の応募に対して9月にインターンシップを実施する予定です。9月のインターンシップに向けて、前回のインターンシップで学んだことを早くまとめて活かしたいところです。が、間に合わなそうです。17日あるうちのまだ3日目です。

さて、前回は3日目に3つ学んだことの中の1つ「1人で開発しているときにtypoとどうつきあっていくか」についてまとめました。今回は3日目に学んだことの2つめである「テストを整理する方法」についてまとめます。

冗長なテスト

3日目の時点でのテストは以下のように50行にも満たない小さなものでした。

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
43
44
45
# -*- coding: utf-8 -*-

require 'test-unit'

require 'epub/parser'
require 'epub-searcher/epub-document'

class TestEPUBDocument < Test::Unit::TestCase
  def setup
    # groonga_doc_all.epub ... spine を一つしか含まない EPUB ファイル
    # 本文は groonga ドキュメント 1 章 が全て入っている
    epub_book_1 = EPUB::Parser.parse(fixture_path('groonga_doc_all.epub'))
    @document_1 = EPUBSearcher::EPUBDocument.new(epub_book_1)

    # groonga_doc_11_12.epub ... spine を二つ含む EPUB ファイル
    # 本文は groonga ドキュメント 1.1 と 1.2 が入っている
    epub_book_2 = EPUB::Parser.parse(fixture_path('groonga_doc_11_12.epub'))
    @document_2 = EPUBSearcher::EPUBDocument.new(epub_book_2)
  end

  def test_extract_contributors
    assert_equal([], @document_1.extract_contributors)
    assert_equal(["groongaコミュニティ A", "groongaコミュニティ B", "groongaコミュニティ C"], @document_2.extract_contributors)
  end

  def test_extract_creators
    assert_equal(["groonga"], @document_1.extract_creators)
    assert_equal(["groongaプロジェクト"], @document_2.extract_creators)
  end

  def test_extract_title
    assert_equal("groongaについて", @document_1.extract_title)
    assert_equal("groongaについて", @document_2.extract_title)
  end

  def test_extract_xhtml_spine
    assert_equal(["OEBPS/item0001.xhtml"], @document_1.extract_xhtml_spine)
    assert_equal(["item0001.xhtml", "item0002.xhtml"], @document_2.extract_xhtml_spine)
  end

  private
  def fixture_path(basename)
    File.join(__dir__, 'fixtures', basename)
  end
end

しかし、いくつか冗長な点が見えてきています。例えば変数名です。

1
2
3
4
5
6
7
8
9
10
11
def setup
  # groonga_doc_all.epub ... spine を一つしか含まない EPUB ファイル
  # 本文は groonga ドキュメント 1 章 が全て入っている
  epub_book_1 = EPUB::Parser.parse(fixture_path('groonga_doc_all.epub'))
  @document_1 = EPUBSearcher::EPUBDocument.new(epub_book_1)

  # groonga_doc_11_12.epub ... spine を二つ含む EPUB ファイル
  # 本文は groonga ドキュメント 1.1 と 1.2 が入っている
  epub_book_2 = EPUB::Parser.parse(fixture_path('groonga_doc_11_12.epub'))
  @document_2 = EPUBSearcher::EPUBDocument.new(epub_book_2)
end

単にepub_book@documentとするのではなく、epub_book_1@document_2としています。epub_book_@document_が重複しています。

同様に、テストメソッド名にも冗長な点があります。すべてのテストメソッド名がextract_を含んでいます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def test_extract_contributors
  # ...
end

def test_extract_creators
  # ...
end

def test_extract_title
  # ...
end

def test_extract_xhtml_spine
  # ...
end

このように、重複した情報によりテストが冗長になってきています。

流れ的には「冗長なテストをすっきりさせるにはどうしたらよいか」という話にいくのですが、その前に、どうして冗長なテストをすっきりさせなければいけないかを考えてみましょう。

冗長なテストをすっきりさせなければいけない理由

実装のコードはすっきりさせないといけないと言われています。そのための技術としてリファクタリングという名前もついているぐらいです。では、テストのコードはどうでしょうか。テストのコードもすっきりさせないといけないのでしょうか。もし、テストのコードもすっきりさせないといけないなら、テストのコードにもリファクタリングという技術を使えるのでしょうか。

結論を先にいうと、テストのコードもすっきりさせなければいけません。しかし、リファクタリングという技術は使えません。テストのコードをすっきりさせるには別のやり方を使わなければいけません。

テストのコードをすっきりさせなければいけない理由は、実装のコードをすっきりさせなければいけない理由と同じです。コードがすっきりしていないと、新しくコードを追加したり、既存のコードを修正したりといったコードを変更することが難しくなるからです。

では、テストのコードを変更しやすくするのはどうしてでしょうか。それは、実装のコードを変更しやすくするためです。テストがあれば、実装のコードを整理しても整理したことによって実装が壊れていないことを簡単に確認できます。同様に、新しく機能を追加したときも既存の機能が壊れていないことを簡単に確認できます。テストのコードが変更しづらくなって、テストを追加しなくなっていくと、実装のコードが壊れてしまったかどうかを確認することが難しくなります。そうすると、こわくて実装のコードを変更しづらくなります。

テストのコードが変更しやすいと以下のように開発できます。

実装変更 + テスト追加 → テスト実行 → 問題なし →
実装変更 + テスト追加 → テスト実行 → 問題なし →
実装整理              → テスト実行 → 問題なし →
実装変更 + テスト追加 → テスト実行 → 問題なし →
…

しかし、テストのコードの変更がしづらいと以下のようになります。

実装変更(テスト追加なし) → テスト実行 → たぶん問題なし →
実装変更(テスト追加なし) → テスト実行 → たぶん問題なし →
実装整理                   → テスト実行 → たぶん問題なし →
実装変更(テスト追加なし) → テスト実行 → たぶん問題なし →
実装変更(テスト追加なし) →
テスト少ないし実行しなくてもいいよね →
問題ないといいなぁ →
実装変更(したくないなぁ) →
…

まとめると、テストのコードを変更しやすくしておくことは、実装を変更しやすくしておくことにつながるので重要です。

それでは、テストのコードをすっきりさせるにはどうしたらよいでしょうか。残念ながらリファクタリングという技術は使えません。なぜなら、リファクタリングをするにはテストが必要だからです。テストをリファクタリングするにはテストのテストが必要になります。これではいつまでたってもテストのリファクタリングができません。

テストをすっきりさせるにはどのようなやり方がよいかを考えてみましょう。

それぞれのテストを独立させる

テストのコードを整理するときは、変更が他のテストに影響を与えていないことを手動で確認する必要があります。テストのテストがないため自動で確認することができないからです。

手動で確認するので、多くのことを確認しなければいけない状況は避けましょう。ミスが多くなります。この状況を避けるためには、それぞれのテストの影響範囲を小さくしておくことが有効です。変更の影響を確認する範囲が小さくなります。

具体的には以下のようにしてそれぞれのテストを独立させます。

  • 1つのテストで1つのことを確認する。
  • テスト対象ごとにテストをグループ化する。(さまざまなテスト対象のテストを1つのグループにまとめない。)
1つのテストで1つのことを確認

1つのテストで1つのことを確認するというのは、ざっくりというと1つのテストの中で1つのアサーションを使うということです*3

今回のケースでは、以下のようにtest_extract_contributorsという1つのテストの中で、「spineを1つしか含まないケース」と「spineを2つ含むケース」の2つのことを確認していました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TestEPUBDocument < Test::Unit::TestCase
  def setup
    # groonga_doc_all.epub ... spine を一つしか含まない EPUB ファイル
    # 本文は groonga ドキュメント 1 章 が全て入っている
    epub_book_1 = EPUB::Parser.parse(fixture_path('groonga_doc_all.epub'))
    @document_1 = EPUBSearcher::EPUBDocument.new(epub_book_1)

    # groonga_doc_11_12.epub ... spine を二つ含む EPUB ファイル
    # 本文は groonga ドキュメント 1.1 と 1.2 が入っている
    epub_book_2 = EPUB::Parser.parse(fixture_path('groonga_doc_11_12.epub'))
    @document_2 = EPUBSearcher::EPUBDocument.new(epub_book_2)
  end

  def test_extract_contributors
    assert_equal([], @document_1.extract_contributors)
    assert_equal(["groongaコミュニティ A", "groongaコミュニティ B", "groongaコミュニティ C"], @document_2.extract_contributors)
  end
end

これは、以下のように別のテストにわけると1つのテストで1つのことを確認するようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
class TestEPUBDocument < Test::Unit::TestCase
  def setup
    # ...
  end

  def test_extract_contributors_with_spine
    assert_equal([], @document_1.extract_contributors)
  end

  def test_extract_contributors_with_spines
    assert_equal(["groongaコミュニティ A", "groongaコミュニティ B", "groongaコミュニティ C"], @document_2.extract_contributors)
  end
end

こうすると、@document_1を変更しても2つめのテスト(..._spinesの方)には影響がありません。影響範囲が狭くなりましたね。

なお、1つのテストで1つのことを確認することはテストが失敗したときのデバッグのしやすさにもつながるので、テストの変更のしやすさ以外の観点からも有用です。

テスト対象ごとにテストをグループ化

個々のテストではなくもう少し大きな単位で考えてみましょう。影響範囲を小さくする別の方法があります。具体的に言うと、「テストをグループ化した単位」です。xUnitの場合はテストケースです。

具体的なコードで考えてみましょう。以下のコードには1つのテストケースの中に2つのテストがあります。それぞれのテストではsetupで用意したのに使っていない変数があります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TestEPUBDocument < Test::Unit::TestCase
  def setup
    # groonga_doc_all.epub ... spine を一つしか含まない EPUB ファイル
    # 本文は groonga ドキュメント 1 章 が全て入っている
    epub_book_1 = EPUB::Parser.parse(fixture_path('groonga_doc_all.epub'))
    @document_1 = EPUBSearcher::EPUBDocument.new(epub_book_1)

    # groonga_doc_11_12.epub ... spine を二つ含む EPUB ファイル
    # 本文は groonga ドキュメント 1.1 と 1.2 が入っている
    epub_book_2 = EPUB::Parser.parse(fixture_path('groonga_doc_11_12.epub'))
    @document_2 = EPUBSearcher::EPUBDocument.new(epub_book_2)
  end

  def test_extract_contributors_with_spine
    assert_equal([], @document_1.extract_contributors)
  end

  def test_extract_contributors_with_spines
    assert_equal(["groongaコミュニティ A", "groongaコミュニティ B", "groongaコミュニティ C"], @document_2.extract_contributors)
  end
end

@document_1は1つめのテストでしか使われていません。@document_2は2つめのテストでしか使われていません。setupで準備したのに使われていない変数があるということは、そのテストケースでは様々な種類のテストをグループ化しているということです。必要な変数を使うテスト毎にグループを細かくすることで影響範囲を小さくすることができます。

「spineの数」でさらにグループ化しましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class TestEPUBDocument < Test::Unit::TestCase
  class TestSingleSpine < self
    def setup
      epub_book = EPUB::Parser.parse(fixture_path('groonga_doc_all.epub'))
      @document = EPUBSearcher::EPUBDocument.new(epub_book)
    end

    def test_extract_contributors
      assert_equal([], @document.extract_contributors)
    end
  end

  class TestMultipleSpines < self
    def setup
      epub_book = EPUB::Parser.parse(fixture_path('groonga_doc_11_12.epub'))
      @document = EPUBSearcher::EPUBDocument.new(epub_book)
    end

    def test_extract_contributors
      assert_equal(["groongaコミュニティ A", "groongaコミュニティ B", "groongaコミュニティ C"], @document.extract_contributors)
    end
  end
end

「spineの数」でグループ化したことにより、テストケース名(TestSingleSpine)で「どのようなspineに注目しているか」ということが表現できるようになりました。そのため、もともとは以下のように「spineの数は1つだよ」と書いていたコメントを削除しました。

1
2
3
4
5
6
def setup
   # groonga_doc_all.epub ... spine を一つしか含まない EPUB ファイル
   #                          本文は groonga ドキュメント 1 章 が全て入っている
   epub_book_1 = EPUB::Parser.parse(fixture_path('groonga_doc_all.epub'))
   @document_1 = EPUBSearcher::EPUBDocument.new(epub_book_1)
end

使っている変数に注目してテストケースをわけたため、それぞれのテストは独立するようになりました。TestSingleSpineの中を変更しても、もう一方のテストケースには影響を与えません。

今は変数に注目してテストケースをわけましたが、メソッド名に注目してわける方法もあります。メソッド名の一部のtest_extract_contributorsに注目するとこうなります。

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
class TestEPUBDocument < Test::Unit::TestCase
  class TestExtractContributors < self
    def test_empty
      assert_equal([], extract("groonga_doc_empty.epub"))
    end

    def test_multiple
      assert_equal(["groongaコミュニティ A", "groongaコミュニティ B", "groongaコミュニティ C"],
                   extract("groonga_doc_multiple_contributors.epub"))
    end

    private
    def extract(path)
      epub_book = EPUB::Parser.parse(fixture_path(path))
      document = EPUBSearcher::EPUBDocument.new(epub_book)
      document.extract_contributors
    end
  end

  def test_XXX
    # ...
  end

  # ...
end

extract_contributorsという情報をテストケース名にもっていって、テスト名からは抜きました。これで、このテストケース内の変更は他のテストケースには影響を与えません。

extract_contributorsがsnake_caseからExtractContributorsというCamelCaseになって気持ち悪いと感じる人もいるでしょう。その場合はsub_test_caseを使えば、snake_caseのまま書けます*4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class TestEPUBDocument < Test::Unit::TestCase
  sub_test_case("extract_contributors") do
    def test_empty
      # ...
    end

    def test_multiple
      # ...
    end

    private
    def extract(path)
      # ...
    end
  end

  def test_XXX
    # ...
  end

  # ...
end

テストのグループ化についてはテストをすっきり書く方法として過去にまとめていて、インターンシップでもこのエントリーを読んでもらいました。

まとめ

インターンシップで説明した、テストを整理する方法についてまとめました。今回説明したやり方は以下の2つです。

  • 1つのテストで1つのことを確認する。
  • テスト対象ごとにテストをグループ化する。

過去にこれらのやり方についてエントリーにまとめていたので、インターンシップではまずエントリーを読んでもらい、概要を把握してもらったあとに説明しました。知見を文章としてまとめておくと有用であることがわかりました。過去に説明したものはまとめておいて、次に説明する機会があるときに参照しながら説明する、ということは今後も続けたいです。

作業をしていてなにかあったら相談して欲しいということは伝えていましたが、それだと曖昧すぎるようです。それよりも、「名前に困ったら相談するタイミング」というより具体的な基準を設定するほうが実行しやすいということがわかりました。今回のケースでは_1_2がでていますが、連番は適切な名前が思いつかなかったときに使われることが多いです*5。このように、この基準はわかりやすいため、これからも使えそうです。

*1 そりゃそうだろうなぁという感じですね。

*2 リーダブルなコードに関する講演依頼はお問い合わせフォームからどうぞ。

*3 複数のアサーションで1つのことを確認しているならそれでもOKです。厳密に1つのアサーションにしろということではなく、1つのことを確認しているかがわかりやすい表現なだけです。

*4 ただし、いつものRubyの書き方ではなくなります。この書き方を知っている人にはわかりやすいかもしれませんが、Rubyだけを知っている人には馴染みにくいでしょう。どちらを選ぶかはトレードオフを判断して決めてください。

*5 連番が適切なときもありますが、配列などで表現することもできるのでそんなに多くはありません。

2013-08-27

インターンシップで学んだこと2:1人で開発しているときはていねいに開発を進める

インターンシップ1日目と2日目ではコメントに注目するとよいということを学びました。インターンシップ3日目のメモを読み返すと3日目は3つ学んだことがありました。今回はそのうちの1つ「1人で開発しているときにtypoとどうつきあっていくか」です。

背景

インターンシップは基本的にメンターがいつもインターンのそばにいて一緒に開発を進めていく予定でしたが、打ち合わせなど他の予定が入ってしまうこともありました。そのようなときはインターンが1人で開発を進めていました。メンターは他の予定から戻ってきたときに「どうだった?」「詰まっているところはない?」などと聞きながらコミットメールを読んでいました。

コミットメールを読めばだいたいどんなことをやっていたかはわかりますが、「変なことをしていないか?」という視点で読んでいるわけではないので細かいミスすべてに気づくわけではありません。どちらかというと時間を越えてペアプログラミングしている気分で読んでいます。これは、コードレビューでの読み方とは違う読み方です。

そんなわけで、2日目に追加したテスト名にtypoがあり、テストとして認識されていないことに気づいていませんでした*1。typoを直してテストとして認識されるようにしたところテストが失敗していました。せっかくテストを追加しながら開発していたのですが活かせていませんでした。

typoはよくあることですし、すぐに直せることなので、typoしてしまうことが悪いことだとは思いません。typoを防ぐことよりもtypoしてもすぐに気づいて直せるようにした方が現実的でしょう。ということで、「1人で開発しているときにtypoとどうつきあっていくか」についてです。どうつきあっていくかを最初に言うと、ていねいに開発を進めてつきあっていく、です。

開発を継続するためのテスト

開発するときはテストを書きますが、それは開発を駆動するためではないのでテスト駆動開発という言葉を使うことはあまりありません。「テストを書きながら開発をするやり方」をざっくりと説明するために便利なのでテスト駆動開発という言葉を使うくらいです。テストを書くのは開発を駆動するためではなく、開発を継続するためです。

いくつもフリーソフトウェアの開発に関わっていると、数カ月ぶりにコミットするということがあります。そんなときはコードの隅々まで覚えているという状態ではありません。そんな状態でも安心してコードを変更できるようにするためにテストを書いておきます。他にも、開発を長く続けていると新しいプラットフォームや新しいライブラリーに対応したりする場合にテストがあると安心してコードを変更できます。つまり、いつまでも安心してコードを変更していけるように、開発を継続していけるように、そのためにテストを書いています。

開発を駆動するためにテストを書いているわけではないので、テストファーストで開発を進めるときもあればそうでない場合もあります。入力のパターンがわかっている場合は1つずつテストファーストで開発を進めていきますが、さぐりさぐりで開発しているときはある程度実装が見えてきてからテストを書きます。

このように、あまりテスト駆動開発を取り入れていませんが、テスト駆動開発の中にていねいに開発を進めるためによいやり方があるので、それは取り入れています。

最初にテストを失敗させる

テスト駆動開発の中にあるていねいに開発を進めるためのよいやり方とは「最初にテストが失敗させ、ちゃんとテストが実行されていることを確認する」やり方です。これは、1人で開発しているときでもなるべく早くtypoに気づけるようになるからです。具体的にどうやっているかというと、最初は期待する値として"XXX"という必ず失敗する値を指定して実行しています。別に"XXX"でなくてもよいのですが、コードのコメントの中で「なんか気をつけて!」くらいの意味合いで「XXX」が使われるので、失敗する値としても"XXX"を使っています。

1
2
3
def test_plus
  assert_equal("XXX", 1 + 2)
end

このコードをコミットすることはないのでこのように開発していることを知っている人はほとんどいないでしょう。インターンシップのときも伝え忘れました。単に、「テストを書いたら最初に失敗させてテストが実行されていて確認するといいよ」と伝えたくらいだった気がします。

テストでは補完機能を使わない

typoを早く見つけるために気をつけていることがもう1つありました。それは、テストコードの方ではあまりエディターの補完機能を使わないということです。テストコード固有のところでは使いますが、テスト対象のコードを書くとき(開発した機能をテストの中で使っているとき)には使いません。これは、補完機能を使ってしまうと開発しているコードの中にあるtypoをそのままテストでも使ってしまうからです。そのまま使ってしまうとtypoに気づくことができません。

テストコードの中では開発しているコードを使う側の気持ちになって書きたいので、エディターの補完機能に頼らず実際に入力しています。

このやり方もインターンシップ中には伝え忘れた気がします。

まとめ

メインで開発している人が自分だけの場合は、typoに気づきづらいものですが、ていねいに開発を進めれば早いうちに気づけるものですよ、ということをまとめました。普段、意識せずにやっていて、インターンシップのときもそれほどまとまった形で伝えられなかったことが、こうしてインターンシップのメモをまとめ直すことでまとまった形にできてよかったです。

*1 こういうときはdef test_XXXではなく、test "..." {}でテストを定義したほうがよいのではないかという気がしてきます。

2013-08-13

Ruby用単体テストフレームワークtest-unitでのデータ駆動テストの紹介

test-unitはRuby用のxUnit系の単体テストフレームワークです。2.3.1からデータ駆動テスト機能が追加されていたのですが、2.5.3まではリファレンスに記述がなく、知る人ぞ知る機能でした。

2013-01-23にリリースされた2.5.4ではデータ駆動テスト機能についてのドキュメントが追加されています。

データ駆動テスト自体の説明はUxUを用いたデータ駆動テストの記述を参照してください。

Cucumberのscenario outlinesに似ていると言えばピンと来る人もいるのではないでしょうか。 Cucumberのscenario outlinesも前述のククログ記事の通り、テストのデータとロジックを分離しているのでデータ駆動テストの一種と言えます。

今回は、データ駆動テストを導入した例を見ながらtest-unitでのデータ駆動テスト機能の使い方を紹介します。なお、以降の説明では「テスト対象のデータ」のことを「テストデータ」とします。

データ駆動テスト導入例

データ駆動テストの導入例としてBitClust*1での使い方を紹介します。

データ駆動テスト導入前のBitClustのnameutils.rbには次のようなテストメソッドがありました。test_nameutils.rb@r5333から一部を抜粋します。

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
class TestNameUtils < Test::Unit::TestCase

  include BitClust::NameUtils

  def test_libname?
    assert_equal true, libname?("_builtin")
    assert_equal true, libname?("fileutils")
    assert_equal true, libname?("socket")
    assert_equal true, libname?("open-uri")
    assert_equal true, libname?("net/http")
    assert_equal true, libname?("racc/cparse")
    assert_equal true, libname?("test/unit/testcase")
    assert_equal false, libname?("")
    assert_equal false, libname?("fileutils ")
    assert_equal false, libname?(" fileutils")
    assert_equal false, libname?("file utils")
    assert_equal false, libname?("fileutils\n")
    assert_equal false, libname?("fileutils\t")
    assert_equal false, libname?("fileutils.rb")
    assert_equal false, libname?("English.rb")
    assert_equal false, libname?("socket.so")
    assert_equal false, libname?("net/http.rb")
    assert_equal false, libname?("racc/cparse.so")
  end
# ...省略
end

上記の抜粋箇所だけ取り出してテストを実行すると、結果はこのようになります*2

$ ruby test/run_test.rb -n /test_libname.$/ -v
Loaded suite test
Started
TestNameUtils: 
  test_libname?:                                        .: (0.000636)

Finished in 0.000982531 seconds.

1 tests, 18 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

1017.78 tests/s, 18320.03 assertions/s

テストが1つだけ実行されています。この1つのテストの中で様々なデータに対するアサーションを実行しています。ただ、上のコードでは、どのようなデータに対してテストしているのかを知るためにはソースコードを確認する必要があります。

これをデータ駆動テスト機能を使用して書き換えると以下のようになります。コード全体は現在のtest_nameutils.rb@r5551を参照してください。

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
class TestNameUtils < Test::Unit::TestCase

  include BitClust::NameUtils

  data("_builtin"                       => [true, "_builtin"],
       "fileutils"                      => [true, "fileutils"],
       "socket"                         => [true, "socket"],
       "open-uri"                       => [true, "open-uri"],
       "net/http"                       => [true, "net/http"],
       "racc/cparse"                    => [true, "racc/cparse"],
       "test/unit/testcase"             => [true, "test/unit/testcase"],
       "empty string"                   => [false, ""],
       "following space"                => [false, "fileutils "],
       "leading space"                  => [false, " fileutils"],
       "split by space"                 => [false, "file utils"],
       "following new line"             => [false, "fileutils\n"],
       "folowing tab"                   => [false, "fileutils\t"],
       "with extension .rb"             => [false, "fileutils.rb"],
       "CamelCase with extension .rb"   => [false, "English.rb"],
       "with extension .so"             => [false, "socket.so"],
       "sub library with extension .rb" => [false, "net/http.rb"],
       "sub library with extension .so" => [false, "racc/cparse.so"])
  def test_libname?(data)
    expected, target = data
    assert_equal(expected, libname?(target))
  end
# ...省略
end

上記の抜粋箇所だけ取り出してテストを実行すると、結果はこのようになります。

$ ruby test/run_test.rb -n /test_libname.$/ -v
Loaded suite test
Started
TestNameUtils: 
  test_libname?[_builtin]:                              .: (0.000591)
  test_libname?[fileutils]:                             .: (0.000389)
  test_libname?[socket]:                                .: (0.000365)
  test_libname?[open-uri]:                              .: (0.000354)
  test_libname?[net/http]:                              .: (0.000355)
  test_libname?[racc/cparse]:                           .: (0.000354)
  test_libname?[test/unit/testcase]:                    .: (0.000349)
  test_libname?[empty string]:                          .: (0.000397)
  test_libname?[following space]:                       .: (0.000346)
  test_libname?[leading space]:                         .: (0.000343)
  test_libname?[split by space]:                        .: (0.000346)
  test_libname?[following new line]:                    .: (0.000353)
  test_libname?[folowing tab]:                          .: (0.000344)
  test_libname?[with extension .rb]:                    .: (0.000344)
  test_libname?[CamelCase with extension .rb]:          .: (0.000347)
  test_libname?[with extension .so]:                    .: (0.000343)
  test_libname?[sub library with extension .rb]:        .: (0.000346)
  test_libname?[sub library with extension .so]:        .: (0.000345)

Finished in 0.007920974 seconds.

18 tests, 18 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

2272.45 tests/s, 2272.45 assertions/s

アサーションの数は修正前と同じですが、テストの数が修正前よりも増えています。具体的には「1 tests」から「18 tests」に増えています。また、実行結果にテストしたテストデータの名前が表示されるようになったので、どのようなテストデータに対してテストを実行したのかを実行時にも確認できます。

dataとload_dataの使い方

テストデータを登録するためにはdataメソッドまたはload_dataメソッドを使います。それぞれのメソッドの使い方を説明します。

dataメソッドとload_dataメソッドはTest::Unit::TestCaseに定義されている特異メソッドです。publicprivateのようにメソッド定義の直前に書いて使用します。例えば、以下のように書きます。

1
2
3
4
5
6
class TestDataDrivenTest < Test::Unit::TestCase
  data("...")
  def test_xxx(test_data)
    # ...
  end
end

dataメソッドの使い方には次の三種類があります。

  • data(label, data)
  • data(data_set)
  • data(&block)

load_dataメソッドの使い方は次の一種類だけです。

  • load_data(file_name)

それぞれの使い方を順に説明します。

data(label, data)

labelにはテストデータの名前を指定します。dataにはテストデータとして任意のオブジェクトを指定します。ここに指定したオブジェクトがテストメソッドにそのまま渡されます。

1
2
3
4
5
6
7
8
9
10
require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string", [true, ""])
  data("plain string", [false, "hello"])
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

この例ではテストデータを配列で指定していますが、複雑なデータを渡すときは、Hashやテストで使いやすいようにラップしたオブジェクトを使うとテストコードが読みやすくなります。

data(data_set)

data_setにはテストデータの名前をキー、テストデータを値とする要素を持つHashを指定します。この使い方の場合は、Hashの各要素の値がテストメソッドにそのまま渡されます。

1
2
3
4
5
6
7
8
9
10
require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string" => [true, ""],
       "plain string" => [false, "hello"])
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end
data(&block)

ブロックでテストデータを生成することもできます。

ブロックはテストデータの名前をキー、テストデータを値とする要素を持つHashを返すようにします。ランダムな値を生成するテストや、網羅的な値を生成して使うテストが書きやすくなります。外部からテストデータを読み込んで使うようなテストも書きやすくなるでしょう。

以下のようにテストデータの生成部分とテストのロジック部分を独立して書くことができるので、テストが書きやすくなります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
require "test-unit"

class TestData < Test::Unit::TestCase
  data do
    data_set = {}
    data_set["empty string"] = [true, ""]
    data_set["plain string"] = [false, "hello"]
    data_set
  end
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

最初に紹介したnameurils.rbのテストでも網羅的なテストを実行するためにこの機能を使用しています。興味のある人はtest_typemark?test_typechar?を見てください。

load_data(file_name)

load_dataメソッドは外部のファイルからデータを読み込みます。

load_dataはファイルの拡張子によって、ファイル形式を自動的に判断してデータを読み込みます。現在の最新版であるtest-unit-2.5.4では、CSVとTSVに対応しています。

例えば、次の表のようなtest-data.csvという名前のCSVファイルを用意します。

labelexpectedtarget
empty stringtrue""
plain stringfalsehello

ヘッダーの最初の要素(一番左上の要素)は必ず「label」にしてください。

CSVファイルだと以下のようになります。

label,expected,target
empty string,true,""
plain string,false,hello

このCSVファイルを使って書いたテストコードはこのようになります。このファイルをtest-sample.rbとします。

1
2
3
4
5
6
7
8
require "test-unit"

class TestData < Test::Unit::TestCase
  load_data("test-data.csv")
  def test_empty?(data)
    assert_equal(data["expected"], data["target"].empty?)
  end
end

実行結果はこのようになります。

$ ruby test-sample.rb -v
Loaded suite TestData
Started
test_empty?[empty string]:                              .: (0.000572)
test_empty?[plain string]:                              .: (0.000424)

Finished in 0.001337316 seconds.

2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

1495.53 tests/s, 1495.53 assertions/s

CSVファイルを使ったこのテストコードは、以下のように書いたテストコードと同じテストになります。

1
2
3
4
5
6
7
8
9
10
# test-sample.rb
require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string" => {"expected" => true,  "target" => ""},
       "plain string" => {"expected" => false, "target" => "hello"})
  def test_empty?(data)
    assert_equal(data["expected"], data["target"].empty?)
  end
end

また、次のようなヘッダーのないCSVファイルにも対応しています。一番左上の要素が「label」にならないように注意してください。「label」となっていると最初の行をヘッダーとみなします。

empty stringtrue""
plain stringfalsehello

CSVファイルだと以下のようになります。

empty string,true,""
plain string,false,hello

この場合は、次のようなテストコードになります。

1
2
3
4
5
6
7
8
9
10
# test-sample.rb
require "test-unit"

class TestData < Test::Unit::TestCase
  load_data("test-data.csv")
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

実行結果はこのようになります。

$ ruby test-sample.rb -v
Loaded suite TestData
Started
test_empty?[empty string]:                              .: (0.000584)
test_empty?[plain string]:                              .: (0.000427)

Finished in 0.001361219 seconds.

2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

1469.27 tests/s, 1469.27 assertions/s

このようなCSVファイルを読み込んだ場合はdata(data_set)の例と同じように解釈されます。サンプルコードを再掲します。

1
2
3
4
5
6
7
8
9
10
require "test-unit"

class TestData < Test::Unit::TestCase
  data("empty string" => [true, ""],
       "plain string" => [false, "hello"])
  def test_empty?(data)
    expected, target = data
    assert_equal(expected, target.empty?)
  end
end

CSVファイルやTSVファイルでテストデータを作成できると、テストデータの作成に表計算ソフトやデータ生成用スクリプトを利用できます。そのため、たくさんのパターンのテストケースを作成しやすくなります。ただし、テストデータは多ければ多いほどよいというものではないことに注意してください。テストデータが多くなるとその分テスト実行時間が長くなり、テスト実行コストが高くなります。テストデータを作りやすくなったからといって、必要以上にテストデータを作らないようにしましょう。

まとめ

test-unitでのデータ駆動テスト機能について紹介しました。いろいろなパターンがあるテストをメンテナンスしやすい状態に保つために、データ駆動テスト機能を使ってみてはいかがでしょうか。

*1 るりまプロジェクトで使用しているドキュメント生成ツール。

*2 test/run_test.rbはtest-unitでテストを実行するためのスクリプトです。

タグ: Ruby | テスト
2013-01-23

Firefox/Thunderbird用アドオン開発者向けテスティングフレームワーク UxU 1.0.0をリリースしました

長らく更新がない状態が続いてしまっておりましたが、Firefox/Thunderbird用アドオンの開発者向けテスティングフレームワークUxU(UnitTest.XUL)の新バージョンとなるバージョン1.0.0を、本日付けでリリースしました。動作対象は、現在Mozillaによって公式にサポートが継続されているFirefox 10 ESR、Thunderbird 10 ESR、および最新のリリース版までです。Firefox 3.6、Thunderbird 3.1などの旧バージョンでの動作は保証されていませんので、ご注意下さい。

テストの並列実行

バージョン1.0.0では影響の大きな変更点として、複数のテストの並列実行に関する動作が大幅に改善され、実用的な機能になりました。

設定画面から最大の並列実行数を変更するか、コマンドライン引数を通じて並列実行数を明示的に指定することで、これまですべてシーケンシャルに実行されていたテストが、テストケース(ファイル)単位で並列に実行されるようになります。

並列実行が有効な場面

JavaScriptを使ったテストでは、Webページの読み込みなど、同期的には結果を得られない・処理待ちが必要となる場面が多くあります。UxUでは、ユーティリティメソッドなどを通じて簡単に処理待ちを行える事が特長の1つとなっています。

しかしながら、処理待ちが発生するという事は、必然的に、全体のテスト実行時間が長くなってしまう事にも繋がります。

複数のテストを並列実行すると、1つのテストで処理待ちしている間に、別のテストの処理を進める事ができます。処理待ちしている時間を無駄にせずに済むようになるため、処理待ちが多い場合においては、全体的なテスト実行時間の短縮が期待できます。

並列実行が有効でない場面

UxUは、プロファイルを指定して別プロセスを起動する場合を除き、すべてのテストが同一プロセスの同一スレッド上で動作する設計となっています。そのため、単純なforループやwhileループなど、同期的な処理により時間がかかってしまっている場面については、他のテストを並列実行する事ができません。

このような理由でテストの実行時間が長くなっているケースについては、残念ながら、並列実行では実行時間の短縮は望めません。

並列実行できない場面

テストの設計によっては、他のテストと並列に実行するとランダムに失敗するようになってしまう場合があります*1。並列実行機能は、個々のテスト同士が衝突しないようになっている事を確認した上で利用して下さい。

なお、テストケース中で「var parallel = false;」と明示的に指定する事により、そのテストケースを並列実行の対象外にする事ができます。並列実行の対象外になっているテストケースは、同時に2つ以上実行される事はありません*2。並列実行すると失敗するテストについてはこの方法で問題を回避し、できる所から並列実行の恩恵を得るという事もできます。

まとめ

UxU バージョン1.0.0で実用的な機能となった複数テストの並列実行機能について、有効な場面と有効でない場面、問題が起こる場面などの情報を簡単にご紹介しました。テスト実行時間の長さに悩まされていた方は、是非一度お試し下さい。

*1 例えば、単一のテスト用フレームを2つのテストで同時に使用してしまっている場合や、複数のテストでそれぞれウィンドウを開いていて「最前面にあるウィンドウ」を取り合ってしまう場合など。

*2 並列実行の対象になっているテストが他にある場合は、それとは並行して実行されます。

タグ: Mozilla | テスト | UxU
2012-07-24

テストをすっきり書く方法

はじめに

ソフトウェアを作るときには同時にテストも作ります。 テストを動かすことで、ソフトウェアが設計の通り動作しているかを確認できます。もし設計の通りに動作しない場合はテストが失敗し、ソフトウェアに期待する動作と現在の間違った動作が明確になります。 テストをすっきりと書くことができると、テストを読みやすくなり、また、きれいなソースコードのままで新しくテストを追加することができます。 今回は、そのすっきりとテストを書くための方法について説明します。

テストを追加していくと発生する問題

例えば、1つのテストケースの中にいろいろな機能のテストがある場合を考えます。 ここで、ある機能の実装を修正したので、この機能に関するテストを追加しようとしました。 テスト名に「テストのコンテキスト」と「テスト対象」を含めてどのような内容のテストかを示します。 このとき、ある機能に対して様々な動作をテストすることも多いため、すでにテストケースの中に追加したいテストとコンテキストを共有しているテストがある場合もあります。 ここで問題が発生します。すべてのテスト名にコンテキストを含めるとテスト名が長くなってしまいます。 さらに、これを色んなコンテキストで繰り返すことで、同じコンテキスト名を含んだテスト名が複数あると、同じ情報が何度も現れて見にくくなります。

テストのグループ分け

この問題を解決する方法がテストをクラスでグループ分けする方法です。 グループ分けすることで、ごちゃごちゃしたテストをすっきりと整頓できます。 つまり、テスト名を短く、またすっきりとした状態を保ったままテストを追加できるようになります。

実際にクラスによるグループ分けをどうやるかについて、全文検索エンジンgroongaのRubyバインディングであるrroongaのテストを例に挙げて説明します。 rroongaはgroongaをRubyから便利に使うためのライブラリで、内部でgroongaを使用しています。 ここでは1つのデータの集まりを表すレコードオブジェクトに関するテストを例にします。

以下はレコードに関するテストの一部です。 テスティングフレームワークはtest-unitを使っています。

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class RecordTest < Test::Unit::TestCase
  include GroongaTestUtils

  def test_have_column_id
    groonga = @bookmarks.add
    assert_true(groonga.have_column?(:_id))
  end

  def test_have_column_key_hash
    mori = @users.add("mori")
    assert_true(mori.have_column?(:_key))
  end

  def test_have_column_key_array_with_value_type
    groonga = @bookmarks.add
    assert_true(groonga.have_column?(:_key))
  end

  def test_have_column_key_array_without_value_type
    groonga_ml = @addresses.add
    assert_false(groonga_ml.have_column?(:_key))
  end

  def test_attributes
    values = {
      "uri" => "http://groonga.org/",
      "rate" => 5,
      "comment" => "Grate!"
    }
    groonga = @bookmarks.add(values)
    assert_equal(values.merge("_id" => groonga.id,
                              "content" => nil,
                              "user" => nil),
                 groonga.attributes)
  end

  def test_recursive_attributes
    @bookmarks.define_column("next", @bookmarks)

    top_page_record = @bookmarks.add(top_page)
    doc_page_record = @bookmarks.add(doc_page)

    top_page_record["next"] = doc_page_record
    doc_page_record["next"] = top_page_record

    expected = {
      "_id" => 1,
      "user" => nil,
      "uri" => "http://groonga.org/",
      "rate" => 5,
      "next" => {
        "_id" => 2,
        "user" => nil,
        "uri" => "http://groonga.org/document.html",
        "rate" => 8,
        "content" => nil,
        "comment" => "Informative"
      },
      "content" => nil,
      "comment" => "Great!"
    }
    expected["next"]["next"] = expected

    assert_equal(expected, top_page_record.attributes)
  end

  def test_key
    documents = Groonga::PatriciaTrie.create(:name => "Documents",
                                             :key_type => "ShortText")
    reference = documents.add("reference")
    assert_equal("reference", reference.key)
  end

  def test_value
    bookmark = @bookmarks.add
    assert_equal(0, bookmark.value)
    bookmark.value = 100
    assert_equal(100, bookmark.value)
  end
end

説明のために、上のソースコードからテストケース名とテスト名だけ抜き出して並べます。

1
2
3
4
5
6
7
8
9
10
class RecordTest
  def test_have_column_id; end
  def test_have_column_key_hash; end
  def test_have_column_key_array_with_value_type; end
  def test_have_column_key_array_without_value_type; end
  def test_attributes; end
  def test_recursive_attributes; end
  def test_key; end
  def test_value; end
end

テスト名を見ると、「どんなカラムを持っているか」という「コンテキスト(have_column)」のテストが4つ、「属性はどんな状態か」という「コンテキスト(attributes)」のテストが2つあることがわかります。 しかし、「どんなカラムを持っているか」という「コンテキスト(have_column)」をもつテスト4つ全てに、コンテキスト(have_column)を示す名前が入っています。 「属性はどんな状態か」という「コンテキスト(attributes)」をもつテストにも同じようにコンテキスト(attributes)を示す名前が入っているため、同じ情報が何度も現れて見にくくなる問題が発生しています。

そこで、「どんなカラムを持っているか」という「コンテキスト(have_column)」と、「属性はどんな状態か」という「コンテキスト(attributes)」でテストをグループ分けします。 まず、「have_column」をコンテキストをグループ分けしましょう。 対象となるのは以下のテストです。

1
2
3
4
5
6
class RecordTest
  def test_have_column_id; end
  def test_have_column_key_hash; end
  def test_have_column_key_array_with_value_type; end
  def test_have_column_key_array_without_value_type; end
end

まず、これらのテストをコンテキスト名を含んだクラスに移動します。

1
2
3
4
5
6
7
8
class RecordTest
  class HaveColumnTest
    def test_have_column_id; end
    def test_have_column_key_hash; end
    def test_have_column_key_array_with_value_type; end
    def test_have_column_key_array_without_value_type; end
  end
end

コンテキスト名を含んだクラスは移動前のクラスを継承します。 継承することで、自然と移動前のコンテキストを引き継いだテストを書くことができるからです。

1
2
3
4
5
6
7
8
class RecordTest
  class HaveColumnTest < self
    def test_have_column_id; end
    def test_have_column_key_hash; end
    def test_have_column_key_array_with_value_type; end
    def test_have_column_key_array_without_value_type; end
  end
end

selfはクラスの定義中だとそのクラスとなるため、ここでのselfはRecordTestになります。 最後に、テスト名からコンテキストを除きます。 すでにクラス名にコンテキストが含まれているため、コードでコンテキストを表現できているからです。

1
2
3
4
5
6
7
8
class RecordTest
  class HaveColumnTest < self
    def test_id; end
    def test_key_hash; end
    def test_key_array_with_value_type; end
    def test_key_array_without_value_type; end
  end
end

このように分類することで、コンテキスト(have_column)を表すのがクラス名だけになり、同じ情報が何度も現れる問題を解決することができました。

同じようにattributesのコンテキストについてもやってみましょう。 対象となるのは以下のテストです。

1
2
3
4
class RecordTest
  def test_attributes; end
  def test_recursive_attributes; end
end

まず、これらのテストをコンテキスト名を含んだクラスに移動します。

1
2
3
4
5
6
class RecordTest
  class AttributesTest
    def test_attributes; end
    def test_recursive_attributes; end
  end
end

コンテキスト名を含んだクラスは移動前のクラスを継承します。

1
2
3
4
5
6
class RecordTest
  class AttributesTest < self
    def test_attributes; end
    def test_recursive_attributes; end
  end
end

最後に、テスト名からコンテキストを除きます。

1
2
3
4
5
6
class RecordTest
  class AttributesTest < self
    def test_single; end
    def test_recursive; end
  end
end

test_attributesは、attributesというコンテキストでは単一の属性についてテストしているので、ここでは新しくtest_singleとしました。 コンテキストがattributesのテストに関しても、attributesというコンテキストが何度も現れる問題が解決されています。 グループ分けをした後のRecordテストは次のようになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class RecordTest
  class HaveColumnTest < self
    def test_id; end
    def test_key_hash; end
    def test_key_array_with_value_type; end
    def test_key_array_without_value_type; end
  end

  class AttributesTest < self
    def test_single; end
    def test_recursive; end
  end

  def test_key; end
  def test_value; end
end

コンテキストをクラス名に移動することで、関連するテストをまとめることができました。これにより、テストコード全体がすっきりしました。 今後、テストを追加するときも同じコンテキスト名を重複して書かなくてもよくなるため、すっきりした状態を保つことができます。

まとめ

テストをすっきりさせる方法としてテストをグループ分けする方法を紹介しました。 すっきりした状態ではテストも読みやすくなります。 また、きれいなソースコードのままテストを追加することもできます。

おまけ

今回の例に挙げたテストは、RSpecではdescribeを使って書くことができます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe Groonga::Record do
  describe "#have_column?" do
    it "should true for _id" do; end
    it "should true for _key of hash" do; end
    it "should true for _key of array that has value type" do; end
    it "should false for _key of array that doesn't have value type" do; end
  end

  describe "#attributes" do
    it "should return hash for record that doesn't have reference" do; end
    it "should return hash for record that has recursive reference" do; end
  end

  it "should get key" do; end
  it "should get and set value" do; end
end
タグ: Ruby | テスト
2012-04-25

デバッグしやすいHTMLのテストの書き方

注意: 長いです。

一言まとめ: withinとtest-unit-capybaraを使ってHTMLのテストを書くと問題を見つけやすくなる。あわせて読みたい: デバッグしやすいassert_equalの書き方

HTMLに対するテストに限らず、開発を進めていく中でテストが失敗する状況になることは日常的にあることです。HTMLの場合は、入力フォームのラベルを変更したり、項目を追加したら既存のテストが失敗するようになるでしょう。そのとき、どのようにテストを書いていれば原因を素早く見つけられるのかを説明します。ポイントは「注目しているノードを明示すること」です。

HTMLテストのライブラリ

さて、Rubyで処理結果のHTMLをテストするときにはどんなライブラリを使っていますか?The Ruby ToolboxにあるBrowser testingカテゴリを見てみると、Capybaraが最も使われていて、次にWebratが広く使われているようです。どちらも同様の機能が揃っているため、特別な使い方をしないならどちらを使っても困ることはないでしょう。もし、今から使いはじめるならCapybaraの方がよいでしょう。これは、Capybaraの方がより活発に開発されているためです。

ここでは、CapybaraでHTMLのテストを書いたときを例にして、どのようにテストを書けば失敗した時に原因をすぐに見つけられるようになるかを説明します。考え方は他のツールでも応用できますし、test-unitやRSpecなどのテスティングフレームワークにも依存しません。そのため、ここでは、もっともRubyらしく書けるテスティングフレームワークであるtest-unit 2を使います。

よくある書き方

まずは一般的なCapybaraでのHTMLのテストがどのように書けるのかを確認します。

まず、以下のようなRackアプリケーションがあるとします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyRackApplication
  def call(env)
    html = <<-HTML
<html>
  <head>
    <title>Welcome! - my site</title>
  </head>
  <body>
    <h1>Welcome!</h1>
    <div class="header">
      <p>No navigation.</p>
    </div>
  </body>
</html>
HTML
   [200, {"Content-Type" => "text/html"}, [html]]
  end
end

はじめにCapybaraでテストを書ける状態にします。include Capybara::DSLCapybara.app=がポイントです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyRackApplication
  # ...
end

gem "test-unit"
require "test/unit"
require "capybara/dsl"

class TestMyRackApplication < Test::Unit::TestCase
  include Capybara::DSL

  def setup
    Capybara.app = MyRackApplication.new
  end
end

それでは、まずは期待した見出しが返ってきているかを確認してみましょう。

1
2
3
4
5
6
7
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_heading
    visit("/")
    assert_equal("Welcome!", find("h1").text)
  end
end

実行するとテストがパスするので正しい値が返ってきていることを確認できます。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
.

Finished in 0.427469388 seconds.

1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

2.34 tests/s, 2.34 assertions/s

ヘッダー部分も確認しましょう。テスト対象のHTMLの一部も再掲します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyRackApplication
  def call(env)
    html = <<-HTML
<html>
  <!-- ... -->
  <body>
    <h1>Welcome!</h1>
    <div class="header">
      <p>No navigation.</p>
    </div>
  </body>
</html>
HTML
   [200, {"Content-Type" => "text/html"}, [html]]
  end
end
# ...
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_header
    visit("/")
    assert_equal("No navigation.", find(".header p").text)
  end
end

これもパスします。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
..

Finished in 0.419134417 seconds.

2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed

4.77 tests/s, 4.77 assertions/s

雰囲気はわかりましたね。

HTMLを変更

それでは、HTMLを少し変更してナビゲーション用のリンクをいれましょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class MyRackApplication
  def call(env)
    html = <<-HTML
<html>
  <!-- ... -->
  <body>
    <h1>Welcome!</h1>
    <div class="header">
      <ul>
        <li><a href="/">Top</a></li>
      </ul>
    </div>
  </body>
</html>
HTML
   [200, {"Content-Type" => "text/html"}, [html]]
  end
end

HTMLの構造が変わったのでテストは失敗します。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
E
===============================================================================
Error:
test_header(TestMyRackApplication):
Capybara::ElementNotFound: Unable to find css ".header p"
    /var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:154:in `raise_find_error'
    /var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:27:in `block in find'
    /var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/base.rb:46:in `wait_until'
    /var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:27:in `find'
    (eval):2:in `find'
    /var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/dsl.rb:161:in `find'
    test-capybara.rb:40:in `test_header'
===============================================================================
.

Finished in 0.431328598 seconds.

2 tests, 1 assertions, 0 failures, 1 errors, 0 pendings, 0 omissions, 0 notifications
50% passed

4.64 tests/s, 2.32 assertions/s

さて、ここでどうしますか?よく使われる方法は以下のような方法ではないでしょうか。

  • puts sourceを埋め込んでHTMLを確認する。
  • assert_equalのメッセージにsourceを指定する。
  • save_and_open_browserでブラウザで確認する。

それでは、それぞれの方法についてみていきましょう。

puts sourceを埋め込む

include Capybara::DSLするとsourceメソッドが追加され、アプリケーションが返した生のHTMLを確認することができます。

1
2
3
4
5
6
7
8
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_header
    visit("/")
    puts source
    assert_equal("No navigation.", find(".header p").text)
  end
end

テストを実行するとHTMLが出力されます。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
<html>
  <head>
    <title>Welcome! - my site</title>
  </head>
  <body>
    <h1>Welcome!</h1>
    <div class="header">
      <ul>
        <li><a href="/">Top</a></li>
      </ul>
    </div>
  </body>
</html>
E
===============================================================================
Error:
test_header(TestMyRackApplication):
Capybara::ElementNotFound: Unable to find css ".header p"
...

出力されたHTMLと「Unable to find css ".header p"」というエラーメッセージと目CSSセレクタを活用して、どうしてCSSセレクタ".header p"がマッチしなくなったのかを考えます。

この方法はテスト自体を書き換える必要があるため、テストが失敗したらテストを修正してもう一度実行し直さなければいけません。テストがパスするようになったら変更を元に戻すことも忘れてはいけません。

assert_equalのメッセージにsourceを指定

assert_equalに限らずアサーションには失敗時に表示する追加のメッセージを指定できます。この方法では、失敗したときのみHTMLが表示されるため「失敗したときだけテストを変更する」といったことをする必要はありません。

1
2
3
4
5
6
7
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_header
    visit("/")
    assert_equal("No navigation.", find(".header p").text, source)
  end
end

実行してみます。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
E
===============================================================================
Error:
test_header(TestMyRackApplication):
Capybara::ElementNotFound: Unable to find css ".header p"
    /var/lib/gems/1.9.1/gems/capybara-1.1.2/lib/capybara/node/finders.rb:154:in `raise_find_error'
    ...
===============================================================================
...

おや、エラーメッセージの中にHTMLが出力されていませんね。もう一度assert_equalを確認してみましょう。

1
assert_equal("No navigation.", find(".header p").text, source)

問題なさそうに見えますが、どうして出力されていないのでしょうか。

それは、assert_equalが呼び出される前にfindの中で例外が発生しているからです。Capybaraでは要素が見つけられなかったときはCapybara::ElementNotFound例外を投げるのでassert_equalは呼び出されなかったのです。

これを回避するためには以下のようにfindではなくhas_selector?で事前にCSSセレクタがマッチするかを確認する必要があります。

1
2
3
4
5
6
7
8
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_header
    visit("/")
    assert_true(has_selector?(".header p"), source)
    assert_equal("No navigation.", find(".header p").text)
  end
end

これを実行するとHTMLが出力されます。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
F
===============================================================================
Failure:
test_header(TestMyRackApplication) [test-capybara.rb:40]:
<html>
  <head>
    <title>Welcome! - my site</title>
  </head>
  <body>
    <h1>Welcome!</h1>
    <div class="header">
      <ul>
        <li><a href="/">Top</a></li>
      </ul>
    </div>
  </body>
</html>
.
<true> expected but was
<false>
===============================================================================
...

しかし、今度は指定したCSSセレクタが出力されません。

そもそも、findする前に事前に存在を確認するようなテストの書き方になってしまうのでは、せっかくのCapybaraのすっきりした記法を活かせていないと言えるでしょう。

ちなみに、RSpecでは以下のように書くことになります。こちらもHTMLとCSSセレクタを同時に出力してくれないのであまりうれしくありません。

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
require "capybara/rspec"

class MyRackApplication
  def call(env)
    html = <<-HTML
<html>
  <head>
    <title>Welcome! - my site</title>
  </head>
  <body>
    <h1>Welcome!</h1>
    <div class="header">
      <ul>
        <li><a href="/">Top</a></li>
      </ul>
    </div>
  </body>
</html>
HTML
   [200, {"Content-Type" => "text/html"}, [html]]
  end
end

Capybara.app = MyRackApplication.new

describe MyRackApplication, :type => :request do
  it "should have header content" do
    visit("/")
    page.should have_selector(".header p")
    find(".header p").text.should == "No navigation."
  end
end

実行すると以下のようにCSSセレクタのみが出力されます。

% rspec capybara_spec.rb
F

Failures:

  1) MyRackApplication should have header content
     Failure/Error: page.should have_selector(".header p")
       expected css ".header p" to return something
     # ./capybara_spec.rb:29:in `block (2 levels) in <top (required)>'

Finished in 0.60582 seconds
1 example, 1 failure

Failed examples:

rspec ./capybara_spec.rb:27 # MyRackApplication should have header content

ということで、この方法は「失敗したときだけ必要な情報を出力して原因を素早く見つけたい」という期待する結果を実現できません。メッセージの中にsourceとCSSセレクタを指定すれば実現できなくもありませんが、テストが書きづらくなってしまうため割にあいません。

save_and_open_browserでブラウザで確認

include Capybara::DSLするとsave_and_open_browserメソッドが追加され、アプリケーションが返した生のHTMLをブラウザで開いて確認することができます*1

1
2
3
4
5
6
7
8
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_header
    visit("/")
    save_and_open_page
    assert_equal("No navigation.", find(".header p").text)
  end
end

ブラウザでHTMLを確認することによりFirebugなどHTMLの構造を視覚的に確認することができます。確認してどうしてCSSセレクタ".header p"がマッチしなくなったのかを考えます。

この方法もテスト自体を書き換える必要があるため、テストが失敗したらテストを修正してもう一度実行し直さなければいけません。テストがパスするようになったら変更を元に戻すことも忘れてはいけません。元に戻さないままコミットしてしまうと、他の開発者の環境でもテストを実行するたびにブラウザが起動してしまいます。

どの方法がよいか

ブラウザで確認する方法が視覚的で一番わかりやすいですが、ページの内容が増えてくるとページ全体から問題の箇所を素早く探すのは大変です。一方、コンソールにHTMLを出力する方法は視覚的ではありませんが、HTMLをテキストとして検索することができるところは便利です。しかし、やはりページ全体から問題の箇所を素早く探すのは大変です。

小規模のWebアプリケーションでもそこそこのHTMLになり、どの方法でもページ全体から問題の箇所を素早く探すのは大変です。ということで、どの方法も今一歩と言えます。

ページ全体ではなくする

どうして問題の箇所を素早く探すのが大変かというと、探索範囲が広いからです。探索範囲が狭くなれば素早く見つけやすくなります。ついでに言うと、テストの書きやすさを損なわずにできるだけ自然に狭くしたいという希望もあります。

ところで、Capybaraにはwithinというメソッドがあることを知っていますか?これを使うと検索範囲を限定できます。例えば、以下は同じ意味になります。

1
2
3
4
5
6
# これまでの書き方
find(".header p").text
# withinを使った書き方
within(".header") do
  find("p").text
end

withinを使ってテストを書きなおしてみましょう。

1
2
3
4
5
6
7
8
9
class TestMyRackApplication < Test::Unit::TestCase
  # ...
  def test_header
    visit("/")
    within(".header") do
      assert_equal("No navigation.", find("p").text)
    end
  end
end

テストを実行するとfindのところで失敗するのですが、この時点では".header"はマッチすることがわかっています。つまり、findはHTML全体ではなく、以下のHTML断片内で"p"がマッチすることを期待しています。

1
2
3
4
5
<div class="header">
  <ul>
    <li><a href="/">Top</a></li>
  </ul>
</div>

このくらいの量であればコンソールに出力されても解析しやすいでしょう。少なくとも「<p>はないが<ul>はある」ということはすぐにわかります。このヒントがあれば原因を特定するのにだいぶ役立つはずです。

では、withinのブロック内でマッチしなかった場合に現在マッチしたノードのHTMLを出力するにはどうしたらよいでしょうか。これを実現できると、失敗した時だけ必要最低限の情報を得られて素早く原因を見つけられそうです。しかもCapybaraの自然な使い方です。

test-unit-capybara

test-unit 2でCapybaraを便利に使うためのtest-unit-capybaraというライブラリがあります。これを使えば、これまで通りCapybaraの作法で書くだけで必要な情報を過不足なく表示してくれます。以下のようにrequire "test/unit/capybara"とするだけでtest-unit-capybaraを使えます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
gem "test-unit"
require "test/unit/capybara"
# require "test/unit"
# require "capybara/dsl"
# ...
class TestMyRackApplication < Test::Unit::TestCase
  include Capybara::DSL

  def setup
    Capybara.app = MyRackApplication.new
  end

  # ...

  def test_header
    visit("/")
    within(".header") do
      assert_equal("No navigation.", find("p").text)
    end
  end
end

実行すると以下のようになります。

% ruby test-capybara.rb
Loaded suite test-capybara
Started
F
===============================================================================
Failure:
test_header(TestMyRackApplication)
    [/var/lib/gems/1.9.1/gems/test-unit-capybara-1.0.1/lib/test/unit/capybara.rb:77:in `raise_find_error_for_test_unit'
...
     test-capybara.rb:40:in `test_header']:
<"p">(:css) expected to find a element in
<<div class="header">
      <ul>
<li><a href="/">Top</a></li>
      </ul>
</div>>
===============================================================================
...

探索範囲であるHTML断片<div class="header">...</div>とCSSセレクタ"p"が表示されています。これがわかれば素早く原因を見つけられますね。

最近のCapybaraはfindメソッドでノードを見つけられなかったときの挙動をカスタマイズできます*2。test-unit-capybaraはその機能を使って必要な情報を収集して表示しています。同様のことは他のテスティングフレームワークでも実現できるでしょう。

まとめ

現状のテストの書き方・ツールではHTMLに対するテストが失敗したときの原因を素早く見つけることが困難であることを示しました。また、解決方法としてwithinを使って「注目しているノードを明示」し、テストツールとしてtest-unit-capybaraを使う方法を紹介しました。この方法は他のテスティングフレームワークでも実現できる一般的な方法です。

インターフェイスの改良や文言の変更などでHTMLのテストが失敗することはよくあることです。そんなときもすぐにどこが変わったかに気付けるようなテストだと変更を嫌がらずによいものを作ることに専念できますよね。

あわせて読みたい: デバッグしやすいassert_equalの書き方

おまけ: assert_match問題

ここで取り上げた問題は「問題があるだろう範囲が広すぎて問題を発見することが困難になる」ことが原因です。同様の問題はtest-unitのassert_match、RSpecのshould matchにもあります。

以下はUser-Agentが任意のバージョンのbingbotであることをテストしています。このテストは失敗するのですが、どうして失敗するかすぐにわかるでしょうか?

1
2
3
4
5
6
7
def assert_bingbot(user_agent)
  assert_match(/;\s+bingbot\/[\d]+;/, user_agent)
end

def test_bot
  assert_bingbot("Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)")
end

正規表現は少し間違えて書いてしまうとどこが間違えているのか見つけることが大変です。これを防ぐために、assert_matchではなく、以下のようにマッチ対象を正規化してからassert_equalで比較することをオススメします。

1
2
3
4
5
6
7
8
def assert_bingbot(user_agent)
  assert_equal("Mozilla/5.0 (compatible; bingbot/XXX; +http://www.bing.com/bingbot.htm)",
               user_agent.gsub(/bingbot\/[\d]+/, "bingbot/XXX"))
end

def test_bot
  assert_bingbot("Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)")
end

assert_equalだと以下のようにdiffがでるので何が悪かったのかがすぐにわかります。

===============================================================================
Failure:
test_bot(TestMyRackApplication)
    [test-capybara.rb:50:in `assert_bingbot'
     test-capybara.rb:55:in `test_bot']:
<"Mozilla/5.0 (compatible; bingbot/XXX; +http://www.bing.com/bingbot.htm)"> expected but was
<"Mozilla/5.0 (compatible; bingbot/XXX.0; +http://www.bing.com/bingbot.htm)">

diff:
? Mozilla/5.0 (compatible; bingbot/XXX.0; +http://www.bing.com/bingbot.htm)
===============================================================================

バージョン番号を検出しようとしていた[\d]+が「.」を考慮していないことが原因ですね。

*1 別途launchy gemが必要なことに注意。

*2 raise_find_errorメソッドをオーバーライドする

タグ: Ruby | テスト
2012-01-18

デバッグしやすいassert_equalの書き方

デバッグしやすいassert_equalの書き方とデバッグしにくいassert_equalの書き方があるのは知っていますか?*1

デバッグしやすいassert_equalの書き方を2パターン紹介します。

まとめたassert_equal

まず、1つ目のよくみるデバッグしにくいassert_equalの書き方です。

1
2
3
4
5
def test_parse
  assert_equal(29, parse_integer("29"))   # (1)
  assert_equal(29, parse_integer("+29"))  # (2)
  assert_equal(-29, parse_integer("-29")) # (3)
end

これがデバッグしにくいのは、(1)が失敗したら(2)、(3)が実行されないからです。すべてのassert_equalが実行されて、どのassert_equalが失敗したかを確認することでバグの傾向を見つけやすくなります。

例えば…(1)だけが失敗するなら符号が無い場合の処理が怪しいでしょう。(3)だけが失敗するなら、マイナス記号に対応していないのかもしれません。全部失敗するなら根本的におかしいのでしょう。

このように、どのassert_equalが失敗したかがデバッグのヒントになります。よって、↑のような書き方はデバッグがしにくいassert_equalの書き方といえます。

デバッグしやすく書くと、例えば、こうなります。

1
2
3
4
5
6
7
8
9
10
11
def test_parse_no_sign
  assert_equal(29, parse_integer("29"))
end

def test_parse_plus_sign
  assert_equal(29, parse_integer("+29"))
end

def test_parse_mius_sign
  assert_equal(-29, parse_integer("-29"))
end

まとめないassert_equal

続いて、1つ目のよくみるデバッグしにくいassert_equalの書き方です。

1
2
3
4
5
def test_parse
  bob = MyJSONParser.parse('{"name": "bob", "age": 29}')
  assert_equal("bob", bob["name"])
  assert_equal(29, bob["age"])
end

1つ目の書き方のように複数のassert_equalが書いてあります。1つ目の書き方と同じように直すならこうなります。

1
2
3
4
5
6
7
8
9
def test_parse_string_value
  bob = MyJSONParser.parse('{"name": "bob", "age": 29}')
  assert_equal("bob", bob["name"])
end

def test_parse_numeric_value
  bob = MyJSONParser.parse('{"name": "bob", "age": 29}')
  assert_equal(29, bob["age"])
end

でも、本当にこれでよいでしょうか。この書き方では値の型だけを注目しています。値の型に注目するならば以下のように書いた方がよいでしょう。こうした方が余計なものがなくなり、より注目できています。

1
2
3
4
5
6
7
8
9
def test_parse_string_value
  bob = MyJSONParser.parse('{"name": "bob"}')
  assert_equal("bob", bob["name"])
end

def test_parse_numeric_value
  anonymous = MyJSONParser.parse('{"age": 29}')
  assert_equal(29, anonymous["age"])
end

もし、最初のコードが「複数のキーと値をパースできること」を確認したい場合はassert_equalを複数のテストに分割するのではなく、複数のassert_equalを1つのassert_equalにまとめるべきです。

1
2
3
4
5
def test_parse
  bob = MyJSONParser.parse('{"name": "bob", "age": 29}')
  assert_equal({"name" => "bob", "age" => 29},
               bob)
end

1つのassert_equalにまとめると、失敗する場合でも、一部の比較だけが実行されるのではなく、すべての比較が実行されます。そのため、"name"はパースに成功しているけど"age"は失敗している、というように、失敗の傾向がわかります。失敗の傾向がわかるとデバッグしやすくなるというのは前述の通りです。

おまけ: Hashのassert_equal結果が見づらい

ところで、Hashのassert_equalの結果は見づらいですよね。これは、RSpecでも一緒です。

1
{"age" => 30, "name" => "bob"}.should == {"name" => "bob", "age" => 29}

この結果は以下のようになります。

expected: {"name"=>"bob", "age"=>29}
     got: {"age"=>30, "name"=>"bob"} (using ==)
Diff:
@@ -1,2 +1,2 @@
-{"name"=>"bob", "age"=>29}
+{"age"=>30, "name"=>"bob"}

一見しただけではどこが違うのかわかりません。

しかし、最新のtest-unit 2は一味違います。

1
2
assert_equal({"name" => "bob", "age" => 29},
             {"age" => 30, "name" => "bob"})

この結果は以下のようになります。

<{"age"=>29, "name"=>"bob"}> expected but was
<{"age"=>30, "name"=>"bob"}>

diff:
? {"age"=>29, "name"=>"bob"}
?         30

違いがわかりやすいですね。

まとめ

デバッグしやすいassert_equalの書き方を2パターン紹介しました。今度から、assert_equalを書くときは、単に数を増やしたりするのではなく、どうすれば有用なassert_equalになるかを考えてみてください。assert_equalが失敗したときにいかにデバッグしやすいかがヒントになるはずです。

*1 assert_equalではなくて、should ==と読み替えてもよい。

タグ: Ruby | テスト
2011-02-28

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|
タグ: