Caution: You are browsing the legacy symfony 1.x part of this website.

8日目: ユニットテスト

1.2 / Doctrine

最近の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つのことをテストします。

テストファイルを実行できます。 すべてのテストが期待通りにパスする場合、"緑色のバー"を楽しめます。 そうでなければ、悪名高い"赤いバー"がテストが通らなくて、それらを修正するように警告します。

slugify()テスト

テストが失敗すると、失敗した理由に関する情報が出力されます; しかし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 -');

メッセージつきのslugify()テスト

テストの説明文字列は何をテストするのか理解しようとするときにも貴重なツールです。 テストの文字列でパターンがわかります: これらはメソッドがどのようにふるまいテストするメソッドの名前でつねに始まることを記述しています。

sidebar

コードカバレッジ

テストを書くとき、コードの位置を忘れがちです。

すべてのコードが十分にテストされるのかチェックする作業を手助けするために、symfonyはtest:coverageタスクを提供します。 このタスクにテストファイルもしくはディレクトリとlibファイルもしくはディレクトリを引数として渡せばテストのコードカバレッジが表示されます:

$ php 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~に依存するので、最初にインストールして有効にする必要があります。

新しい機能のためにテストを追加する

空の文字列用の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');

slugify()のバグ

テストがパスしないことをチェックした後で、Jobeetクラスを編集して空の文字列のチェックをメソッドの末端に移動させます:

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

これで、他のすべてのテストのように、新しいテストは通ります。 100%のカバレッジにもかかわらずslugify()にはバグがありました。

テストを書く際にすべてのエッジケースを考えることはできませんが、それで十分です。 しかしエッジケースが見つかったら、コードを修正する前にテストを書く必要があります。 これは時間の経過と共にあなたのコードがよくなることも意味するので、これは常によいことです。

sidebar

よりよいslugifyメソッドに向けて

ご存じかもしれませんがsymfonyはフランス人によって作られました。 ですので"アクセント"を持つフランス語の単語テストを追加してみましょう:

$t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents');

テストは失敗しなければなりません。 éeに置き換える代わりにslugify()メソッドはこれをダッシュ(-)に置き換えます。 これは翻字と呼ばれる難しい問題です。 幸いにして"iconv"がインストールされていれば、私たちの代わりにこの関数がこれらの作業を代行してくれます。 slugifyメソッドのコードを次の内容で置き換えます:

// http://php.vrana.cz/vytvoreni-pratelskeho-url.phpより派生
static public function slugify($text)
{
  // 文字ではないもしくは数値を-に置き換える
  $text = preg_replace('~[^\\pL\d]+~u', '-', $text);
 
  // トリムする
  $text = trim($text, '-');
 
  // 翻字する
  if (function_exists('iconv'))
  {
    $text = iconv('utf-8', 'us-ascii//TRANSLIT', $text);
  }
 
  // 小文字に変換する
  $text = strtolower($text);
 
  // 望まない文字を取り除く
  $text = preg_replace('~[^-\w]+~', '', $text);
 
  if (empty($text))
  {
    return 'n-a';
  }
 
  return $text;
}

UTF-8エンコーディングですべてのPHPファイルを保存することを覚えてください。 これはsymfonyのデフォルトエンコーディングで、翻字を行うために"iconv"によって使われます。

"iconv"が利用可能な場合のみテストを実行するようにテストファイルを変更します:

if (function_exists('iconv'))
{
  $t->is(Jobeet::slugify('Développeur Web'), 'developpeur-web', '::slugify() removes accents');
}
else
{
  $t->skip('::slugify() removes accents - iconv not installed');
}

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

sidebar

symfonyのコンフィギュレーションの原則

4日目において、設定ファイルから由来する設定は異なるレベルで定義できることを見ました。

これらの設定は環境にも依存します。 これはこれまで使ってきたたいていの設定ファイル: databases.ymlapp.ymlview.ymlsettings.ymlにあてはまります。 これらのファイルにおいて、メインキーの値は環境で、allキーはすべての環境用の設定を示します:

# config/databases.yml
dev:
  doctrine:
    class: sfDoctrineDatabase
 
test:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet_test'
 
all:
  doctrine:
    class: sfDoctrineDatabase
    param:
      dsn: 'mysql:host=localhost;dbname=jobeet'
      username: root
      password: null

テストデータ

テスト用に専用のデータベースを用意したので、テストデータをロードする方法が必要です。 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/modelJobeetJobTest.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以上のテストがあります。

明日はjobcategoryモジュール用の機能テストを書きます。 それまでは、Jobeetモデルクラス用のユニットテストをさらに書くための時間をとってください。