Menu

4.用户可以对话题进行回复

本节说明

对应视频第 4 小节:A User May Response To Threads

本节内容

上节中我们的..\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;

    public function setUp()
    {
        parent::setUp();

        $this->thread = factory('App\Thread')->create();
    }

    /** @test */
    public function a_thread_has_replies()
    {
        $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection',$this->thread->replies);
    }
}

测试一下:

$ phpunit tests/Unit/ThreadTest.php

测试通过:
file
继续编写$thread->creator的测试代码:
ThreadTest.php

.
.
    /** @test */
    public function 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\Thread.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:详见课后附注处。

课后附注

  • 关于注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

本文章首发在 Laravel China 社区
上一篇 下一篇
讨论数量: 3
发起讨论


Jarvis42
这个是什么意思?
0 个点赞 | 1 个回复 | 问答
不负韶华。
ParticipateInForumTest 错误?
0 个点赞 | 1 个回复 | 问答