続き。
http://eccube.localhost/
でユーザ入口、
http://eccube.localhost/admin/
で管理画面入口になります。
EC-CUBEのインストール時に設定したIDで管理画面に入ることが出来ます。
とりあえずショップ情報など通り一遍の基本情報設定を済ませます。
したらば早速サイトデザインの編集を行います。
ヘッダフッタ、ブロックの編集などは管理画面上から行うことができます。
が、直接テンプレートいじった方が早いのでそうしましょう。
デフォルトのテンプレートは
/data/Smarty/templates/default/フォルダ以下にありますが、
編集や追加を行ったテンプレートは
/html/user_data/packages/default/フォルダに保存されます。
なんでそんな外から見える位置なんだよ。
実際公式に載ってる事例サイトでも直接覗けるところがちょくちょくあったりします。
パスに/default/という文字が入っていますが、EC-CUBEはデザインテンプレートを丸ごと入れ替えることができる機能が付いており、実行した場合は/default/の部分が取り替えた先のテンプレート名に変更になります。
テンプレートの優先順位はuser_data>Smartyで、user_dataディレクトリにカスタムされたテンプレートが無ければSmartyディレクトリのデフォルトテンプレートを読むようになっています。
本来はデフォルトファイルは触らずカスタムファイルを追加するのが正道ですが、デフォルトテンプレート自体を変更したいので直接書き換えてしまいます。
/data/Smarty/templates/default/header.tplが共通ヘッダ、/data/Smarty/templates/default/footer.tplが共通フッタです。
CSSは/html/user_data/packages/default/css/main.cssです。
直接編集するとユーザ画面で即座に反映されるのがわかります。
テンプレートの記法は、デリミタが<!--{、}-->に変更されていますが普通のSmartyなので慣れていれば簡単です。
画像や外部JSなどを貼りたい場合は/html/user_data/packages/default/フォルダ以下に置き、相対リンクは{$TPL_DIR}で指定します。
サイト全体の大まかな構成を変更したい場合は、デザイン管理→レイアウト管理から行うと簡単です。
左右のウィジェットなどをパーツ単位で移動、削除などを行えます。
サイト構成はおおざっぱにトップページ、商品一覧(及び会員登録や問い合わせページ等)、商品詳細、マイページに分かれています。
サイトトップのレイアウトを適当にドラッグして場所や順番を変更してみると、トップページにすぐ反映されます。
非常に簡単ですね。
商品一覧や商品詳細は元々右カラムに何もない状態になっているのですが、そこにヴィジェットを突っ込むと、中央カラムがヴィジェットに突っ込んでおかしなレイアウトになってしまいます。
CSSを見てみたところ、中央カラムを定義しているdiv#three_maincolumnが幅432pxなのに、その中のコンテンツ記述部分であるdiv#undercolumnが580pxで定義されていました。
何故。
つうかundercolumnとかいかにもフッタっぽい名前なんだけど何故こんな名前なんだろう。
というわけでdiv#undercolumn{width: 580px;}とすれば解決だ、と思えば全然そんなことはありませんでした。
商品ページ内のフォームとかタイトルとかデザインとかが悉く580pxと指定されている。
なんでピクセル指定するかなあ…
ということでもっとも簡単な解決方法はdiv#three_maincolumn{width: 580px;}、div#container {width: 912px;}だと思われます。
横幅が変わるのでヘッダフッタのCSS編集が必要になりますが、それ以外の全コンテンツのCSS編集を行うことに比べればましでしょう、きっと。
EC-CUBEの記事
http://www.ec-cube.net/
<div id="fotter">とか書いてある時点で信頼性ゼロのEC-CUBEですが、ローカルのXAMPPでさくさくと買い物サイトを作成してみます。
マニュアルサイトがわりと丁寧に書かれているのでそこ読めば終わっちゃうのですが、せっかくなので設置作業のログでも書いておこうかと。
例によって教え口調になっているんですがあくまで自分用なので文体は気にしない。
まずEC-CUBEの本体をダウンロードしてきて、どこか適当なフォルダに解凍します。
本来はこの後めんどくさいパーミッションの設定が必要ですが、Windowsなので必要ありません。
次にバーチャルホストを切ります。
別にhttp://localhost/hoge/fuga/eccube/とかでも問題無いんですが、見た目が綺麗な方がやっぱりいいですよね。
ということでhttpd.confを編集。
最近のApacheは各設定を別ファイルに分離してくれるので、
C:\xampp\apache\conf\extra\httpd-vhosts.conf
あたりを探し出して以下を追加。
1
2
3
4
5
6
7
|
<VirtualHost *:80>
ServerName eccube.localhost
DocumentRoot "C:\hoge\fuga\eccube\html"
<Directory "C:\hoge\fuga\eccube\html">
AllowOverride All
</Directory>
</VirtualHost>
|
ディレクトリはEC-CUBEを置いたところに適当に置き換えてください。
設定したらApacheを再起動。
最後にhostsの編集。
C:\WINDOWS\system32\drivers\etc\hostsに、
127.0.0.1 eccube.localhost
を追加します。
これはeccube.localhostというドメインに接続しようとしたら強制的に127.0.0.1(つまりローカルホスト)を見に行くよ、という設定です。
その後
http://eccube.localhost/
に行こうとしたらいきなりEC-CUBEのインストール画面に辿り着くはずです。
デフォルトだとDocumentRoot直下がEC-CUBEのインストールディレクトリになります。
任意の場所に置きたい場合、先に/html/define.phpのパスを書き換え、その場所にhtmlディレクトリおよびdataディレクトリを移動、リネームしておきます。
データベースを使用することになるので、先に場所を作っておきます。
デフォルトのままであれば
http://localhost/phpmyadmin/
でphpMyAdminが使用できるので、EC-CUBE用のユーザとデータベースを適当に作成します。
EC-CUBE2.4.0以前はMySQLのパフォーマンスに随分難があったようですが、今ならまあ概ね大丈夫なんじゃないかな。
あとは画面の指示に従っていけば勝手にECサイトができあがります。びっくりだ。
インストール完了後アクセスすると、サイトの頭に
>> /install/index.phpは、インストール完了後にファイルを削除してください。
という表示が出るので削除するか改名しPHPとして実行されないようにしておきます。
うっかり実行されてしまうとデータベースを全削除されたりと恐ろしいことになってしまいますので、どうせならinstallディレクトリそのものを亡き者にした方がより安全でしょう。
EC-CUBEの記事
lib/test/JobeetTestFunctional.class.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
class JobeetTestFunctional extends sfTestFunctional{
public function loadData(){
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
return $this;
}
public function getMostRecentProgrammingJob(){
$q = Doctrine_Query::create()
->select('j.*')
->from('JobeetJob j')
->leftJoin('j.JobeetCategory c')
->where('c.slug = ?', 'programming');
$q = Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q);
return $q->fetchOne();
}
public function getExpiredJob(){
$q = Doctrine_Query::create()
->from('JobeetJob j')
->where('j.expires_at < ?', date('Y-m-d', time()));
return $q->fetchOne();
}}
|
test/functional/frontend/jobActionsTest.php
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
|
include(dirname(__FILE__).'/../../bootstrap/functional.php');
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
$browser->info('1 - The homepage')->
get('/')->
with('request')->begin()->
isParameter('module', 'job')->
isParameter('action', 'index')->
end()->
with('response')->begin()->
info(' 1.1 - Expired jobs are not listed')->
checkElement('.jobs td.position:contains("expired")', false)->
end()
;
$max = sfConfig::get('app_max_jobs_on_homepage');
$browser->info('1 - The homepage')->
info(sprintf(' 1.2 - Only %s jobs are listed for a category', $max))->
with('response')->
checkElement('.category_programming tr', $max)
;
$browser->info('1 - The homepage')->
get('/')->
info(' 1.3 - A category has a link to
the category page only if too many jobs')->
with('response')->begin()->
checkElement('.category_design .more_jobs', false)->
checkElement('.category_programming .more_jobs')->
end()
;
$browser->info('1 - The homepage')->
info(' 1.4 - Jobs are sorted by date')->
with('response')->begin()->
checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]',
$browser->getMostRecentProgrammingJob()->getId()))->
end()
;
$browser->info('2 - The job page')->
info(' 2.1 - Each job on the homepage is clickable
and give detailed information')->
click('Web Developer', array(), array('position' => 1))->
with('request')->begin()->
isParameter('module', 'job')->
isParameter('action', 'show')->
isParameter('company_slug', 'sensio-labs')->
isParameter('location_slug', 'paris-france')->
isParameter('position_slug', 'web-developer')->
isParameter('id', $browser->getMostRecentProgrammingJob()->getId())->
end()->
info(' 2.2 - A non-existent job forwards the user to a 404')->
get('/job/foo-inc/milano-italy/0/painter')->
with('response')->isStatusCode(404)->
info(' 2.3 - An expired job page forwards the user to a 404')->
get(sprintf('/job/sensio-labs/paris-france/%d/web-developer',
$browser->getExpiredJob()->getId()))->
with('response')->isStatusCode(404)
;
|
test/functional/frontend/categoryActionsTest.php
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
|
include(dirname(__FILE__).'/../../bootstrap/functional.php');
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
$browser->info('1 - The category page')->
info(' 1.1 - Categories on homepage are clickable')->
get('/')->
click('Programming')->
with('request')->begin()->
isParameter('module', 'category')->
isParameter('action', 'show')->
isParameter('slug', 'programming')->
end()->
info(sprintf(' 1.2 - Categories with
more than %s jobs also have a "more" link',
sfConfig::get('app_max_jobs_on_homepage')))->
get('/')->
click('22')->
with('request')->begin()->
isParameter('module', 'category')->
isParameter('action', 'show')->
isParameter('slug', 'programming')->
end()->
info(sprintf(' 1.3 - Only %s jobs are listed'
, sfConfig::get('app_max_jobs_on_category')))->
with('response')->checkElement('.jobs tr'
, sfConfig::get('app_max_jobs_on_category'))->
info(' 1.4 - The job listed is paginated')->
with('response')->begin()->
checkElement('.pagination_desc', '/32 jobs/')->
checkElement('.pagination_desc', '#page 1/2#')->
end()->
click('2')->
with('request')->begin()->
isParameter('page', 2)->
end()->
with('response')->checkElement('.pagination_desc', '#page 2/2#')
;
|
個別にテストを行います。
>php test/functional/frontend/jobActionsTest.php
>php test/functional/frontend/categoryActionsTest.php
アプリケーション単位のテスト、またテストフォルダに存在するテストを一気に全部行うこともできます。
>php symfony test:functional frontend
>php symfony test:all
めでたしめでたし。
……エラーになりました。
>php test/functional/frontend/categoryActionsTest.php
> 1 - The category page
> 1.1 - Categories on homepage are clickable
# get /
# get /category/programming
ok 1 - request parameter module is category
ok 2 - request parameter action is show
ok 3 - request parameter slug is programming
> 1.2 - Categories with more than 5 jobs also have a "more" link
# get /
InvalidArgumentException: Cannot find the "22" link or button.
at () in C:\xampp\php\PEAR\symfony\util\sfBrowserBase.class.php line 689
at sfBrowserBase->doClick() in test\sfTestFunctionalBase.class.php line 289
at sfTestFunctionalBase->click() in test\functional\frontend\categoryActionsTest.php line 20
not ok 4 - An uncaught exception has been thrown.
# Failed test (test\sfTestFunctionalBase.class.php at line 612)
1..4
Looks like you failed 1 tests of 4.
categoryActionsTest.phpのclick('22')の部分でエラーが発生しました。
原因はチュートリアルのapp.ymlが
max_jobs_on_homepage: 10
max_jobs_on_category: 20
であるのに対し、手元で試したときの設定が
max_jobs_on_homepage: 5
max_jobs_on_category: 10
になっていたからです。
これだとリンク部分が「and 27 more...」になってしまうので22のリンクが見つからないのです。
てか、このテストは思いっきりデータベースの件数やapp.ymlの設定に依存したテストになってしまってるんだがそれでいいのか?
限界値分析のためにデータ放り込んでテストしなきゃならんとかなったら毎回テストを書き換えなければならず手間暇があんまり変わらないような。
あと、このテスト機能をブラウザから閲覧できる機能があってもいいと思うんだがどうだろう。
単に機能テストというと幅が広いですが、Webアプリの場合はリンクが正しいか、フォームに変な値を入れても大丈夫か、といったブラウザ上で正しく動くかどうかのテストが中心となります。
Webアプリを作成するときは必ずフォームに「'or 1 -- 」を入力してみるといったテストを行うことになりますが、はっきりいって面倒です。
機能テストを書くことで、そこらへんを自動的にやってくれるようになります。
まずtest/functional/frontend/ディレクトリを見てみると、知らない間にjobActionsTest.phpとcategoryActionsTest.phpが出来ています。
モジュールを作成したときに、ついでに作成されたものです。
categoryActionsTest.phpを見てみると、/category/indexにアクセスするとcategoryモジュールのindexアクションが実行され、200のステータスコードが返ってくるはずだよ、的なことが書かれています。
実際http://symfony.localhost/frontend_dev.php/category/indexにアクセスすると404エラーになるので、このテストも失敗します。
>php test/functional/frontend/categoryActionsTest.php
# get /category/index
ok 1 - request parameter module is category
not ok 2 - request parameter action is index
# Failed test (test\sfTesterRequest.class.php at line 48)
# got: 'show'
# expected: 'index'
not ok 3 - status code is 200
# Failed test (test\sfTesterResponse.class.php at line 257)
# got: 404
# expected: 200
ok 4 - response selector body does not match regex /This is a temporary page/
1..4
Looks like you failed 2 tests of 4.
indexアクションが実行されるって書いてあるのに実際はshowアクションが起動するよ、ステータス200が返ってくるはずなのに404が返ってくるよ、という二つのエラーが返ってきました。
正しいカテゴリページはこんなかんじで、
http://symfony.localhost/frontend_dev.php/category/programming
実行されるアクションもcategoryActions::executeShow()なので動作テストの一部を現状に合わせて書き換えます。
test/functional/frontend/categoryActionsTest.php
1
2
3
4
5
|
get('/category/programming')->
with('request')->begin()->
isParameter('module', 'category')->
isParameter('action', 'show')->
end()
|
>php test/functional/frontend/categoryActionsTest.php
# get /category/programming
ok 1 - request parameter module is category
ok 2 - request parameter action is show
ok 3 - status code is 200
ok 4 - response selector body does not match regex /This is a temporary page/
1..4
Looks like everything went fine.
想定通りになりました。
ちなみに4行目は、本文中に「This is a temporary page」が入っていないかどうかをチェックしてOKだった、という意味です。
これからデータベースと接続を行うみたいなのですが、このJobeetTestFunctionalクラスの意味がわかりません。
何故かこのファイルだけtestディレクトリではなくlibディレクトリ内に設置しないといけないので気をつけましょう。
lib/test/JobeetTestFunctional.class.php
1
2
3
4
5
6
|
class JobeetTestFunctional extends sfTestFunctional{
public function loadData(){
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
return $this;
}
}
|
次は用意されていたjobActionsTest.phpを書き換えてみます。
test/functional/frontend/jobActionsTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
include(dirname(__FILE__).'/../../bootstrap/functional.php');
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
$browser->info('1 - The homepage')->
get('/')->
with('request')->begin()->
isParameter('module', 'job')->
isParameter('action', 'index')->
end()->
with('response')->begin()->
info(' 1.1 - Expired jobs are not listed')->
checkElement('.jobs td.position:contains("expired")', false)->
end()
;
|
何処にもinclude()とか書いてないのに何で勝手にlib/test/JobeetTestFunctional.class.phpが呼び出されるんでしょうね。
今回は'/'でアクセスした場合jobモジュールのindexアクションが呼び出され、出力されたHTML内に'.jobs td.position:contains("expired")'セレクタが存在しない、というチェックを行っています。
何れも正しいのでテストは成功します。
適当に存在するセレクタ、'.category .feed a'とかを入れてみると発見されてしまいエラーになります。
ところで':contains'って何なんだ?
CSS3には見あたらないんですが。
ヘルプにはそもそも何も無し。
どうにもこうにも。
データベースに接続したりしないといけないので前回より少々面倒です。
テスト動作は今まで作成してきたdev環境でも本番用のprod環境でもなく、test環境で動作します。
現在データが入っているデータベースはdev環境なので、これではデータベースを用いたテストができません。
ということでMySQLにtest用のデータベースを作成します。
> mysqladmin -uroot -proot create jobeet_test
Symfonyにtest環境用の接続設定を作成します。
> php symfony configure:database --name=doctrine --class=sfDoctrineDatabase --env=test "mysql:host=localhost;dbname=jobeet_test" root root
config/databases.ymlを見てみると、test用のデータベース定義が追加されています。
フィクスチャに書いてあるデータをtest環境に追加します。。
> php symfony doctrine:insert-sql --env=test
データの投入が終わりました。
ここから先は一気にいきます。
まずdata/fixtures/ディレクトリにあるファイルを全部test/fixtures/ディレクトリにコピペ。
test/bootstrap/Doctrine.phpおよびtest/unit/model/JobeetJobTest.phpを作成。
test/bootstrap/Doctrine.php
1
2
3
4
5
|
include(dirname(__FILE__).'/unit.php');
$configuration = ProjectConfiguration::getApplicationConfiguration(
'frontend', 'test', true);
new sfDatabaseManager($configuration);
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
|
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
|
include(dirname(__FILE__).'/../../bootstrap/Doctrine.php');
$t = new lime_test(3, new lime_output_color());
$t->comment('->getCompanySlug()');
$job = Doctrine::getTable('JobeetJob')->createQuery()->fetchOne();
$t->is($job->getCompanySlug(), Jobeet::slugify($job->getCompany()),
'->getCompanySlug() return the slufor the company');
$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;
}
|
テストを実行
>php test/unit/model/JobeetJobTest.php
1..3
# ->getCompanySlug()
ok 1 - ->getCompanySlug() return the slufor the company
# ->save()
ok 2 - ->save() updates expires_at if not set
ok 3 - ->save() does not update expires_at if set
Looks like everything went fine.
テストに成功しました。
何をどうやって何のテストに成功したの?
さあ?
全く意味がわかりません。
他のDoctrineクラス用のテストとかとても書けそうにないのでパス。
テストを行わずに作成できるアプリケーションなど存在しません。
非常に重要で、金も期間もかかるのがテストフェーズなのですが、残念ながら世間ではたいしたものだとは思われていません。
まあ私もテスト苦手なんですが。
というわけでSymfonyにはプログラムのテストを行うライブラリが予め用意されています。
まずはユニットテストを行ってみましょう。
ユニットテストはひとつの関数、一つのメソッドが想定通りに動いているかをチェックするテストです。
とりあえずやってみます。
test/unit/JobeetTest.php
1
2
3
4
|
require_once dirname(__FILE__).'/../bootstrap/unit.php';
$t = new lime_test(1, new lime_output_color());
$t->pass('This test always passes.');
|
上二行は決まり文句なので、常に記述しておきます。
lime_testオブジェクトの適当なメソッドを実行するだけで簡単にテストが行えます。
第一引数はテストを行う件数です。
ここと実際行った件数が違う場合はそのことを教えてくれます。
第二引数は出力結果を色付けしてくれるのですがWindowsにはその機能がありません。Linux限定です。
実行は残念ながらコマンドライン限定。
> php test/unit/JobeetTest.php
1..1
ok 1 - This test always passes.
Looks like everything went fine.
上記lime_test::pass()は常に成功するというメソッドなので、実行結果もさくっと成功という内容になりました。
次はもう少しだけまともなテスト。
test/unit/JobeetTest.php
1
2
3
4
5
6
|
$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');
|
> php test/unit/JobeetTest.php
1..6
ok 1
ok 2
ok 3
ok 4
ok 5
ok 6
Looks like everything went fine.
lime_test::is()はふたつの引数が同一かどうかをチェックするメソッドです。
Jobeet::slugify()がうまく動作していれば、両者は一致します。
比較に失敗してしまった場合、次のようなエラーが表示されます。
> php test/unit/JobeetTest.php
not ok 1
# Failed test (.\test\unit\JobeetTest.php at line 5)
# got: 'sensio'
# expected: 'sensio1'
Looks like you failed 1 tests of 1.
この場合は第一引数が'sensio'、第二引数が'sensio1'なので不一致になりました、という意味になります。
ちなみに比較方法ですが、
1
2
|
$t->is(1,'1');
$t->is(1,true);
|
第三引数にメッセージを渡すこともできます。
test/unit/JobeetTest.php
1
2
3
|
$t->comment('::slugify()');
$t->is(Jobeet::slugify('Sensio'), 'sensio'
, '::slugify() converts all characters to lower case');
|
> php test/unit/JobeetTest.php
# ::slugify()
ok 1 - ::slugify() converts all characters to lower case
Looks like everything went fine.
何処でエラーになったかがわかりやすくなったりする利点があります。
次にコードカバレッジ。
対象のテストで実行されていない行があるかチェックしてくれる、命令網羅用のチェック機構です。
> php symfony test:coverage --detailed test/unit/JobeetTest.php lib/Jobeet.class.php
>> coverage running C:\xampp\htdocs\src\php.../test/unit/JobeetTest.php (1/1)
lib/Jobeet.class 100%
TOTAL COVERAGE: 100%
現在Jobeetクラスにはメソッドがslugify()しかないので、Jobeet::slugify()を一回でも実行した時点でカバレッジ率100%になります。
適当にfunction hoge(){}とか一行足して確認すると、100%ではなくなります。
> php symfony test:coverage --detailed test/unit/JobeetTest.php lib/Jobeet.class.php
>> coverage running C:\xampp\htdocs\src\php.../test/unit/JobeetTest.php (1/1)
lib/Jobeet.class 83%
# missing: 17
TOTAL COVERAGE: 83%
この二つを利用して、コードのユニットテストを行うことができます。
さて、先にどのような挙動をさせたいかを記述し、その動作に合致するように実装を行っていくことをテスト駆動開発と言います。
ユニットテストはこの手法で開発を行うのに適した作りになっています。
まず動作してほしい動きを書きます。
個別記事に飛んだときはこのようなURLになるわけですが、
http://symfony.localhost/frontend_dev.php/job/sensio-labs/paris-france/1/web-developer
jobeet_job.companyを空白にしてみると、出力されるURLは以下のようになります。
http://symfony.localhost/frontend_dev.php/job//paris-france/1/web-developer
アクセスするとエラーになってしまいます。
このように空白になってしまう時に、Jobeet::slugify()によって'n-a'という文字列を挿入してもらいましょう。
test/unit/JobeetTest.php
1
2
|
$t->is(Jobeet::slugify(''), 'n-a'
, '::slugify() converts the empty string to n-a');
|
現時点で実行すると、当然ながらテストは失敗します。
> php test/unit/JobeetTest.php
not ok 1 - ::slugify() converts the empty string to n-a
# Failed test (.\test\unit\JobeetTest.php at line 6)
# got: ''
# expected: 'n-a'
Looks like you failed 1 tests of 1.
このテストが成功するようにしつつ、これまでの動作は変化しないように実装を行います。
lib/Jobeet.class.php
1
2
3
4
5
6
7
8
9
|
static public function slugify($text){
//テキストが空であれば'n-a'を返す
if (empty($text)){ return 'n-a'; }
// 文字ではないもしくは数値ではないものすべてを-に置き換える
$text = preg_replace('/\W+/', '-', $text);
// トリムして小文字に変換する
$text = strtolower(trim($text, '-'));
return $text;
}
|
最初の行を挿入しました。
再度テストすると、これまでのテストを含め全てのテストに成功しました。
出力されるURLも
http://symfony.localhost/frontend_dev.php/job/n-a/paris-france/1/web-developer
となり、アクセスすると正しく内容が表示されるようになりました。
さて、完成かと思えば実はこの実装は間違っています。
先ほど空にしたjobeet_job.companyを今度は'-----'にしてみましょう。
$textはemptyではないので最初のチェックはスルーされ、preg_replace()で全削除されてしまいます。
従ってリンク先はまたもやエラーが出る、このようなURLになってしまいます。
http://symfony.localhost/frontend_dev.php/job//paris-france/1/web-developer
これを修正するために、まずはテストコードを追加しましょう。
test/unit/JobeetTest.php
1
2
3
|
$t->is(Jobeet::slugify(' - '), 'n-a',
'::slugify() converts a string that only contains non-ASCII characters to n-a');
|
実行するとまたエラーになります。
lib/Jobeet.class.phpを再度修正します。
単にifの行を最後に持っていくだけです。
すると無事にテストを通過し、リンク先URLも正しくなりました。
チュートリアルでは更に通常の文字ではない文字でのslugifyも行っています。
iconv()はeウムラウトみたいなちょっと扱いに困る文字を似た形の文字に置き換えるという素敵な関数です。
あとおまけとして文字コード変換ができます。
'us-ascii//TRANSLIT'と指定することで、アスキー文字に存在しないものは削除あるいは翻字を行っています。
Symfonyの記事一覧
早速ページング処理してみます。
まずapp.ymlに1ページあたりの表示件数を記述。
config/app.yml
1
2
|
all:
max_jobs_on_category: 20
|
チュートリアルではapps/frontend/config/app.ymlに書くようになっていますがどちらでもいいです。
apps/frontend/modules/category/actions/actions.class.php
1
2
3
4
5
6
7
8
9
10
11
|
public function executeShow(sfWebRequest $request){
$this->category = $this->getRoute()->getObject();
$this->pager = new sfDoctrinePager(
'JobeetJob',
sfConfig::get('app_max_jobs_on_category')
);
$this->pager->setQuery($this->category->getActiveJobsQuery());
$this->pager->setPage($request->getParameter('page', 1));
$this->pager->init();
}
|
sfDoctrinePagerがページャーオブジェクトです。
setQuery()でクエリオブジェクトを突っ込むとページング処理を行ってくれます。
JobeetCategory::getActiveJobsQuery()はまだ無いので作成します。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
4
5
6
|
public function getActiveJobsQuery(){
$q = Doctrine_Query::create()
->from('JobeetJob j')
->where('j.category_id = ?', $this->getId());
return Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q);
}
|
やってることはJobeetCategory::getActiveJobs()とほぼ同じで、実際にクエリを実行してしまうかクエリオブジェクトをそのまま持ってくるかの違いしかありません。
ということでリファクタリングできます。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
4
5
6
7
|
public function getActiveJobs($max = 10){
$q = $this->getActiveJobsQuery()->limit($max);
return $q->execute();
}
public function countActiveJobs(){
return $this->getActiveJobsQuery()->count();
}
|
JobeetCategory::getActiveJobsQuery()はクエリオブジェクトをそのまま返し、countActiveJobs()は取得できる件数を返し、getActiveJobs()は実際にその内容を取得して返します。
最後にテンプレートを更新。
ページング関連を追加します。
apps/frontend/modules/category/templates/showSuccess.php
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
|
use_stylesheet('jobs.css')
slot('title', sprintf('Jobs in the %s category', $category->getName()))
<div class="category">
<div class="feed">
<a href="">Feed</a>
</div>
<h1> echo $category </h1>
</div>
include_partial('job/list', array('jobs' => $pager->getResults()))
if ($pager->haveToPaginate()):
<div class="pagination">
<a href=" echo url_for('category', $category) ?page=1">
<img src="/images/first.png" alt="First page" title="First page" />
</a>
<a href=" echo url_for('category', $category) ?page= echo $pager->getPreviousPage() ">
<img src="/images/previous.png" alt="Previous page" title="Previous page" />
</a>
foreach ($pager->getLinks() as $page):
if ($page == $pager->getPage()):
echo $page
else:
<a href=" echo url_for('category', $category) ?page= echo $page "> echo $page </a>
endif;
endforeach;
<a href=" echo url_for('category', $category) ?page= echo $pager->getNextPage() ">
<img src="/images/next.png" alt="Next page" title="Next page" />
</a>
<a href=" echo url_for('category', $category) ?page= echo $pager->getLastPage() ">
<img src="/images/last.png" alt="Last page" title="Last page" />
</a>
</div>
endif;
<div class="pagination_desc">
<strong> echo $pager->getNbResults() </strong>
jobs in this category
if ($pager->haveToPaginate()):
- page <strong> echo $pager->getPage() / echo $pager->getLastPage() </strong>
endif;
</div>
|
なんだこのややこしいテンプレートは。
こんな訳の解らないものを出して「ロジックとデザインの分離が出来た!」とか言ってる場合じゃないと思うんだが。
こんなの絶対デザイナーには触れない。
あとどうでもいいけどチュートリアルのテンプレはFirstpageにtitleが無い。
やってることを順に解説すると、まずsfDoctrinePager::haveToPaginate()でページングの必要があるかどうか判断。
その後は、1ページ目へのリンク、前のページへのリンク、各ページへの順番リンク、次のページへのリンク、最後のページへのリンクを順に書いているだけです。
ちなみにsfDoctrinePager::getLinks()で返ってくるのは単なる数値の配列です。
非常に残念なのがリンクを自力で書かなければならないこと。
Pear::PagerやCakePHP、Zend_Paginatorといった主なページャーはここらへんを自動でやってくれるので、これらに比べると使い勝手の悪さが目立ちます。
せっかくのフルスタックフレームワークなんだからurl_for('category', $pager->next())くらいやってほしかった。
てーかそこらへんリファレンスで何一つ解説されてないとか酷すぎる。
Symfonyの記事一覧
リンク先はcategoryモジュールという別個のモジュールにしてしまうので、とりあえずコマンドラインから骨組みを作成します。
> php symfony generate:module frontend category
次に前回url:slugとか書いていた部分に対してその実体を作成します。
>categoryテーブル用にslugカラムを追加する必要があります:
>このslugカラムはDoctrineのSluggableビヘイビアによって考慮されます。
>JobeetCategoryモデルのビヘイビアを有効にすればすべてが考慮されます。
駄目だ、何度読んでも意味がわからない。
Doctrineのドキュメントでようやくそれっぽい記述を発見しました。
スキーマにテーブル名:actAs:Sluggable:というオプションを記述しておけば、テーブル自体にはカラムを作成しないけどモデル的にはカラムが存在するように振る舞う、というような内容のようです。
あと、"Sluggable ビヘイビア"でようやくslugの意味がわかったわ。
何故Doctrineのサイト自身にこれを適用していないんだろう?
スキーマファイル、JobeetCategory:actAs:内に以下を追加します。
config/doctrine/schema.yml
1
2
3
4
|
JobeetCategory:
actAs:
Sluggable:
fields: [name]
|
スキーマを書き換えたのでデータベースを作り直します。
例によって直接DBを触った内容は全削除されます。
>php symfony doctrine:build-all-reload --no-confirmation
1
2
3
4
5
6
7
8
9
10
|
CREATE TABLE IF NOT EXISTS `jobeet_category` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`slug` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `name` (`name`),
UNIQUE KEY `sluggable_idx` (`slug`)) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;
|
slugフィールドが作成されていました。なんだそれ。
この定義により、JobeetCategory::save()メソッド等で保存を行った際、jobeet_category.nameをslugifyした結果が勝手にslugフィールドに入るようになります。
さて、slugフィールドが実在するようになったので、今日スキーマに書いた
url: /category/:slug
の:slugは実在のカラムを参照できるようになり、せっかく書いたJobeetCategory::getSlug()は不要となります。
あったとしても元のメソッドがオーバーライドされるだけですが、まあ意味がないので削除しましょう。
ようやく画面表示コントローラとテンプレートの作成です。
ルーティングでparam: {module: category,action: show }と定義しているのでcategoryActions::executeShow()が呼び出されます。
apps/frontend/modules/category/actions/actions.class.php
1
2
3
|
public function executeShow(sfWebRequest $request){
$this->category = $this->getRoute()->getObject();
}
|
apps/frontend/modules/category/templates/showSuccess.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
use_stylesheet('jobs.css') slot('title', sprintf('Jobs in the %s category', $category->getName()))
<div class="category">
<div class="feed">
<a href="">Feed</a>
</div>
<h1> echo $category </h1>
</div>
<table class="jobs">
foreach ($category->getActiveJobs() as $i => $job):
<tr class=" echo fmod($i, 2) ? 'even' : 'odd' ">
<td class="location">
echo $job->getLocation()
</td>
<td class="position">
echo link_to($job->getPosition(), 'job_show_user', $job)
</td>
<td class="company">
echo $job->getCompany()
</td>
</tr>
endforeach;
</table>
|
ようやくカテゴリ単位で閲覧を行えるようになりました。
なにげに10件しか表示されていないのですが、それはまた今度。
先にパーシャルテンプレートを解説します。
テンプレートをよく見てみると、apps/frontend/modules/job/templates/indexSuccess.phpとapps/frontend/modules/category/templates/showSuccess.phpの<table class="jobs">
の中身は全く同じです。
こういう場合、共通する部分だけ別のファイルに書き出して共有を行うことができます。
apps/frontend/modules/job/templates/_list.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
<table class="jobs">
foreach ($jobs as $i => $job):
<tr class=" echo fmod($i, 2) ? 'even' : 'odd' ">
<td class="location">
echo $job->getLocation()
</td>
<td class="position">
echo link_to($job->getPosition(), 'job_show_user', $job)
</td>
<td class="company">
echo $job->getCompany()
</td>
</tr>
endforeach;
</table>
|
include_partial()でインクルードを行うことができます。
<?php include_partial('job/list'); ?>とすると、jobモジュール内のlistテンプレート、すなわちapps/frontend/modules/job/templates/_list.phpがそのまま取得できます。
注意点としては、変数はそのままでは全く渡されないということです。
上記apps/frontend/modules/category/templates/showSuccess.phpから単にinclude_partial('job/list');としただけでは、_list.php内の$jobsは空っぽです。
共通して変数を使いたい場合は、第二引数で渡す必要があります。
よって、上記showSuccess.phpのテーブル表示部分はこのようになります。
apps/frontend/modules/category/templates/showSuccess.php
1
|
include_partial('job/list', array('jobs' => $category->getActiveJobs()))
|
トップ画面の方も修正しましょう。
apps/frontend/modules/job/templates/indexSuccess.php
1
2
3
|
include_partial('job/list',
array('jobs' => $category->getActiveJobs(
sfConfig::get('app_max_jobs_on_homepage'))))
|
ちなみにトップ画面の場合、同じモジュール内にあるのでモジュール名を省略してinclude_partial('list')と書くこともできます。
Symfonyの記事一覧
まず、カテゴリ宛へのリンクでやってきた場合のルーティングを定義します。
apps/frontend/config/routing.yml
1
2
3
4
5
|
category:
url: /category/:slug
class: sfDoctrineRoute
param: { module: category, action: show }
options: { model: JobeetCategory, type: object }
|
だからslugって何なんだよ。
とりあえず以上のルーティングで下記のようなURLに合致することになります。
http://symfony.localhost/frontend_dev.php/category/hoge
categoryモジュールのshowアクションが実行されます。
>ルートは関連オブジェクトからの任意のカラムをパラメーターとして使うことができます。
>オブジェクトクラスで定義された関連アクセサーが存在する場合、ルートは他の値も使用できます。
>slugパラメーターは対応するcategoryテーブルのカラムを持たないので、ルートを動作させるためにJobeetCategoryのバーチャルアクセサーを追加する必要があります:
何この日本語。
解読すると、どうやらルーティングのurl:パラメータに、options:model:のモデルで指定されているカラムが存在しない場合、モデルにあるgetメソッドを実行する、ということのようです。
というか、全てのurl:パラメータに対してgetメソッドを実行してるんだけど、カラムが存在しているパラメータに対してのgetメソッドは最初から存在するので実装する必要が無く、存在しないカラムの場合は書かないといけないよ、ということのようです。
さてポイントは、これらはcategory:に合致するURLにやってきたときに動作するのではなく、テンプレートからcategoryを使用したURLへのリンクを作成しようとした際に動作するということです。
リンク先に移動したときではなく、リンクを表示するときに必要だったわけですね。
7日目にしてようやく明かされるこの真実。
ようやく5日目後半に作成したslugifyの動作原理がなんとなくわかったような気がしないでもありません。
ということでJobeetCategoryモデルに:slugなんてカラムは存在しないので、対応するgetメソッドを作成します。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
|
public function getSlug(){
return Jobeet::slugify($this->getName());
}
|
Jobeet::slugify()は+や%といったURLとして表示するには美しくない文字をすべて削除します。
テンプレートからlink_to()した際のリンク先URLを勝手に書き換えてくれます。
メソッド自体は昔作成したので改めて実装する必要はありません。
しかし、このクラス名をいきなり書いたら何故か/lib/Jobeet.phpがインクルードされるというのが意味不明なのですが、これは一体何処から出てくるのでしょうか。
あとslugifyって何だ。
トップページからカテゴリへのリンクを作成します。
apps/frontend/modules/job/templates/indexSuccess.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
<!-- some HTML code -->
<h1>
echo link_to($category, 'category', $category)
</h1>
<!-- some HTML code -->
</table>
if (($count = $category->countActiveJobs() -
sfConfig::get('app_max_jobs_on_homepage')) > 0):
<div class="more_jobs">
and echo link_to($count, 'category', $category)
more...
</div>
endif;
</div>
endforeach;
</div>
|
どういうこと?
上半分は、既存テンプレートの
<h1><?php echo $category ?></h1><br />
の部分を差し替えます。
下半分は
</table><br />
以下を差し替えます。
こうすることでインデックスからカテゴリページへのリンクが表示されま、せん。
JobeetCategory::countActiveJobs()が実装されていないからです。
早速実装します。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
4
5
6
7
|
public function countActiveJobs(){
return Doctrine_Query::create()
->from('JobeetJob j')
->addWhere('j.category_id = ?', $this->getId())
->addWhere('j.expires_at > NOW() ')
->count();
}
|
できました。
単に有効な求人件数を取得するというメソッドです。
結果として、有効な求人全てを表示できない場合に、カテゴリページへのリンクが作成されます。
link_to()の第二引数に'category'を与えると、ルーティングのcategory:url:に合致するように勝手にURLを組み立ててくれます。
まだ存在していないcategoryモジュールへのルーティングなんてものを先に設置しなければならなかった理由が、このlink_to()というわけです。
JobeetCategoryにJobeetJobの内容を書いてしまったので、例によって分割します。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
4
5
6
|
public function countActiveJobs(){
$q= Doctrine_Query::create()
->from('JobeetJob j')
->addWhere('j.category_id = ?', $this->getId());
return Doctrine::getTable('JobeetJob')->countActiveJobs($q);
}
|
1
2
3
4
5
6
7
8
|
public function countActiveJobs(Doctrine_Query $q = null){
if (is_null($q)){
$q = Doctrine_Query::create()
->from('JobeetJob j');
}
$q->addWhere('j.expires_at > NOW() ');
return $q->count();
}
|
できました。
この状態で問題無いといえば問題無いのですが、JobeetJobTableクラスをよく見るとJobeetJobTable::getActiveJobs()とJobeetJobTable::countActiveJobs()にほとんど同じコードが並んでいます。
効率よく纏めてしまいましょう。
lib/model/doctrine/JobeetJobTable.class.php
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
|
class JobeetJobTable extends Doctrine_Table{
/*
* 現在有効なJobeetJobを一件だけ取得
*/
public function retrieveActiveJob(Doctrine_Query $q){
return $this->addActiveJobsQuery($q)->fetchOne();
}
/*
* 現在有効なJobeetJobを全件取得
*/
public function getActiveJobs(Doctrine_Query $q = null){
return $this->addActiveJobsQuery($q)->execute();
}
/*
* 現在有効なJobeetJobの件数を取得
*/
public function countActiveJobs(Doctrine_Query $q = null){
return $this->addActiveJobsQuery($q)->count();
}
/*
* DQLに現在有効なJobeetJobを追加
*/
public function addActiveJobsQuery(Doctrine_Query $q = null){
if (is_null($q)){
$q = Doctrine_Query::create()->from('JobeetJob j');
}
$alias = $q->getRootAlias();
$q->andWhere($alias . '.expires_at > NOW()')
->addOrderBy($alias . '.created_at DESC');
return $q;
}}
|
SQLを組み立てる部分と実行する部分を分けることにより、すっきりしたコードにすることができました。
ただORDER BYは本来そんなところに書くべきではないと思うのですが、Doctrineはcount()の際に不要なORDERとかを削除してくれるのでひとまずは問題ありません。
しかしgetRootAliasは突如出てきて一切説明無しですか。
動作から推測するに、$q->addWhere('j.expires_at > NOW() ');の'j'の部分を取得するメソッドのようです。
Doctrine_Hydrate::getRootAlias()で定義されているようですがヘルプが存在しねえ。
Symfonyの記事一覧
チュートリアルは最初に表示の上限を設定するようになっていますが、こちらでは先にその下のフィクスチャから行います。
フィクスチャはYAMLでデータベースの中身を書いとけば下記コマンドでデータベースに内容を登録できるよ、というものです。
>php symfony doctrine:data-load
で、実はYAMLファイル内にPHPコードを書くことができます。
まずチュートリアルのjobs.ymlフィクスチャをコピーし、これまでの3つのjob_sensio_labs、job_extreme_sensio、expired_jobの下に追加。
上記コマンドを実行。
データベースを覗いてみると、さくっと34件のレコードが作成されています。
ブラウザから覗いてみると、期限切れになっていないレコードがだだーと33件表示されるはずです。
求人件数が100件とか1000件になったら非常に困るので、表示上限とページング処理が必要となります。
とりあえず表示上限を追加しましょう。
といっても単にLIMIT句を追加するだけですが。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
4
5
6
7
|
public function getActiveJobs($max = 10){
$q = Doctrine_Query::create()
->from('JobeetJob j')
->where('j.category_id = ?', $this->getId())
->limit($max);
return Doctrine::getTable('JobeetJob')->getActiveJobs($q);
}
|
$maxとlimitメソッドを追加しただけです。
この状態でリロードすると、カテゴリ毎の最大表示件数が10件になります。
テンプレートから呼び出しを行うときに、最大表示件数を追加します。
apps/frontend/modules/job/templates/indexSuccess.php
1
|
foreach ($category->getActiveJobs(10) as $i => $job):
|
最大表示件数をテンプレートに直書きしてしまうのはよろしくないので、デフォルト値をapp.ymlに持たせ、テンプレートから呼び出すようにしましょう。
config/app.yml
1
2
3
|
all:
active_days: 30
max_jobs_on_homepage: 5
|
apps/frontend/modules/job/templates/indexSuccess.php
1
2
3
|
foreach ($category->getActiveJobs(
sfConfig::get('app_max_jobs_on_homepage')) as $i => $job):
|
デフォルトの10件がmax_jobs_on_homepageの5件で上書きされ、カテゴリ毎の最大表示件数が5件になります。
さて、上記のようにチュートリアルではテンプレートの呼び出し側でデフォルト値を取得していますが、ここはgetActiveJobsに実装するのが意味的に正しいと思うんだがどうだろう。こんなかんじで。
lib/model/doctrine/JobeetCategory.class.php
1
2
3
4
5
6
7
8
|
public function getActiveJobs($max = null){
if(!$max){$max=sfConfig::get('app_max_jobs_on_homepage');}
$q = Doctrine_Query::create()
->from('JobeetJob j')
->where('j.category_id = ?', $this->getId())
->limit($max);
return Doctrine::getTable('JobeetJob')->getActiveJobs($q);
}
|
さて、一覧では非表示にすることができた期限切れの求人ですが、↓のようにjob_show_user:ルーティングに直通でアクセスすると普通に見えてしまいます。
http://symfony.localhost/frontend_dev.php/job/a/b/3/c
実際は何度も作り直しているのでid=3は無いと思いますが、データベースから直接確認して行ってみてください。
その表示する部分のロジックはどんなだったか確認してみます。
apps/frontend/modules/job/actions/actions.class.php
1
2
3
|
public function executeShow(sfWebRequest $request){
$this->job = $this->getRoute()->getObject();
}
|
なんだこれ。
ああ、昨日意味がわからんと投げたやつでしたか。
とりあえずこのURLはrouting.ymlのjob_show_userにひっかかるので、job_show_user:options:にmethod_for_queryを追加します。
apps/frontend/config/routing.yml
1
2
|
job_show_user:
options: { model: JobeetJob, type: object, method_for_query: retrieveActiveJob }
|
JobeetJobTableに該当のメソッドを追加します。
lib/model/doctrine/JobeetJobTable.class.php
1
2
3
4
|
public function retrieveActiveJob(Doctrine_Query $q){
$q->andWhere('a.expires_at > NOW()');
return $q->fetchOne();
}
|
すると上記のURLで、期限が切れた求人は見えなくなりました。
めでたしめでたし。
……そのaってどこから来たの?
method_for_queryって何?
これを一日一時間で終わらせろとかどう考えても無理。
十分な時間なんて何処にもねえよ。
Symfonyの記事一覧