Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save idhowardgj94/eeea78a542c30e832c3391dc349d2e1d to your computer and use it in GitHub Desktop.
Save idhowardgj94/eeea78a542c30e832c3391dc349d2e1d to your computer and use it in GitHub Desktop.
Laravel 5 測試起手式

Web Development with Laravel 5

idhowardgj94 實作記錄

實作laravel版本為最新版本 laravel 5.6

目前已知laravel 5.6 改進了預設的資料夾結構, 以及tests 方法重寫。

將針對原來 5實作的部份做修改。

尊重原文作者,修改的部份將已附註的放式加上去,而不直接修改。

目標

如何在開發的過程中加入測試。

  1. Model
  2. Repository
  3. Controller
  4. Auth

範例

建立一個需要登入的文章發表系統。

  1. 使用者登入登出
  2. 文章列表、新增文章

雖然簡單,但足夠我們對 Laravel 5 有基本的理解了。

更完整的專案實作,可以參考 Laracasts 上的 Laravel 5 Fundamentals 一系列影片。

安裝 Laravel 並建立相關檔案與環境

$ composer create-project laravel/laravel demo
$ cd demo

安裝 Mockery :

$ composer require mockery/mockery --dev

針對 model 資料存取做測試

測試資料庫存取時,要儘可能不動到正式資料庫,而且要能快速建立

也儘可能把設定都放在測試可控制的環境,不要跟主程式有牽扯。

  • 定義測試用資料庫
  • 使用 sqlite :memory: 來測試

建立 Article model :

$ php artisan make:model Article -m

這將會:

  • 建立 app/Article.php
  • 建立 database/migrations/xxxx_xx_xx_xxxxxx_create_articles_table.php

Article.php 加入以下屬性:

namespace App;

use Illuminate\Database\Eloquent\Model;

class Article extends Model
{

    // 定義當使用 __construct($data) 或 create($data) 時
    // 可以被修改的欄位,進而保護其他欄位不被修改
    protected $fillable = ['title', 'body'];

}

修改 database/migrations/xxxx_xx_xx_xxxxxx_create_articles_table.php ,在 up 方法加入 titlebody 兩個欄位:

    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->increments('id');
            $table->string('title');
            $table->text('body');
            $table->timestamps();
        });
    }

修改 tests/TestCase.php ,加入:

use Illuminate\Support\Facades\Artisan;

class TestCase extends Illuminate\Foundation\Testing\TestCase {

    // ...

    // 每個 test case 都會重新初始化資料庫
    protected function initDatabase()
    {
        // 在測試時動態修改 config
        // 使其連接 sqlite
        config([
            'database.default' => 'sqlite',
            'database.connections.sqlite' => [
                'driver'    => 'sqlite',
                'database'  => ':memory:',
                'prefix'    => '',
            ],
        ]);

        // 呼叫 php artisan migrate
        // 及 php artisan db:seed
        Artisan::call('migrate');
        Artisan::call('db:seed');
    }

    // 重置資料庫
    // 讓下次測試不會被舊資料干擾
    protected function resetDatabase()
    {
        // 呼叫 php artisan migrate:reset
        // 這樣會把所有的 migration 清除
        Artisan::call('migrate:reset');
    }

laravel 5.6中 預設 test case 寫法不同,但不需要特別更動,將新建的方法寫入即可

namespace Tests;

use Illuminate\Support\Facades\Artisan;
use Laravel\BrowserKitTesting\TestCase as BaseTestCase;


// 每個test case 都會重新初始化資料庫
abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;
    public $baseUrl = 'http://192.168.56.20';
    protected function initDatabase()
    {
        // 在測試時動態修改config
        // 使其連接sqlite
        config([
            'database.default' => 'sqlite',
            'database.connections.sqlite' => [
                'driver'    => 'sqlite',
                'database'  => ':memory:',
                'prefix'    => '',
            ],
        ]);

        // 呼叫 php artisan migrate
        // 及php artisan db:seed
        Artisan::call('migrate');
        Artisan::call('db:seed');
    }
    // 重置資料庫
    // 讓下次測試不會被舊資料干擾
    protected function resetDatabase()
    {
        // 呼叫 php artisan migrate:reset
        // 會把所有的migration清除
        Artisan::call('migrate:reset');
    }
}

另外,不確定從哪一個版本開始,在所有的檔案最上方,最好是使用制定的namespace,如此 laravel 在 autoloading 需要的檔案時才不會有錯誤。 laravel 規定之namesapace必需與路徑名稱一致。如檔案一檔案  app/Http/Controllers/Controller.php,則其namespace應為

namespace App\Http\Controllers

新增 tests/ArticleTest.php ,並加入初始化資料庫的動作:

class ArticleTest extends TestCase
{
    // setUp 每執行一次 test case 前都會執行
    // 可以用來初始化資料庫並重新建立待測試物件
    // 以免被其他 test case 影響測試結果
    public function setUp()
    {
        // 一定要先呼叫,建立 Laravel Service Container 以便測試
        parent::setUp();

        // 每次都要初始化資料庫
        $this->initDatabase();
    }

    // tearDown 會在每個 test case 結束後執行
    // 可以用來重置相關環境
    public function tearDown()
    {
        // 結束一個 test case 都要重置資料庫
        $this->resetDatabase();
    }
}

先測試沒有文章:

class ArticleTest extends TestCase
{
    // 測試如果文章為空
    public function testEmptyResult()
    {
        // 取得所有文章
        $articles = Article::all();

        // 確認 $articles 是 Collection
        $this->assertInstanceOf('Illuminate\Database\Eloquent\Collection', $articles);

        // 而文章數為 0
        $this->assertEquals(0, count($articles));
    }
}

測試新增資料並列出:

class ArticleTest extends TestCase
{
    // ...

    // 測試新增資料並列出
    public function testCreateAndList()
    {
        // 新增 10 筆資料
        for ($i = 1; $i <= 10; $i ++) {
            Article::create([
                'title' => 'title ' . $i,
                'body'  => 'body ' . $i,
            ]);
        }

        // 確認有 10 筆資料
        $articles = Article::all();
        $this->assertEquals(10, count($articles));
    }
}

執行測試:

$ ./vendor/bin/phpunit

這裡的bash指令沒有指定class,phpunit會將tests底下的所有test跑過一次。 也可以指定需要test的class,這在寫phpunit初期偵錯應該是會有幫助,尤其當還不熟如何寫unit test時。

$ ./vendor/bin/phpunit tests/ArticleTests

因為 ORM 已經幫我們實作 Model 存取資料的相關機制,我們只是先測試驗證它沒有問題;所以實際上不需要特別測試 Model ,這裡只是為了確認測試是可以正確運作的。

好的 Pattern 是用 Repository 把 Model 封裝起來。

用 Repository 包裝 Model

  • 不讓 Controller 直接接觸 Model
  • 避免 Controller 肥大
  • 封裝資料存取邏輯
  • 抽換資料庫實作較容易

建立 app/Repositories/ArticleRepository.php

namespace App\Repositories;

use App\Article;

class ArticleRepository
{
}

這就是我們要測試的目標。

再建立 tests/ArticleRepositoryTest.php 測試類別:

use App\Repositories\ArticleRepository;

// 一樣要先繼承
class ArticleRepositoryTest extends TestCase
{
    /**
     * @var ArticleRepository
     */
    protected $repository = null;

    /**
     * 建立 100 筆假文章
     */
    protected function seedData()
    {
        for ($i = 1; $i <= 100; $i ++) {
            Article::create([
                'title' => 'title ' . $i,
                'body'  => 'body ' . $i,
            ]);
        }
    }

    // 跟前面一樣,每次都要初始化資料庫並重新建立待測試物件
    // 以免被其他 test case 影響測試結果
    public function setUp()
    {
        parent::setUp();

        $this->initDatabase();
        $this->seedData();

        // 建立要測試用的 repository
        $this->repository = new ArticleRepository();
    }

    public function tearDown()
    {
        $this->resetDatabase();
        $this->repository = null;
    }
}

測試 latest10 方法:

    public function testFetchLatest10Articles()
    {
        // 從 repository 中取得最新 10 筆文章
        $articles = $this->repository->latest10();
        $this->assertEquals(10, count($articles));

        // 確認標題是從 100 .. 91 倒數
        // "title 100" .. "title 91"
        $i = 100;
        foreach ($articles as $article) {
            $this->assertEquals('title ' . $i, $article->title);
            $i -= 1;
        }
    }

執行測試出現 Fatal error ,因為我們還沒有 latest10 方法。

app/Repositories/ArticleRepository.php 加入:

    public function latest10()
    {
    }

執行測試後紅燈,但錯誤訊息不同了,這是正常的第一步,我們還需要加入實作:

    public function latest10()
    {
        return Article::query()->orderBy('id', 'desc')->limit(10)->get();
    }

測試出現綠燈,成功。

這就是 TDD 的流程,也就是「寫測試 → 紅燈 → 寫程式 → 綠燈」。

接下來都會照這個步調來進行。

測試 create 方法:

class ArticleRepositoryTest extends TestCase
{
    // ...

    public function testCreateArticle()
    {
        // 因為前面有 100 筆了,
        // 所以這裡我們可以預測新增後的 id 是 101
        $latestId = self::POST_COUNT + 1;

        $article = $this->repositorys->create([
            'title' => 'title ' . $latestId,
            'body'  => 'body ' . $latestId,
        ]);

        $this->assertEquals(self::POST_COUNT + 1, $article->id);
    }
}

測試失敗,新增 ArticleRepository::create 方法:

    public function create(array $attributes)
    {
        return Article::create($attributes);
    }

測試成功。

設定資料庫與建立資料表

  • 測試通過後,也許會想實際執行看看是否真的有寫入資料庫。
  • 前面建立 model 時,已經建立好相關的 migration 檔案。
  • 設定在實際環境運作的資料庫設定。

修改 database/migrations/xxxx_xx_xx_xxxxxx_create_articles_table.php

    public function up()
    {
        Schema::create('articles', function(Blueprint $table)
        {
            $table->increments('id');

            // 加入以下兩行
            $table->string('title');
            $table->text('body');

            $table->timestamps();
        });
    }

如果使用 MySQL 的話,修改 .env 檔。因為這邊為示範用,故採用 sqlite 。

修改 config/database.phpdefault 設定:

    'default' => 'sqlite',

建立 sqlite 資料庫:

$ touch storage/database.sqlite

安裝資料表:

$ php artisan migrate

php artisan tinker 驗證:

>>> $rep = new ArticleRepository();
>>> $rep->latest10()->toArray();
>>> $rep->create([
    'title' => 'test',
    'body' => 'test',
])
>>> $rep->latest10()->toArray();
  • tinker 已經初始化好相關 Laravel 執行時期的 autoload 機制。

建立 Controller

建立 Controller 與 View :

$ php artisan make:controller ArticleController --plain
$ mkdir -p resources/views/articles
$ touch resources/views/articles/index.blade.php

如果不加 --plain ,會預設加入 indexcreatestore ... 等操作 resource 的相關方法。

這邊做的範例將 ArticleConcroller 視為一個resource,在5.6版,可以使用以下指另加入resource controller

$ php artisan make:controller ArticleController --resource

resource 將網站上的app當作資源來使用,可以建立CRUD操作。 在 laravel 中,不同的方法會指定給不同的action實作。如範例的 index(),是get方法的指定action。 詳情可以看官網。

https://laravel.com/docs/5.6/controllers#resource-controllers 可以另外去了解一點restFul的概念。

ArticleController.php 中加入:

    public function index()
    {
        $articles = [];
        return view('articles.index', compact('articles');
    }

編輯 app/Http/routes.php ,將已定義的 routes 暫時註解掉,再加入:

Route::resource('articles', 'ArticleController');

php artisan route:list 確認有沒有正確加入。

在laravel 5.6中,routes.php並不存在app/Http/之下,而是另外獨立了一個資料夾 /routes/, 並在裡面預設加入了四個routes檔案 api.phpchannels.phpconsole.phpweb.php, 如果要另外增加routes.php,必須去改Providers\內的程式碼。剛開始不建議去改較為底層的laravel架構。 我實作時是將articles在web.php底下注冊,但我不確定在web.php底下注冊合不合理。

建立 Controller 測試

  • 測試流程邏輯
  • 測試 HTTP 狀態

把原來的 tests/ExampleTest.php 改名:

$ mv tests/ExampleTest.php tests/ArticleControllerTest.php

編輯 tests/ArticleControllerTest.php

class ArticleControllerTest extends TestCase {

    public function testArticleList()
    {
        // 用 GET 方法瀏覽網址 /post
        $this->call('GET', '/posts');

        // 改用 Laravel 內建方法
        // 實際就是測試是否為 HTTP 200
        $this->assertResponseOk();

        // 應取得 articles 變數
        $this->assertViewHas('articles');
    }

}

這裡使用到assertResponseOK,根據官網所寫

Laravel 5.4's testing layer has been re-written to be simpler and lighter out of the box. If you would like to continue using the testing layer present in Laravel 5.3, you may install the laravel/browser-kit-testing package into your application.

laravel 的 git 位址:https://github.com/laravel/browser-kit-testing 使用以下指令安裝:

composer require laravel/browser-kit-testing --dev

必須修改BaseTestCase。詳情見git。

另外,這裡應該是筆誤,我們在註冊routes時,註冊名為articles,所以tests中,應該是call articles:

class ArticleControllerTest extends TestCase {

    public function testArticleList()
    {
        // 用 GET 方法瀏覽網址 /articles
        $this->call('GET', '/articles');

        // 改用 Laravel 內建方法
        // 實際就是測試是否為 HTTP 200
        $this->assertResponseOk();

        // 應取得 articles 變數
        $this->assertViewHas('articles');
    }

}

測試成功。

  • 透過 call 方法執行 route,進而建立 Controller 實體來測試。
  • Laravel 有內建一些測試 response 狀態的 assert 方法。
  • session 或 cache 直接使用 array

注入 Repository 到 Controller 中

文章實際會從 ArticleRepository 裡取得,所以 Controller 會需要注入 Repository 。

namespace App\Http\Controllers;

use App\Repositories\ArticleRepository;
use Illuminate\Http\Response;

class ArticleController extends Controller {

    protected $repository;

    // 利用 Service Container (DI) 來自動注入 ArticleRepository
    public function __construct(ArticleRepository $repository)
    {
        $this->repository = $repository;
    }

    // ...
}

修改 ArticleController::index

    public function index()
    {
        // 改成從 ArticleRepository 中取得資料
        $articles = $this->repository->latest10();

        return view('article.index', compact('articles'));
    }

測試不成功,因為我們沒有連接資料庫。

用 Mockery 隔離 ArticleRepository

  • 不讓 Controller 測試接觸資料庫或其他需要 IO 的媒介
  • 利用 Mockery 透過 ArticleRepository 生成假物件 (mock object)
  • 利用 Service Container 注入假物件取代原本應該被呼叫的物件
  • 讓假物件的方法回傳假值
class ArticleControllerTest extends TestCase {

    protected $repositoryMock = null;

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

        // Mockery::mock 可以利用 Reflection 機制幫我們建立假物件
        $this->repositoryMock = Mockery::mock('App\Repositories\ArticleRepository');

        // Service Container 的 instance 方法可以讓我們
        // 用假物件取代原來的 ArticleRepository 物件
        $this->app->instance('App\Repositories\ArticleRepository', $this->repositoryMock);
    }

    public function tearDown()
    {
        // 每次完成 test case 後,要清除掉被 mock 的假物件
        Mockery::close();
    }

    public function testArticleList()
    {
        // 確認程式會呼叫一次 ArticleRepository::latest10 方法
        // 實際上是為這個 mock object 加入 latest10 方法
        // 沒有呼叫到的話就會發出異常
        // 再假設它會回傳 foo 這個字串
        // 這樣就不需要真的去連結資料庫
        $this->repositoryMock
            ->shouldReceive('latest10')
            ->once()
            ->andReturn([]);

        $this->call('GET', '/');
        $this->assertResponseOk();

        // 應取得 articles 變數
        // 而其值為空陣列
        $this->assertViewHas('articles', []);
    }
}

新增資料的測試

  • 程式中會透過 Repository 來新增資料,所以也要 mock 新增方法。
  • 因為有用到 POST 方法,所以要考慮 CSRF
  • 新增完成後要導向列表頁

先確認 CSRF 的保護機制是有作用的:

    public function testCsrfFailed()
    {
        // 模擬沒有 token 時
        // 程式應該是輸出 500 Error
        $this->call('POST', 'articles');
        $this->assertResponseStatus(500);
    }

加入 ArticleControllerTest::testCreateArticleSuccess

use Illuminate\Support\Facades\Session;

class ArticleControllerTest extends TestCase {

    // ...

    // 測試新增資料成功時的行為
    public function testCreateArticleSuccess()
    {
        // 會呼叫到 ArticleRepository::create
        $this->repositoryMock
            ->shouldReceive('create')
            ->once();

        // 初始化 Session ,因為需要避免 CSRF 的 token
        Session::start();

        // 模擬送出表單
        $this->call('POST', 'articles', [
            'title' => 'title 999',
            'body' => 'body 999',
            '_token' => csrf_token(), // 手動加入 _token
        ]);

        // 完成後會導向列表頁
        $this->assertRedirectedToRoute('articles.index');
    }

完成新增功能,也就是 store 方法。而 store 方法也會透過 Service Container 來注入 HTTP Request 物件。

use Illuminate\Http\Request;

class ArticleController extends Controller {

    // ...

    /**
     * Store a newly created resource in storage.
     *
     * @param Request $request
     * @return Response
     */
    public function store(Request $request)
    {
        // 直接從 Http\Request 取得輸入資料
        $this->repository->create($request->all());

        // 導向列表頁
        return Redirect::route('articles.index');
    }

redirect 在5.6中,使用use Illuminate\Routing\Redirector,記得要use。 另外,5直接使用::指定Redirect到route;5.6使用Redirector中的Redirect()函式。

use use Illuminate\Routing\Redirector;
class ArticleController extends Controller{
    
    // ......
    // ......
    
    public function store(Request $request)
    {
        // 直接從 Http\Request 取得輸入資料
        $this->repository->create($request->all());

        // 導向列表頁
        return redirect()->route('articles.index');
    }
    
    // ......
    // ......
}

加入表單

剛開始學習較複雜的測試時,即便通過測試,但實際頁面的實作也還是必要的。

  • 真正加入表單頁面。
  • Laravel 5 把 HTML 和 Form 元件拿掉了,要自己加回來。

加入 illuminate/http 套件。

$ composer require illuminate/html

config/app.php 加入:

    'providers' => [

        // ...

        // 加入此行,載入 illuminate/html 的 Service Provider
        'Illuminate\Html\HtmlServiceProvider',

        /*
         * Application Service Providers...
         */
        // ...
    ],

    // ...

    'aliases' => [
        // ...

        // 加入以下兩行,使用 Form 的 facade 介面
        'Form'      => 'Illuminate\Html\FormFacade',
        'HTML'      => 'Illuminate\Html\HtmlFacade',

    ],

according to https://stackoverflow.com/questions/23126562/how-to-remove-a-package-from-laravel-using-composer illuminate/html 已經被拿掉,取而代之的為 laravelcollective/html,所以要將上述的設定做如下修改:

You need to remove this outdated package (taken out of the core and no longer supported):

"illuminate/html": "^5.0", When you remove it, you need to also remove its service providers / aliases. So, if you open up config/app.php, you will see a providers and aliases section. Remove these lines of code if you haven't done so already.

'Illuminate\Html\HtmlServiceProvider'

'Form'=> 'Illuminate\Html\FormFacade', 'HTML'=> 'Illuminate\Html\HtmlFacade', In place of it, you should install the Laravel collective package. To install that, replace the illuminate/html package with this:

"laravelcollective/html": "5.2.*" Then in your config/app.php file, add this to your providers array:

Collective\Html\HtmlServiceProvider::class and this to your aliases array:

'Form' => Collective\Html\FormFacade::class, 'Html' => Collective\Html\HtmlFacade::class,

5.6版做如下調整

 'providers' => [

        // ...

        // 加入此行,載入 illuminate/html 的 Service Provider
        Collective\Html\HtmlServiceProvider::class,

        /*
         * Application Service Providers...
         */
        // ...
    ],
    // ...
'aliases' => [
        // ...

        // 加入以下兩行,使用 Form 的 facade 介面
        'Form' => Collective\Html\FormFacade::class,
        'HTML' => Collective\Html\HtmlFacade::class,

    ],

建立表單,即 resources/views/articles/create.php

  • {!! !!} 輸出 raw data
  • Form::open 會自動加入 _token 的隱藏欄位
  • $error 是一個 ViewErrorBag 物件,用來放置 Session 保留的錯誤訊息
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Create Article</title>
</head>
<body>

{!! Form::open(['route' => 'articles.index']) !!}
<p>
    Title: {!! Form::text('title') !!}
</p>
<p>
    Body: {!! Form::textarea('body') !!}
</p>
<p>
    {!! Form::submit('Create Article') !!}
</p>
{!! Form::close() !!}

@if ($errors->any())
<ul>
    @foreach ($errors->all() as $error)
    <li>{{ $error }}</li>
    @endforeach
</ul>
@endif

</body>
</html>

ArticleController 加入 create 方法:

    /**
     * Show the form for creating a new resource.
     *
     * @return Response
     */
    public function create()
    {
        return view('articles.create');
    }

實際用瀏覽器測試以下網址:

http://localhost/posts/create

驗證測試

  • 利用 Laravel 5 新增的 FormRequest 來做驗證
  • 驗證錯誤訊息與是否有正確保留舊輸入
  • 是否有導回前一頁 (表單頁)

模擬沒有填值即送出表單:

    public function testCreateArticleFails()
    {
        Session::start();

        $this->call('POST', 'articles', [
            '_token' => csrf_token(),
        ]);

        $this->assertHasOldInput();
        $this->assertSessionHasErrors();

        // 應該會導回前一個 URL
        $this->assertResponseStatus(302);
    }

可以在 store 方法中用 $this->validate 來做驗證:

$this->validate($request, [
    'title' => 'required|min:3',
    'body'  => 'required',
]);

也可以用 Form Request 。

  • Form Request 可以被 reuse 。
  • Form Request 可以寫入較複雜的邏輯。

新增 ArticleRequest

$ php artisan make:request ArticleRequest

編輯:

namespace App\Http\Requests;

class ArticleRequest extends Request
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        // 可以在這裡對身份做驗證,避免編輯到別人的資料
        // 暫時先回傳 true
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        // 新增驗證規則
        return [
            'title' => 'required|min:3',
            'body'  => 'required',
        ];
    }
}

ArticleRequest 取代 Http\Request

// 記得修正 import
use App\Http\Requests\ArticleRequest;
use App\Repositories\ArticleRepository;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Redirect;

class ArticleController extends Controller {

    /**
     * Store a newly created resource in storage.
     *
     * @param ArticleRequest $request
     * @return Response
     */
    public function store(ArticleRequest $request)
    {
        // Request 改成 ArticleRequest
        // 以下的程式碼不變
        $this->repository->create($request->all());
        return Redirect::route('articles.index');
    }

加入認證

假設需要登入才可以發表文章,就要加入認證用的 middleware :

  • 直接在 Controller 的 contructor 中用 $this->middleware('auth') 來定義。
  • routes.php 上定義 ['middleware' => 'auth']
    public function __construct(ArticleRepository $repository)
    {
        // 除了列表頁外,其他 action 都加入驗證機制
        // 參考 App\Http\Kernel.php 裡的 $routeMiddleware
        $this->middleware('auth', ['except' => 'index']);

        $this->repository = $repository;
    }

再次執行測試會失敗,因為我們沒有認證成功。

Laravel 提供以下方式來模擬已經通過身份驗證:

$this->be(new User(['email' => 'username@example.com']));

把它放在 TestCase 類別中方便呼叫:

    protected function userLoggedIn()
    {
        $this->be(new User(['email' => 'username@example.com']));
    }

修正測試:

    public function testCreateArticleSuccess()
    {
        // 把 Session::start 移到 setUp

        // 模擬使用者已登入
        $this->userLoggedIn();

        // 以下不變
        // ...
    }

    public function testCreateArticleFails()
    {
        // 把 Session::start 移到 setUp

        // 模擬使用者已登入
        $this->userLoggedIn();

        // 以下不變
        // ...
    }

如果使用者沒登入,可以用以下方法模擬:

    public function testAuthFailed()
    {
        $this->call('POST', 'articles', [
            '_token' => csrf_token(),
        ]);
        $this->assertRedirectedTo('auth/login');
    }

登入與登出的測試

use Illuminate\Support\Facades\Session;

class AuthControllerTest extends TestCase
{
    public function setUp()
    {
        parent::setUp();
        Session::start();
    }

    public function testLoginInvalidInput()
    {
        $this->call('POST', 'auth/login', [
            '_token' => csrf_token(),
        ]);

        $this->assertHasOldInput();
        $this->assertSessionHasErrors();
        $this->assertResponseStatus(302);
    }

    public function testLoginSuccess()
    {
        // Mock Auth Guard Object
        $guardMock = Mockery::mock('Illuminate\Auth\Guard');
        $this->app->instance('Illuminate\Contracts\Auth\Guard', $guardMock);

        /* @see App\Http\Middleware\RedirectIfAuthenticated */
        $guardMock
            ->shouldReceive('check')
            ->once()
            ->andReturn(false);

        /* @see Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers */
        $guardMock
            ->shouldReceive('attempt')
            ->once()
            ->andReturn(true);

        $this->call('POST', 'auth/login', [
            'email'    => 'jaceju@gmail.com',
            'password' => 'password',
            '_token'   => csrf_token(),
        ]);

        $this->assertRedirectedTo('home');
    }

    public function testLoginFailed()
    {
        // Mock Auth Guard Object
        $guardMock = Mockery::mock('Illuminate\Auth\Guard');
        $this->app->instance('Illuminate\Contracts\Auth\Guard', $guardMock);

        /* @see App\Http\Middleware\RedirectIfAuthenticated */
        $guardMock
            ->shouldReceive('check')
            ->once()
            ->andReturn(false);

        /* @see Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers */
        $guardMock
            ->shouldReceive('attempt')
            ->once()
            ->andReturn(false);

        $this->call('POST', 'auth/login', [
            'email'    => 'jaceju@gmail.com',
            'password' => 'password',
            '_token'   => csrf_token(),
        ]);

        $this->assertHasOldInput();
        $this->assertSessionHasErrors();
        $this->assertRedirectedTo('auth/login');
    }

    public function testLogout()
    {
        $this->userLoggedIn();

        // Mock Auth Guard Object
        $guardMock = Mockery::mock('Illuminate\Auth\Guard');
        $this->app->instance('Illuminate\Contracts\Auth\Guard', $guardMock);

        /* @see App\Http\Middleware\RedirectIfAuthenticated */
        $guardMock
            ->shouldReceive('logout')
            ->once();

        $this->call('GET', 'auth/logout');

        $this->assertRedirectedTo('/');
    }

    public function testRegister()
    {

    }

重構測試

    protected function doesLoginPass($pass)
    {
        // Mock Auth Guard Object
        $guardMock = Mockery::mock('Illuminate\Auth\Guard');
        $this->app->instance('Illuminate\Contracts\Auth\Guard', $guardMock);

        /* @see App\Http\Middleware\RedirectIfAuthenticated */
        $guardMock
            ->shouldReceive('check')
            ->once()
            ->andReturn(false);

        /* @see Illuminate\Foundation\Auth\AuthenticatesAndRegistersUsers */
        $guardMock
            ->shouldReceive('attempt')
            ->once()
            ->andReturn($pass);

        $this->call('POST', 'auth/login', [
            'email'    => 'jaceju@gmail.com',
            'password' => 'password',
            '_token'   => csrf_token(),
        ]);

        if ($pass) {
            $this->assertRedirectedTo('home');
        } else {
            $this->assertHasOldInput();
            $this->assertSessionHasErrors();
            $this->assertRedirectedTo('auth/login');
        }
    }
}

心得

  • 跨出把流程圖轉換成測試這步,再把它變成理所當然的開發步驟後,測試先行也沒那麼困難了。

  • 不要想著要接上正式的資料來測試,你應該測試的是程式邏輯是否能正確轉換或存取預期或非預期的資料格式,而不是資料的正確性。

  • 利用 Mock 把要測試的類別分離開來,讓測試的重點專注於類別的職責上。

  • 測試時,利用 Interface + DI 來注入 Mock 物件。

Reference

Example

CSRF

Controller

Email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment