モノノフ日記

普通の日記です

Jobeet - 8日目: ユニットテスト

Day 8: The Unit Tests (1_2) - Symfony

前回までのJobeet

週末にかけて、アドベントカレンダーの最初の5日間で学んだJobeetの要素をカスタマイズしたり新しい要素を追加したりする全ての要素を復習しました。そのプロセスでsymfonyが持つその他の拡張機能にも触れました。
本日は、全く別物であるテストの自動化について話します。テストの話題はかなり大きいので、2日間かけて全てのことがカバーできるようにしていきます。

symfonyでのテスト

symfonyで自動化されたテストは2種類あります。ユニットテストと機能テストです。
ユニットテストは各メソッドや関数が正しく動作するかを検証します。各テストは他のテストとできるだけ非依存にしなければなりません。
一方、機能テストはアプリケーションの結果が全体として正しいのかを検証します。
symfonyの全てのテストはプロジェクトのtestディレクトリに位置されます。そこは2つのサブディレクトリを持っていて、1つはユニットテスト用(test/unit/)、もう1つは機能テスト用(test/functional/)です。
今日のチュートリアルではユニットテストを取り上げ、機能テストは明日集中してやります。

ユニットテスト

ユニットテストを書く事はWeb開発のベストプラクティスの中で最も辛いことの1つです。Web開発者は実際には自分が書いたコードをテストすることには不慣れなので、たくさんの疑問が生じます。実装する前にテスト書かなければならないのはなぜ?テストの必要性って何?テストは一つ一つの細かいケースにも対応するのか?全てのテストが上手くいくにはどうやったらよいのか?しかし大抵、最初の疑問がほとんどの基礎となります。何から話せばよいでしょうか?
たとえテストを強く主張したとしても、symfonyのアプローチは実利的です。全くテストが無いよりはいくつかのテストがあった方がよいということを常に示します。すでにテストをしていないたくさんのコードがありますか?心配いりません。テストを持つ利点から恩恵を受けるための完全なテストスイートは必要ありません。コード内にバグを見つけたときはすぐにテストに追加しましょう。やがてコードは良くなっていき、コードカバレッジも上昇し、より信頼の高いコードになっているでしょう。実際のアプローチをしていくことによって、もっとテストに満足していることでしょう。次のステップでは新しい要素に対し、テストを記述します。すぐにテスト中毒になると思います。
ほとんどのテストライブラリの問題点は習得が難しいことです。symfonyではlimeという非常にシンプルなテストライブラリを提供するので非常に簡単にテストを記述できます。

このチュートリアルではlimeライブラリを大々的に説明していますが、PHPUnitのような別のテストライブラリも使えます。

limeテストフレームワーク

limeフレームワークで書かれた全てのユニットテストは同じコードで始まります。

require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(1, new lime_output_color());

最初に、bootstrapファイルであるunit.phpで初期化処理を行います。そして新しいlime_testオブジェクトを生成し、引数として渡したテスト回数だけ実行します。

テストプランは実行するテストが少ないケースでlimeでエラーメッセージを出力することを認めます。(例えば、テストでPHP fatalエラーを生成するとき)

テストが動作すると所定の入力とセットでメソッドや関数が呼ばれ、予想される出力と比較します。この比較でテストが合格か不合格が決まります。
比較するのを楽にするため、lime_testオブジェクトはいくつかのメソッドを提供します。

メソッド 説明
ok($test) Tests a condition and passes if it is true
is($value1, $value2) Compares two values and passes if they are
isnt($value1, $value2) equal (==)
Compares two values and passes if they are
like($string, $regexp) not equal
Tests a string against a regular expression
unlike($string, $regexp) Checks that a string doesn't match a regular
is_deeply($array1, $array2) expression
Checks that two arrays have the same values

全てのテストはok()メソッドを使って書くことが可能であるのに、多くのテストメソッドが定義されていることを不思議に思うかもしれません。別のメソッドが定義されている利点はテストが失敗したケースでのエラーメッセージがあいまいでなくなったり、テストの可読性が向上することです。

lime_testオブジェクトは他の便利なテストメソッドも提供します。

メソッド 説明
fail() Always fails--useful for testing exceptions
pass() Always passes--useful for testing exceptions
skip($msg, $nb_tests) Counts as $nb_tests tests--useful for conditional
todo() tests
Counts as a test--useful for tests yet to be

最後に、テストが実行されないケース以外のときcomment($msg)メソッドでコメントを出力します。

ユニットテストの実行

全てのユニットテストは test/unit/ ディレクトリ以下に保存されます。仕様により、テストはクラス名の後にTextを接尾語とします。
test/unit/ ディレクトリ下にファイルを準備していても、lib/ ディレクトリの構造を複製するのを薦めます。
test/unit/JobeetTest.phpファイルを作り、下記コードをコピーしてください。

<?php
// test/unit/JobeetTest.php
require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(1, new lime_output_color());
$t->pass('This test always passes.');

テストを開始するには、ディレクトリ内のファイルを実行します。

$ php test/unit/JobeetTest.php

または、test:unitタスクを使います。

$ symfony test:unit Jobeet

f:id:Kiske:20081218194450p:image

Windowsのコマンドラインは不幸なことにテスト結果を緑や赤でハイライトしません。

slugifyのテスト

Jobeet::slugify()メソッドのテストを書くことでユニットテストの素晴らしい旅を始めましょう。
5日目にURLに安全な文字を含めるように整形するslugify()メソッドを作りました。その整形は全ての非ASCII文字をダッシュ(-)へ変換したり、小文字に変換するような基本的な変換から構成されます。

入力 出力
Sensio Labs sensio-labs
Paris,France paris-france

テストファイルのコンテンツを下記コードに置き換えます。

<?php
// test/unit/JobeetTest.php
require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(6, new lime_output_color());
 
$t->is(Jobeet::slugify('Sensio'), 'sensio');
$t->is(Jobeet::slugify('sensio labs'), 'sensio-labs');
$t->is(Jobeet::slugify('sensio   labs'), 'sensio-labs');
$t->is(Jobeet::slugify('paris,france'), 'paris-france');
$t->is(Jobeet::slugify('  sensio'), 'sensio');
$t->is(Jobeet::slugify('sensio  '), 'sensio');

記述されたテストをよく見ると、各行に1つのテストだけで構成されているのに気づくでしょう。このことはユニットテストを記述する際、留意する必要があります。テストは1回ずつ実行されます。

テストファイルを実行できます。もし全てのテストに合格したなら、期待している"グリーンバー"が表示されます。もし不合格ならば、悪名高き"レッドバー"が不合格したテストと対策について警告するでしょう。
f:id:Kiske:20081218195614p:image

もしテストが失敗したなら、失敗した理由についての情報を出力から得られます。しかし1ファイルで何百というテストをしていると、失敗した動作を素早く認識するのは難しくなります。
全てのlimeテストのメソッドは最後の引数にテストの解説として役割を持つ文字列を持ちます。説明書きを強制するのは実際にテストをするときには非常に便利です。メソッドに期待している動作のドキュメントとしての役割も持ちます。slugifyテストファイルにメッセージを追加していきましょう。

<?php
require_once dirname(__FILE__).'/../bootstrap/unit.php';
 
$t = new lime_test(6, new lime_output_color());
 
$t->comment('::slugify()');
$t->is(Jobeet::slugify('Sensio'), 'sensio', '::slugify() converts all characters to lower case');
$t->is(Jobeet::slugify('sensio labs'), 'sensio-labs', '::slugify() replaces a white space by a -');
$t->is(Jobeet::slugify('sensio   labs'), 'sensio-labs', '::slugify() replaces several white spaces by a single -');
$t->is(Jobeet::slugify('  sensio'), 'sensio', '::slugify() removes - at the beginning of a string');
$t->is(Jobeet::slugify('sensio  '), 'sensio', '::slugify() removes - at the end of a string');
$t->is(Jobeet::slugify('paris,france'), 'paris-france', '::slugify() replaces non-ASCII characters by a -');

f:id:Kiske:20081218195615p:image

テストの解説はテストのことを理解しようとするときに有効な手段になります。テストごとに文字列パターンが見れます。それら文字列はメソッドがどのような動作をするかについて説明した文であり、メソッド名からいつも始まっています。

コードカバレッジ
テストを書くとき、コードの一部に対するテストを忘れるのは簡単です。
テストされる全てのコードをチェックするのを助けるため、symfonyはtest:coverageタスクを提供します。引数として指定するテストファイルやディレクトリ、libファイルやディレクトリに対しこのタスクを実行すると、コードに対するコードカバレッジを教えてくれます。

$ symfony test:coverage test/unit/JobeetTest.php lib/Jobeet.class.php

もしテスト内でカバーされていない行を知りたいのであれば、--detailedオプションを使います。

$ php symfony test:coverage --detailed test/unit/JobeetTest.php lib/Jobeet.class.php

タスクがコードが完全にユニットテストされていると表示したとき、各行が実行されたということを意味しますが、全てのエッジケースがテストされた訳ではないということを留意してください。
test:coverageタスクは情報を収集するのにXDebugに頼っているので、最初にXdebugをインストールして利用可能にする必要があります。

新しい要素のための追加テスト

slugify()での空文字は普通の空文字として扱われます。テストすると動作します。しかしURLの中に空文字があるのはあまり良いことではありません。空文字の場合には"n-a"という文字を返すようにslugify()メソッドを変更しましょう。
テストを最初に書いて、そのあとメソッドを更新します。その逆からの場合もあります。どちらでやるのかは好みの問題ですが、テストを最初に書くことで計画していた通りコードが実装されているという確信を得ることができます。

<?php
$t->is(Jobeet::slugify(''), 'n-a', '::slugify() converts the empty string by n-a');

もし今からテストを開始するなら、レッドバーになるに違いありません。もしレッドバーにならないのであれば、要素はすでに実装されているか、テストされなければいけないテストが実行されていないかということを意味します。
さて、Jobeetクラスを編集して、先頭に下記コードを追加します。

<?php
// lib/Jobeet.class.php
static public function slugify($text)
{
  if (empty($text))
  {
    return 'n-a';
  }
 
  // ...
}

テストは予想通りに合格していることでしょう、しかしテストプランを更新するのを覚えていた場合に限られています。そうでなければ予定されていた6つのテストと1つの余分なテストに対するメッセージが表示されます。テストスクリプトが早々に使えなくなっているかどうかという情報を確認しつづけるためにも、最新のプランニングされたテストの数をカウントしておくことは重要です。

バグのテストのための追加テスト

テストに合格、または変わったバグがレポートされてきたとしましょう。いくつかの仕事ページへのリンクは404エラーページを指しています。調査後、その理由のいくつかがわかります。それらの仕事は会社名や役職、就業場所のslugが空です。どうやればいいですか?
データベースや空でないカラムの記録を調べます。少しの間考えてみれば、原因を見つけるこ
とができます。文字列に非ASCII文字のみで構成されているとき、slugify()メソッドは空文字に変換します。原因を見つけたことはうれしいでしょうが、すぐにJobeetクラスを開いて、問題箇所を修正します。それはまずい考え方です。最初にテストを追加しましょう。

<?php
$t->is(Jobeet::slugify(' - '), 'n-a', '::slugify() converts a string that only contains non-ASCII characters by n-a');

f:id:Kiske:20081219135605p:image

テストが合格しないことを確認した後、Jobeetクラスを編集し空文字のチェックをコードの最後に持ってきます。

<?php
static public function slugify($text)
{
  // ...
 
  if (empty($text))
  {
    return 'n-a';
  }
 
  return $text;
}

これで他のテストが合格するように新しいテストも合格するようになります。slugify()は100%のカバレッジにもかかわらずバグを持っていました。
テストを記述するとき、全てエッジケースを想定することはできません、それでいいんです。しかし動作しないケースを見つけたら、コードを修正する前にテストを書く必要があります。常にテストを書くという良い意識を持つことでコードがだんだんよくなっていきます。

Propelユニットテスト

データベースの設定

Propelモデルクラスのユニットテストはデータベースへの接続が必要となるのでもう少し複雑です。すでに開発でデータベースを利用していますが、テスト用に専用のデータベースを作ることは良い習慣です。
1日目で、アプリケーションの設定で変更できる環境について紹介しました。デフォルトではsymfonyの全てのテストはtest環境で実行されます。そうであるため、test環境には異なるデータベースを使うよう設定しましょう。

$ symfony configure:database --env=test "mysql:host=localhost;dbname=jobeet_test" root mYsEcret

envオプションはタスクにデータベース設定はtest環境のみを使うということを指示します。3日目でこのタスクが使われたとき、envオプションは使いませんでした。その場合設定は全ての環境に適用されます。

もし気になるのであれば、config/database.ymlファイルを開けばsymfonyがどうやって簡単に環境依存設定を切り替えているのかを見ることができます。

データベースの設定ができたので、propel:insert-sqlタスクを使いブートストラップします。

$ mysqladmin -uroot -pmYsEcret create jobeet_test
$ symfony propel:insert-sql --env=test

symfonyの設定の考え方
4日目で異なったレベルで定義された設定ファイルを見ました。
これらの設定は環境非依存です。今まで使われていたほとんどの設定ファイルに対し当てはまります。database.yml, app.yml, view.yml, setting.ymlファイルなどです。これらの全てのファイルでメインキーは環境設定であり、設定で表示しているallキーは全ての環境に対し適用されます。

# config/databases.yml
dev:
  propel:
    class: sfPropelDatabase
    param:
      classname: DebugPDO
 
test:
  propel:
    class: sfPropelDatabase
    param:
      classname: DebugPDO
      dsn: 'mysql:host=localhost;dbname=jobeet_test'
 
all:
  propel:
    class: sfPropelDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet'
      username: root
      password: null
テストデータ

テスト用の専用データベースがあるので、テストデータを読み込ませる必要があります。3日目でpropel:data-loadタスクを使うことを学びました。しかしテストでは、実行されるたびにデータベースを既知の状態にする必要があります。propel:data-loadタスクはデータを読み込むために内部ではsfPropelDataクラスを使います。

<?php
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');

sfConfigオブジェクトはプロジェクトのサブディレクトリの完全パスを得ようとします。デフォルトのディレクトリ構造をカスタマイズして使うこともできます。

loadData()メソッドは第1引数にディレクトリかファイルが指定されます。ディレクトリやファイルの配列も受け取ることができます。
すでにdata/fixtures/ディレクトリに初期データは生成しています。テスト用にこれらのfixturesを使います。このfixturesはPropelのユニットテストや機能テストでよく使われます。
それでは、data/fixtures/のファイルをtest/fixtures/ディレクトリへコピーしましょう。

JobeetJobのテスト

JobeetJobモデルクラスのユニットテストを作りましょう。
Propelユニットテストは全て同じコードで始まるので、bootstrap/ディレクトリに下記コードを記述したPropel.phpファイルを作っておきましょう。

<?php
// test/bootstrap/Propel.php
include(dirname(__FILE__).'/unit.php');
 
$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'test', true);
 
new sfDatabaseManager($configuration);
 
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');

スクリプトはきれいな自己解説をしています。

  • フロントコントローラに対し、test環境用の設定でオブジェクトを初期化します。
$configuration = ProjectConfiguration::getApplicationConfiguration('frontend', 'test', true);
  • データベースマネージャを作ります。database.ymlファイルを読み込み、Propelの接続を初期化します。
new sfDatabaseManager($configuration);
  • sfPropelDataを使ってテストデータを読み込みます。
$loader = new sfPropelData();
$loader->loadData(sfConfig::get('sf_test_dir').'/fixtures');

PropelはSQL文が実行されるときに限り、データベースへ接続します。

これで全ての準備が整ったので、JobeetJobクラスのテストが始めることができます。
最初に、JobeetJobTest.phpファイルをtest/unit/model内に作ります。

<?php
// test/unit/model/JobeetJobTest.php
include(dirname(__FILE__).'/../../bootstrap/Propel.php');
 
$t = new lime_test(0, new lime_output_color());

それから、getCompanySlug()メソッドのテストを追加します。

<?php
$t->comment('->getCompanySlug()');
$job = JobeetJobPeer::doSelectOne(new Criteria());
$t->is($job->getCompanySlug(), Jobeet::slugify($job->getCompany()), '->getCompanySlug() return the slug for the company');

getCompanySlug()メソッドをテストするだけで、他のメソッドをテストするようにslugifyが正しいかそうでないかをわかるでしょう。
少し複雑なsave()メソッドのテストを記述します。

<?php
$t->comment('->save()');
$job = create_job();
$job->save();
$expiresAt = date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days'));
$t->is($job->getExpiresAt('Y-m-d'), $expiresAt, '->save() updates expires_at if not set');
 
$job = create_job(array('expires_at' => '2008-08-08'));
$job->save();
$t->is($job->getExpiresAt('Y-m-d'), '2008-08-08', '->save() does not update expires_at if set');
 
function create_job($defaults = array())
{
  static $category = null;
 
  if (is_null($category))
  {
    $category = JobeetCategoryPeer::doSelectOne(new Criteria());
  }
 
  $job = new JobeetJob();
  $job->fromArray(array_merge(array(
    'category_id'  => $category->getId(),
    'company'      => 'Sensio Labs',
    'position'     => 'Senior Tester',
    'location'     => 'Paris, France',
    'description'  => 'Testing is fun',
    'how_to_apply' => 'Send e-Mail',
    'email'        => 'job@example.com',
    'token'        => rand(1111, 9999),
    'is_activated' => true,
  ), $defaults), BasePeer::TYPE_FIELDNAME);
 
  return $job;
}

テストが追加されるごとに、lime_testコンストラクタで実行されるテスト数を更新することを忘れないようにしてください。JobeetJobTestファイルでは0から3回に変更する必要があります。

他のPropelクラスのテスト

その他のPropelクラス全てにテストを追加できます。ユニットテストを書くプロセスになれてきたなら、非常に簡単になります。生成したfixturesファイルを見てみたいのであれば、今日のSVNリポジトリをチェックしてください

ユニットテストの利用

test:unitタスクはプロジェクトにおける全てのユニットテストを開始するのに使われます。

$ symfony unit:test

タスクは各テストファイルが合格かそうでないかを出力します。
f:id:Kiske:20081219145842p:image