Laravel 專案:使用 TDD 構建論壇 Chapter 4

洛未必達發表於2018-04-28

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

測試通過:
file
繼續編寫$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

file
因為我們還未進行模型關聯:
app\Thread.php

.
.
public function creator()
{
    return $this->belongsTo(User::class,'user_id'); // 使用 user_id 欄位進行模型關聯
}
.
.

再次測試即可通過,重新整理頁面即可看到效果:
file

接下來新建測試:

$ 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

會得到一大段長長的報錯資訊,要定位到錯誤十分困難:
file
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

現在可以十分容易地定位錯誤:
file
新增路由:

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);
    }
}

測試一下:
file
新增addReply方法:
app\Thhread.php

.
.
public function addReply($reply)
{
    $this->replies()->create($reply);
}
.
.

再次執行測試

$ phpunit --filter an_authenticated_user_may_participate_in_forum_threads

結果報錯:
file
按道理說不應該,根據查閱到的資料,在測試環境應該是不會檢驗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);
}
.
.

執行測試:
file
執行完整測試:

$ APP_ENV=testing phpunit

file

注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

file
提示使用者未認證,說明我們的測試有效。接下來再建立一個測試,測試未登入使用者不能新增回復:
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

成功通過:
file
實際上,測試未登入使用者的程式碼可以更加簡單,因為我們實際上只用測試未登入使用者是否丟擲異常即可:
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的說明:
    第四節課經歷比較坎坷,主要是遇到了一個問題:
    file

    Illuminate\Session\TokenMismatchException:

    顯示的問題應該是CSRF令牌不符,於是定位到
    \vendor\laravel\framework\src\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken.phptokensMatch方法:

    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

    file
    本以為問題就是因為$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例項,因為我們會在RepliesControllerstore()方法進行儲存,故使用make()方法。

    另外,create()make()方法的區別可以參見這篇文章 What does the make() method do in Laravel

4.寫在後面

  • 如有建議或意見,歡迎指出~
  • 如果覺得文章寫的不錯,請點贊鼓勵下哈,你的鼓勵將是我的動力!

相關文章