如何在開發的過程中加入測試。php
創建一個須要登入的文章發表系統。html
雖然簡單,但足夠我們對 Laravel 5 有基本的理解了。laravel
更完整的專案實做,能夠參考 Laracasts 上的 Laravel 5 Fundamentals 一系列影片。git
$ composer create-project laravel/laravel demo $ cd demo
安裝 Mockery :github
$ composer require mockery/mockery --dev
測試資料庫存取時,要儘可能不動到正式資料庫,並且要能快速創建sql
也儘可能把設定都放在測試可控制的環境,不要跟主程式有牽扯。session
:memory:
來測試創建 Article model :app
$ php artisan make:model Article -m
這將會:composer
app/Article.php
database/migrations/xxxx_xx_xx_xxxxxx_create_articles_table.php
在 Article.php
加入如下屬性:ide
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
方法加入 title
、 body
兩個欄位:
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'); }
新增 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
因為 ORM 已經幫我們實做 Model 存取資料的相關機制,我們只是先測試驗證它沒有問題;因此實際上不須要特別測試 Model ,這裡只是為了確認測試是能夠正確運做的。
好的 Pattern 是用 Repository 把 Model 封裝起來。
創建 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); }
測試成功。
修改 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.php
的 default
設定:
'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();
創建 Controller 與 View :
$ php artisan make:controller ArticleController --plain $ mkdir -p resources/views/articles $ touch resources/views/articles/index.blade.php
若是不加 --plain
,會預設加入 index
、 create
、 store
... 等操做 resource 的相關方法。
在 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
確認有沒有正確加入。
把原來的 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'); } }
測試成功。
call
方法執行 route,進而創建 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')); }
測試不成功,因為我們沒有連接資料庫。
ArticleRepository
生成假物件 (mock object)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', []); } }
先確認 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'); }
剛開始學習較複雜的測試時,即使通過測試,但實際頁面的實做也還是必要的。
加入 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', ],
創建表單,即 resources/views/articles/create.php
:
{!! !!}
輸出 raw dataForm::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
模擬沒有填值即送出表單:
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 。
新增 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 :
$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 物件。