最近の2日間の間に、アドベントカレンダーの最初の5日の間に学んだJobeetの機能をカスタマイズして新しい機能を追加するために、すべての機能をレビューしました。 このプロセスにおいて、symfonyのより高度な機能にもふれてきました。
今日は、完全に異なる内容: 自動化されたテストを語ります。 このトピックの内容はとても大きいので、すべての内容をカバーするのに2日まるごとかかります。
symfonyのテスト
symfonyにはまったく異なる2つの種類の自動テスト: ユニットテスト(unit test - もしくは単体テスト)と機能テスト(functional test)があります。
ユニットテストはそれぞれのメソッドと関数が適切に機能していることを検証します。 それぞれのテストは可能な限りお互いから独立していなければなりません。
一方で、機能テストはアプリケーションの結果のふるまいが全体として正しいか検証します。
symfonyのすべてのテストはプロジェクトのtest/
ディレクトリに設置されます。
ユニットテスト(test/unit/
)と機能テスト(test/functional/
)用に2つのサブディレクトリが含まれます。
今日はユニットテストをカバーし、明日は機能テストに専念します。
ユニットテスト
ユニットテストを書くのはWeb開発のベストプラクティスの中で実行するのがもっとも難しいことです。 Web開発者は作品をテストすることに本当に慣れていないのと、たくさんの疑問がわき上がります: 機能を実装する前にテストを書かなければならないのか? 何をテストする必要があるのか? テストはすべての単独のエッジケースをカバーする必要があるのか? すべてにおいてよいテストをできる方法は? しかし通常、最初のテストははるかに基本的です: どこで始めるのか?
私たちがテストを強く推奨しているとしても、symfonyのアプローチは実践的です: テストを何もしないよりもしたほうが常によいです。 テストなしのコードがすでにたくさんありますか? 問題ありません。テストの利点から恩恵を受けるためにフルテストスイートを用意する必要はありません。 コードでバグを見つけたときにテストを追加することから始めます。時間が経過して、あなたのコードはよりよいものになり、コードカバレッジは上昇し、テストにより自信を持つようになります。 実践的なアプローチを始めることで、 時間とともにテストがより快適になります。 次のステップは新しい機能に対してテストを書くことです。 すぐに、テストがやみつきになりますよ。
たいていのテストライブラリの問題は急激な学習曲線です。 テストを書く作業を簡単にするためにsymfonyがとてもシンプルなテストライブラリであるlimeを提供するのはそういうわけです。
note
このチュートリアルが組み込みのlimeライブラリを広範囲で説明していても、PHPUnitライブラリのような、優れたテストライブラリを利用できます。
lime
テストフレームワーク
limeフレームワークで書かれたユニットテストは同じコードで始まります:
require_once dirname(__FILE__).'/../bootstrap/unit.php'; $t = new lime_test(1, new lime_output_color());
最初に、いくつかのことを初期化するためにunit.php
ブートストラップファイルがインクルードされます。
それから、新しいlime_test
オブジェクト作成され、立ち上げを計画しているテストの数が引数として渡されます。
note
計画によってlimeは実行されるテストの数が少なすぎるときにメッセージを出力できます(たとえばテストがPHPの致命的エラーを生成するとき)。
あらかじめ定義される入力のセットでメソッドもしくは関数を呼び出し期待される出力で結果を比較することでテストは動作します。 この比較はテストが通るもしくは失敗かどうかを決定します。
比較しやすくするために、lime_test
オブジェクトはいくつかのメソッドを提供します:
メソッド | 説明 |
---|---|
ok($test) |
条件をテストしてtrueであれば通る |
is($value1, $value2) |
2つの値を比較してそれらが等しい(== )場合に通る |
isnt($value1, $value2) |
2つの値を比較しそれが等しくない場合に通る |
like($string, $regexp) |
文字列を正規表現でテストする |
unlike($string, $regexp) |
文字列が正規表現にマッチしないことをチェックする |
is_deeply($array1, $array2) |
2つの配列が同じ値を持っていることをチェックする |
tip
ok()
メソッドだけを使ってすべてのテストを書けるのに、limeがこんなにたくさんのテストを定義するのか疑問に思っているかもしれません。
代替メソッドの利点は失敗したテストの明確なエラーメッセージと改善されたテストの可読性にあります。
lime_test
オブジェクトは他の便利なテストメソッドも提供します:
メソッド | 説明 |
---|---|
fail() |
常に失敗する--例外をテストするのに便利 |
pass() |
常にパスする--例外をテストするのに便利 |
skip($msg, $nb_tests) |
$nb_tests テストとしてカウントする--条件テストに便利 |
todo() |
テストとしてカウントする--まだ書かれていないテストに便利 |
最後に、comment($msg)
メソッドはコメントを出力しますがテストは実行しません。
ユニットテストを実行する
すべてのユニットテストはtest/unit/
ディレクトリに保存されます。
慣習では、テストの名前はテストするクラスの名前にTest
のサフィックスをつけたものです。
ともかくtest/unit/
ディレクトリの下でファイルを編成できますが、lib/
ディレクトリの構造を複製することをお勧めします。
ユニットテストを説明するために、Jobeet
クラスをテストします。
test/unit/JobeetTest.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
タスクを使用する:
$ php symfony test:unit Jobeet
note
不幸にしてWindowsのコマンドラインはテストの結果を赤もしくは緑色でハイライトできません。
slugify
をテストする
Jobeet::slugify()
メソッド用のテストを書くことでユニットテストの世界のすてきな旅を始めましょう。
URLに文字列を安全に含められるようにクリーンナップするために5日目にslugify()
メソッドを作りました。
ASCIIではないすべての文字列をダッシュ(-
)に変換するもしくは文字列を小文字に変換するなどの基本的な変換で構成されます:
入力 | 出力 |
---|---|
Sensio Labs | sensio-labs |
Paris, France | paris-france |
テストファイルの内容を次のコードで置き換えます:
// 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つのことをテストします。
テストファイルを実行できます。 すべてのテストが期待通りにパスする場合、"緑色のバー"を楽しめます。 そうでなければ、悪名高い"赤いバー"がテストが通らなくて、それらを修正するように警告します。
テストが失敗すると、失敗した理由に関する情報が出力されます; しかし1つのファイルで数百のテストがある場合、どれが失敗するふるまいなのか素早く特定するのが困難になる可能性があります。
すべてのlimeテストメソッドは最後の引数としてテストの説明用の文字列を受け取ります。
本当に何をテストしているのか説明することが強制されるのでとても便利です。
メソッドの期待されたふるまいに対してドキュメントの形式でも提供できます。
slugify
テストファイルにメッセージを追加してみましょう:
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 -');
テストの説明文字列は何をテストするのか理解しようとするときにも貴重なツールです。 テストの文字列でパターンがわかります: これらはメソッドがどのようにふるまいテストするメソッドの名前でつねに始まることを記述しています。
新しい機能のためにテストを追加する
空の文字列用のslugは空の文字列です。
これもテスト可能で、動作します。
しかしURLに空文字列を含めるのはよい考えではありません。
空の文字列の場合に"n-a"の文字列を返すようにslugify()
メソッドを変更してみましょう。
テストを最初に書き、メソッドを更新するもしくは逆のことができます。
本当に好みの問題ですが、最初にテストを書くことでコードが計画したものを本当に実装するものに自信を得られます:
$t->is(Jobeet::slugify(''), 'n-a', '::slugify() converts the empty string to n-a');
テストを最初に書いて機能を実装する、この開発の方法論はテスト駆動開発(TDD)として知られます。
テストを立ち上げると、赤いバーが表示されなければなりません。
そうでなければ、機能はすでに実装されたもしくはテストする予定であったものがテストされないことを意味します。
Jobeet
クラスを編集して冒頭部分に次の条件を追加します:
// lib/Jobeet.class.php static public function slugify($text) { if (empty($text)) { return 'n-a'; } // ... }
テストは期待どおりに通らなければならず、そして緑のバーを享受できますがテスト計画を更新することを覚えている場合のみです。 そうでなければ、6つのテストが計画され追加の1つが実行されたことを伝えるメッセージが表示されます。 テストの計画を最新に保つのは重要です。 テストスクリプトが初期に止まる場合に情報の提供が続けられるからです。
バグが原因でテストを追加する
時間が経過しユーザーの1人がうっとおしいバグを報告する場合を考えてみましょう:
求人リンクが404エラーページを指し示します。
調査の後で、これらの求人が空の会社、職、所在地のスラッグなどを持つなど理由を見つけます。
どのように可能なのか?データベースのレコードを調べますがカラムは完全に空ではありません。
しばらく考え、当たりをつけて、原因を見つけます。
文字列が非ASCII文字のみを含む場合、slugify()
メソッドはこれを空の文字列に変換します。
幸いにして原因を発見したら、Jobeet
クラスを開き問題をすぐに直します。
しかしこれはわるいアイディアです。
最初に、テストを追加しましょう:
$t->is(Jobeet::slugify(' - '), 'n-a', '::slugify() converts a string that only contains non-ASCII characters to n-a');
テストがパスしないことをチェックした後で、Jobeet
クラスを編集して空の文字列のチェックをメソッドの末端に移動させます:
static public function slugify($text) { // ... if (empty($text)) { return 'n-a'; } return $text; }
これで、他のすべてのテストのように、新しいテストは通ります。
100%のカバレッジにもかかわらずslugify()
にはバグがありました。
テストを書く際にすべてのエッジケースを考えることはできませんが、それで十分です。 しかしエッジケースが見つかったら、コードを修正する前にテストを書く必要があります。 これは時間の経過と共にあなたのコードがよくなることも意味するので、これは常によいことです。
Doctrineユニットテスト
データベース接続
Doctrineモデルクラスのユニットテストはデータベースの接続が必要なので少し複雑です。 すでにデータベースの接続は開発環境にありますが、テスト専用のデータベースを作るのはよい習慣です。
1日目において、アプリケーションのコンフィギュレーションを変更する方法として環境(environment)を紹介しました。
デフォルトでは、symfonyのすべてのテストはtest
環境で実行されるので、test
環境用に異なるデータベースを設定しましょう:
$ php symfony configure:database --name=doctrine --class=sfDoctrineDatabase --env=test "mysql:host=localhost;dbname=jobeet_test" root mYsEcret
データベースのコンフィギュレーションはtest
環境専用であることをenv
オプションはタスクに伝えます。
3日目でこのタスクを使ったときは、env
オプションを渡しませんでした。
コンフィギュレーションはすべての環境に適用されます。
note
ご興味があれば、config/databases.yml
設定ファイルを開きsymfonyが環境によってコンフィギュレーションを変更する作業をどのように簡単にしているのかご覧ください。
データベースのコンフィギュレーションが終わったので、doctrine:insert-sql
タスクを使ってブートストラップします:
$ mysqladmin -uroot -pmYsEcret create jobeet_test $ php symfony doctrine:insert-sql --env=test
テストデータ
テスト用に専用のデータベースを用意したので、テストデータをロードする方法が必要です。
3日目において、doctrine:data-load
タスクの使い方を学んだとおり、テストに関しては、データベースを既知の状態するためにテストを実行するたびにデータをリロードする必要があります。
doctrine:data-load
タスクは内部でデータをロードするためにDoctrine::loadData()
メソッドを使います:
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
note
sfConfig
オブジェクトはプロジェクトのサブディレクトリのフルパスを得るために使われます。
これを使うことでデフォルトのディレクトリ構造をカスタマイズできます。
loadData()
メソッドは最初の引数としてディレクトリもしくはファイルを受け取ります。
このメソッドはディレクトリかつ/もしくはファイルの配列も受け取ることができます。
data/fixtures/
ディレクトリで初期データをすでに作成しました。
テストに関して、フィクスチャをtest/fixtures/
ディレクトリに設置します。
これらのフィクスチャはDoctrineのユニットテストと機能テストに使われます。
では、data/fixtures/
からtest/fixtures/
ディレクトリにファイルをコピーします。
JobeetJob
をテストする
JobeetJob
モデルクラス用にユニットテストを作りましょう。
Doctrineユニットテストは同じコードで始まるので、bootstrap/
テストディレクトリで次の内容を持つDoctrine.php
ファイルを作ります:
// test/bootstrap/Doctrine.php include(dirname(__FILE__).'/unit.php'); $configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true); new sfDatabaseManager($configuration); Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
スクリプトは自分自身をよく説明しています:
フロントコントローラーに関しては、
test
環境用にコンフィギュレーションオブジェクトを初期化します:$configuration = ProjectConfiguration::getApplicationConfiguration( 'frontend', 'test', true);
データベースマネージャーを作ります。
databases.yml
設定ファイルをロードしてこれはDoctrine接続を初期化します。new sfDatabaseManager($configuration);
Doctrine::loadData()
を使ってテストデータをロードします:Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
note
実行するSQLステートメントが存在する場合、Doctrineはデータベースに接続します。
すべての準備ができたので、JobeetJob
クラスのテストを始めることができます。
最初に、test/unit/model
のJobeetJobTest.php
ファイルを作る必要があります:
// test/unit/model/JobeetJobTest.php include(dirname(__FILE__).'/../../bootstrap/Doctrine.php'); $t = new lime_test(1, new lime_output_color());
それから、getCompanySlug()
メソッドでテストを追加して始めましょう:
$t->comment('->getCompanySlug()'); $job = Doctrine::getTable('JobeetJob')->createQuery()->fetchOne(); $t->is($job->getCompanySlug(), Jobeet::slugify($job->getCompany()), '->getCompanySlug() return the slug for the company');
任意の場所ですでにテストしているので、スラッグが正しくなければ、getCompanySlug()
メソッドのみをテストします。
save()
メソッド用のテストを書くことは微妙に難しいです:
$t->comment('->save()'); $job = create_job(); $job->save(); $expiresAt = date('Y-m-d', time() + 86400 * sfConfig::get('app_active_days')); $t->is(date('Y-m-d', strtotime($job->getExpiresAt())), $expiresAt, '->save() updates expires_at if not set'); $job = create_job(array('expires_at' => '2008-08-08')); $job->save(); $t->is(date('Y-m-d', strtotime($job->getExpiresAt())), '2008-08-08', '->save() does not update expires_at if set'); function create_job($defaults = array()) { static $category = null; if (is_null($category)) { $category = Doctrine::getTable('JobeetCategory') ->createQuery() ->limit(1) ->fetchOne(); } $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)); return $job; }
note
テストを追加するたびに、lime_test
コンストラクターメソッドで 期待されるテストの数(プレーン)を更新することを忘れないでください。
JobeetJobTest
ファイルに関しては、1
から3
に変更する必要があります。
他のDoctrineクラスをテストする
他のすべてのDoctrineクラス用のテストを追加できます。 ユニットテストの過程に慣れつつあるので、とても簡単です。
ユニットテストハーネス
test:unit
タスクはプロジェクトのすべてのユニットテストを起動するためにも使うことができます:
$ php symfony test:unit
タスクはそれぞれのテストファイルがパスするか失敗するかを出力します:
tip
test:unit
タスクがファイルの"あいまいなステータス"を返すとき、これは終わる前に停止したスクリプトを示します。
テストファイルを単独で実行することで正確なエラーメッセージが表示されます。
また明日
アプリケーションのテストが簡単でなくても、今日のチュートリアルをスキップしたい方がいらっしゃるのはわかります。 取り組んでいただけば幸いです。
symfonyを受け入れることはsymfonyが提供するすばらしい機能すべてを学ぶことだけでなく、これはsymfonyが提唱する開発の哲学とベストプラクティスでもあります。 テストはそれらの1つです。遅かれ早かれ、ユニットテストは時間の節約になります。 これらはコードへの確固たる信頼と恐れずにリファクタリングできる自由を与えてくれます。 ユニットテストは何かが壊れているときに警告してくれる安全な護衛です。 symfonyフレームワーク自身には9000以上のテストがあります。
明日はjob
とcategory
モジュール用の機能テストを書きます。
それまでは、Jobeetモデルクラス用のユニットテストをさらに書くための時間をとってください。
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.