0.寫在前面
- 本系列文章為
laracasts.com
的系列視訊教程——Let's Build A Forum with Laravel and TDD 的學習筆記。若喜歡該系列視訊,可去該網站訂閱後下載該系列視訊, 支援正版 。 - 視訊原始碼地址:https://github.com/laracasts/Lets-Build-a-Forum-in-Laravel
- *本專案為一個 forum(論壇)專案,與本站的第二本實戰教程 Laravel 教程 - Web 開發實戰進階 ( Laravel 5.5 ) 類似,可互相參照
- 專案開發模式為
TDD
開發,教程簡介為:A forum is a deceptively complex thing. Sure, it's made up of threads and replies, but what else might exist as part of a forum? What about profiles, or thread subscriptions, or filtering, or real-time notifications? As it turns out, a forum is the perfect project to stretch your programming muscles. In this series, we'll work together to build one with tests from A to Z.
- 專案版本為
laravel 5.4
,教程後面會進行升級到laravel 5.5
的教學 - 視訊教程共計 102 個小節,筆記章節與視訊教程一一對應
1.本節說明
對應視訊第 4 小節:A User May Response To Threads
2.本節內容
上節中我們的..\views\threads\show.blade.php
檢視檔案回覆區域的內容為:
.
.
<div class="row">
<div class="col-md-8 col-md-offset-2">
@ foreach ($thread->replies as $reply) // 此處 @ 後面有空格
<div class="panel panel-default">
<div class="panel-heading">
{{ $reply->owner->name }} 回覆於
{{ $reply->created_at->diffForHumans() }}
</div>
<div class="panel-body">
{{ $reply->body }}
</div>
</div>
@endforeach
</div>
</div>
.
.
為了便於維護,我們將回復區域抽離成一個單獨的檢視。修改如下:
.
.
<div class="row">
<div class="col-md-8 col-md-offset-2">
@ foreach ($thread->replies as $reply) // 此處 @ 後面有空格
@include('threads.reply')
@endforeach
</div>
</div>
.
.
新建..\views\threads\reply.blade.php
檢視檔案:
<div class="panel panel-default">
<div class="panel-heading">
<a href="#">
{{ $reply->owner->name }}
</a>
回覆於 {{ $reply->created_at->diffForHumans() }}
</div>
<div class="panel-body">
{{ $reply->body }}
</div>
</div>
我們可以給話題的內容加上作者的資訊:..\views\threads\show.blade.php
.
.
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">
<a href="#">{{ $thread->creator->name }}</a> 發表了:
{{ $thread->title }}
</div>
<div class="panel-body">
{{ $thread->body }}
</div>
</div>
</div>
</div>
.
.
我們需要先行編寫單元測試,用來測試$thread->creator
。但是在此之前,由於上一節中我們使用了$thread->replies
來獲取回覆,但並未編寫單元測試。現在補上單元測試,首先移除Unit
資料夾下的示例檔案,並新建單元測試檔案:
$ php artisan make:test ThreadTest --unit
修改如下:
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class ThreadTest extends TestCase
{
use DatabaseMigrations;
/** @test */
public function a_thread_has_replies()
{
$thread = factory('App\Thread')->create();
$this->assertInstanceOf('Illuminate\Database\Eloquent\Collection',$thread->replies);
}
}
測試一下:
$ phpunit tests/Unit/ThreadTest.php
測試通過:
繼續編寫$thread->creator
的測試程式碼:ThreadTest.php
.
.
public function test_a_thread_has_a_creator()
{
$this->assertInstanceOf('App\User',$this->thread->creator);
}
.
.
我們可以使用--filter
來單獨測試:
$ phpunit --filter a_thread_has_a_creator
因為我們還未進行模型關聯:app\Thread.php
.
.
public function creator()
{
return $this->belongsTo(User::class,'user_id'); // 使用 user_id 欄位進行模型關聯
}
.
.
再次測試即可通過,重新整理頁面即可看到效果:
接下來新建測試:
$ php artisan make:test ParticipateInForumTest
先編寫測試邏輯:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class ParticipateInForumTest extends TestCase
{
use DatabaseMigrations;
/** @test */
function an_authenticated_user_may_participate_in_forum_threads()
{
// Given we have a authenticated user
// And an existing thread
// When the user adds a reply to the thread
// Then their reply should be visible on the page
}
}
再填充具體程式碼:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class ParticipateInForumTest extends TestCase
{
use DatabaseMigrations;
function an_authenticated_user_may_participate_in_forum_threads()
{
// Given we have a authenticated user
$this->be($user = factory('App\User')->create());
// And an existing thread
$thread = factory('App\Thread')->create();
// When the user adds a reply to the thread
$reply = factory('App\Reply')->create();
$this->post($thread->path().'/replies',$reply->toArray());
// Then their reply should be visible on the page
$this->get($thread->path())
->assertSee($reply->body);
}
}
注意到我們使用$thread->path()
來獲取 URL ,想起在ReadThreadsTest.php
檔案中可進行優化:tests\Feature\ReadThreadsTest.php
.
.
/** @test */
public function a_user_can_read_a_single_thread()
{
$this->get($this->thread->path()) //此處
->assertSee($this->thread->title);
}
/** @test */
public function a_user_can_read_replies_that_are_associated_with_a_thread()
{
// 如果有 Thread
// 並且該 Thread 有回覆
$reply = factory('App\Reply')
->create(['thread_id' => $this->thread->id]);
// 那麼當我們看 Thread 時
// 我們也要看到回覆
$this->get($this->thread->path()) //還有此處
->assertSee($reply->body);
}
.
.
一般而言,當修改已通過的測試時,應該在修改之後(需註釋新建的測試)再次測試,確保之前的測試邏輯未被破壞。
$ phpunit
當我們測試新寫的測試時:
$ phpunit tests/Feature/ParticipateInForumTest.php
會得到一大段長長的報錯資訊,要定位到錯誤十分困難:
在app\Exceptions\Handler.php
中加上一行:
.
.
public function render($request, Exception $exception)
{
if(app()->environment() === 'local') throw $exception; // 此處加上一行
return parent::render($request, $exception);
}
.
.
注1
:視訊教程中使用的是app()->environment() === 'testing'
,但經過測試未生效,遂改為以上local
。
再次執行測試:
$ phpunit tests/Feature/ParticipateInForumTest.php
現在可以十分容易地定位錯誤:
新增路由:
Route::post('/threads/{thread}/replies','RepliesController@store');
前往RepliesController
增加store
方法:
<?php
namespace App\Http\Controllers;
use App\Thread;
use Illuminate\Http\Request;
class RepliesController extends Controller
{
public function store(Thread $thread)
{
$thread->addReply([
'body' => request('body'),
'user_id' => auth()->id(),
]);
}
}
為store
方法新增單元測試:ThreadTest.php
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class ThreadTest extends TestCase
{
use DatabaseMigrations;
protected $thread;
public function setUp()
{
parent::setUp(); // TODO: Change the autogenerated stub
$this->thread = factory('App\Thread')->create();
}
/** @test */
public function a_thread_has_replies()
{
$this->assertInstanceOf('Illuminate\Database\Eloquent\Collection',$this->thread->replies);
}
/** @test */
public function a_thread_has_a_creator()
{
$this->assertInstanceOf('App\User',$this->thread->creator);
}
/** @test */
public function a_thread_can_add_a_reply()
{
$this->thread->addReply([
'body' => 'Foobar',
'user_id' => 1
]);
$this->assertCount(1,$this->thread->replies);
}
}
測試一下:
新增addReply
方法:app\Thhread.php
.
.
public function addReply($reply)
{
$this->replies()->create($reply);
}
.
.
再次執行測試
$ phpunit --filter an_authenticated_user_may_participate_in_forum_threads
結果報錯:
按道理說不應該,根據查閱到的資料,在測試環境應該是不會檢驗CsrfToken
。嘗試了諸多辦法仍舊無法解決,簡單用以下方法臨時解決:
$ APP_ENV=testing phpunit --filter an_authenticated_user_may_participate_in_forum_threads
即:在執行測試的時候將環境設為testing
,未配合使用,應將Hander.php
檔案中程式碼改為如下:
.
.
public function render($request, Exception $exception)
{
if (app()->environment() === 'testing') throw $exception;
return parent::render($request, $exception);
}
.
.
執行測試:
執行完整測試:
$ APP_ENV=testing phpunit
注1
:此處在筆記心得
有詳細解釋。
我們限制只有登入使用者才能新增回復,只需利用auth
中介軟體即可:RepliesController.php
public function __construct()
{
$this->middleware('auth');
}
.
.
測試如果我們將單元測試程式碼更改一下:ParticipateInForumTest.php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class ParticipateInForumTest extends TestCase
{
use DatabaseMigrations;
/** @test */
function an_authenticated_user_may_participate_in_forum_threads()
{
// Given we have a authenticated user
// $this->be($user = factory('App\User')->create()); // 已登入使用者
$user = factory('App\User')->create(); // 未登入使用者
// And an existing thread
$thread = factory('App\Thread')->create();
// When the user adds a reply to the thread
$reply = factory('App\Reply')->create();
$this->post($thread->path() .'/replies',$reply->toArray()); // 注:此處有修改
// Then their reply should be visible on the page
$this->get($thread->path())
->assertSee($reply->body);
}
}
再次執行測試:
$ APP_ENV=testing phpunit
提示使用者未認證,說明我們的測試有效。接下來再建立一個測試,測試未登入使用者不能新增回復:ParticipateInForumTest.php
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class ParticipateInForumTest extends TestCase
{
use DatabaseMigrations;
/** @test */
public function unauthenticated_user_may_no_add_replies()
{
$this->expectException('Illuminate\Auth\AuthenticationException');
$thread = factory('App\Thread')->create();
$reply = factory('App\Reply')->create();
$this->post($thread->path().'/replies',$reply->toArray());
}
/** @test */
function an_authenticated_user_may_participate_in_forum_threads()
{
// Given we have a authenticated user
$this->be($user = factory('App\User')->create());
// And an existing thread
$thread = factory('App\Thread')->create();
// When the user adds a reply to the thread
$reply = factory('App\Reply')->create();
$this->post($thread->path() .'/replies',$reply->toArray());
// Then their reply should be visible on the page
$this->get($thread->path())
->assertSee($reply->body);
}
}
再次測試:
$ APP_ENV=testing phpunit
成功通過:
實際上,測試未登入使用者的程式碼可以更加簡單,因為我們實際上只用測試未登入使用者是否丟擲異常即可:ParticipateInForumTest.php
.
.
/** @test */
public function unauthenticated_user_may_no_add_replies()
{
$this->expectException('Illuminate\Auth\AuthenticationException');
$this->post('threads/1/replies',[]);
}
.
.
最後,需要修改一下an_authenticated_user_may_participate_in_forum_threads
:
.
.
/** @test */
function an_authenticated_user_may_participate_in_forum_threads()
{
// Given we have a authenticated user
$this->be($user = factory('App\User')->create());
// And an existing thread
$thread = factory('App\Thread')->create();
// When the user adds a reply to the thread
$reply = factory('App\Reply')->make(); // -->此處有修改
$this->post($thread->path() .'/replies',$reply->toArray());
// Then their reply should be visible on the page
$this->get($thread->path())
->assertSee($reply->body);
}
.
.
注2
:詳見筆記心得
處。
3.筆記心得
-
關於
注1
的說明:
第四節課經歷比較坎坷,主要是遇到了一個問題:Illuminate\Session\TokenMismatchException:
顯示的問題應該是
CSRF
令牌不符,於是定位到\vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken.php
的tokensMatch
方法:protected function tokensMatch($request) { $token = $this->getTokenFromRequest($request); return is_string($request->session()->token()) && is_string($token) && hash_equals($request->session()->token(), $token); }
發現驗證的是
$token
跟$request->session()->token()
的值,於是將兩者的值列印出來看看:protected function tokensMatch($request) { $token = $this->getTokenFromRequest($request); var_dump($token); var_dump($request->session()->token());exit; return is_string($request->session()->token()) && is_string($token) && hash_equals($request->session()->token(), $token); }
執行:
$ phpunit
本以為問題就是因為$token
的值是null
,然而在看了文章 防範 CSRF 跨站請求偽造-以 Laravel 中介軟體 VerifyCSRFToken 為例 再結合程式碼發現,在進行測試時是不需要驗證CsrfToken
的:\vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken.php
:. . public function handle($request, Closure $next) { if ( $this->isReading($request) || $this->runningUnitTests() || $this->inExceptArray($request) || $this->tokensMatch($request) ) { return $this->addCookieToResponse($request, $next($request)); } throw new TokenMismatchException; } . .
其中的第二條,
$this->runningUnitTests()
即意味著在測試時應該放行。於是追溯runningUnitTests
方法:protected function runningUnitTests() { return $this->app->runningInConsole() && $this->app->runningUnitTests(); }
經過驗證,
$this->app->runningInConsole()
為true
。於是接著追溯runningUnitTests
方法:public function runningUnitTests() { return $this['env'] == 'testing'; }
然後驗證到
$this['env']
的值為local
,終於定位到錯誤:執行測試時的環境為local
。令人疑惑的是,phpunit.xml
的配置與教程相同,但不知為何沒有生效:phpunit.xml
<phpunit backupGlobals="false" backupStaticAttributes="false" bootstrap="bootstrap/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false"> <testsuites> <testsuite name="Feature"> <directory suffix="Test.php">./tests/Feature</directory> </testsuite> <testsuite name="Unit"> <directory suffix="Test.php">./tests/Unit</directory> </testsuite> </testsuites> <filter> <whitelist processUncoveredFilesFromWhitelist="true"> <directory suffix=".php">./app</directory> </whitelist> </filter> <php> <env name="APP_ENV" value="testing"/> -->此處將環境設定為 testing,但未生效 <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value=":memory:"/> <env name="CACHE_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/> <env name="QUEUE_DRIVER" value="sync"/> </php> </phpunit>
-
關於
注2
的說明:
先來看一下create()
與make()
方法的說明:/** * Create a collection of models and persist them to the database. * * @param array $attributes * @return mixed */ public function create(array $attributes = []) { $results = $this->make($attributes); if ($results instanceof Model) { $this->store(collect([$results])); } else { $this->store($results); } return $results; }
/** * Create a collection of models. * * @param array $attributes * @return mixed */ public function make(array $attributes = []) { if ($this->amount === null) { return $this->makeInstance($attributes); } if ($this->amount < 1) { return (new $this->class)->newCollection(); } return (new $this->class)->newCollection(array_map(function () use ($attributes) { return $this->makeInstance($attributes); }, range(1, $this->amount))); }
create()
方法會得到一個例項,並將例項儲存到資料庫中;make()
方法只會得到一個例項。在本節的測試中我們不需要儲存$thread
例項,因為我們會在RepliesController
的store()
方法進行儲存,故使用make()
方法。另外,
create()
與make()
方法的區別可以參見這篇文章 What does the make() method do in Laravel
4.寫在後面
- 如有建議或意見,歡迎指出~
- 如果覺得文章寫的不錯,請點贊鼓勵下哈,你的鼓勵將是我的動力!