從實例學習 Laravel Testing

知道應該好好做 Testing,但通常沒有那個時間。

簡介

基礎環境

  • Laravel 5.8.14
  • PHP 7.3.4

文章範圍

  • 內建認證(Authentication)功能測試
  • Database Model 測試
  • 自定義 Class 測試

https://github.com/ChiVincent/laravel-testing-example

前置工作

安裝 Laravel

本文假設已經安裝 composer,沒有的話可以參見 Download Composer

可以使用 Laravel 官方所提供的 Laravel installer 來安裝

$ composer global require laravel/installer
$ laravel new testing-example

或是使用 composer 的 create-project 來安裝

$ composer create-project laravel/laravel --prefer-dist

接著,便可以使用以下指令啟動 PHP Built-in Server

$ php artisan serve

設定 Testing Database

一般而言,測試時如果使用 In memory SQLite 會大幅增加測試效率。

config/database.php 中的 connection 裡增加以下內容:

<?php
return [
// ...
    'connections' => [
        // ...  

'sqlite_testing' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => true,
],
        // ...

然後在 phpunit.xml 中建立 <server name="DB_CONNECTION" value="sqlite_testing"/> 的值

<phpunit>
<!-- Ignore -->
<php>
<server name="DB_CONNECTION" value="sqlite_testing"/>
<!--Ignore-->
<php>
</phpunit>

內建認證(Authentication)功能測試

附註:其實內建認證功能如果沒有客製化的話,是不需要進行測試的,因為在 Laravel 中已經有這些測試了,本篇僅是以學習的角度來做。

以腳手架(scaffold)建立基礎認證功能

Laravel 內建相當方便的 scaffold 以建立基礎的認證功能:

  • 註冊
  • 登入、登出
  • 忘記密碼、重設密碼

可以利用以下指令建立相關的內容

$ php artisan make:auth

測試註冊功能

首先利用 php artisan make:test Auth/RegisterTest 建立註冊功能的 Test Case,並且移除預設的 test case。

Step 1. 測試註冊頁面

藉由 php artisan route:list 我們可以知道註冊頁面的路由是 /register

所以我們的 Test Case 可以這樣寫:

public function testRegisterPage()
{
$response = $this->get(route('register'));
    $response->assertSuccessful();
}

這裡使用 route('register') 的主要原因在於:如果未來 register 的路由產生變化,其名稱也不應該改變:這比使用 $this->get('/register') 要來得好。

Step 2. 測試註冊功能

當使用者填完資料後,按下「送出表單」的那一刻,程式會有以下動作:

  1. 使用者的密碼會被 Hash
  2. 使用者的資料會被存進 Database
  3. 使用者會被跳轉到 Home

所以我們的 Test Case 可以這樣寫

use RefreshDatabase;
public function testRegister()
{
$user = factory(User::class)->make();
    $response = $this->post(route('register'), [
'name' => $user->name,
'email' => $user->email,
'password' => 'password',
'password_confirmation' => 'password',
]);
    $response->assertRedirect(route('home'));
$this->assertDatabaseHas('users', [
'name' => $user->name,
'email' => $user->email,
]);
$this->assertTrue(
Hash::check('password', User::where('email', $user->email)->first()->password)
);
}

我們使用 RefreshDatabase 這個 Trait,以確保我們每次測試時的資料庫都是乾淨的。

Step 3. 測試登入頁面與功能

use RefreshDatabase;
public function testLoginPage()
{
$response = $this->get(route('login'));
    $response->assertSuccessful();
}
public function testLogin()
{
$user = factory(User::class)->create();
    $response = $this->post(route('login'), [
'email' => $user->email,
'password' => 'password',
]);
    $response->assertRedirect(route('home'));
$response->assertTrue(Auth::check());
}

因為與註冊並沒有太大差別,所以把頁面與功能測試放在一起寫。

值得注意的是, factory(User::class)->create() 的資料,預設密碼是 password ,可以在 database/factories/UserFactory.php 裡查到相關的資訊。

Step 4. 測試忘記密碼

use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Support\Facades\Notification;
class ForgotPasswordTest extends TestCase
{
use RefreshDatabase;
    public function testForgotPasswordPage()
{
$response = $this->get(route('password.request'));
        $response->assertSuccessful();
}
    public function testForgotPassword()
{
Notification::fake();
$user = factory(User::class)->create();
        $response = $this->post(route('password.email'), [
'email' => $user->email
]);
        $response->assertRedirect('/');
$this->assertDatabaseHas('password_resets', [
'email' => $user->email,
]);
Notification::assertSentTo($user, ResetPassword::class);
}

重設密碼的流程的測試稍嫌複雜:

  • 確定重設記錄有被放進 password_resets 這個表
  • 確定 ResetPassword 這個 Notification 有被送出(這是 Laravel 內建,事實上也能夠自己修改它)

Step 5. 測試重設密碼

use RefreshDatabase;
public function testResetPasswordPage()
{
$response = $this->get(route('password.reset', [
'token' => 'fake-token'
]));
    $response->assertSuccessful();
}
public function testResetPassword()
{
$user = factory(User::class)->create();
DB::insert(
'INSERT INTO password_resets (`email`, `token`, `created_at`) VALUES(?, ?, ?)',[
$user->email,
Hash::make('custom-token'),
now()
]);
    $response = $this->post(route('password.update'), [
'email' => $user->email,
'token' => 'custom-token'
'password' => 'new_password',
'password_confirmation' => 'new_password'
);
$response->assertRedirect(route('home'));
$this->assertDatabaseMissing('password_resets', [
'email' => $user->email,
]);
$this->assertTrue(
Hash::check('new_password', User::find($user->id)->password)
);
}

在測試重設密碼之前,需要先塞入假的 Reset Token(就是會通過 Email 寄給使用者的那個)

所以在 testResetPassword() 中,我使用了 DB::insert 去把該值塞入,值得注意的是,該值會先被 Hash 過才被放進資料庫。

Database Model 測試

我個人在進行 Database Model 測試時有一些準則:

  • Database Model 的測試屬於 Unit Test
  • 盡量極小化測試的範圍,並且僅對自定義內容做測試

自定義函式

假設我在 app/User.php 中定義了一個函式,用來取得用戶的 Gravatar 大頭貼

public function getGravatar(): string
{
return 'https://www.gravatar.com/avatar/' . md5(strtolower(trim($this->email)));
}

那我便會在 tests/Unit/Models/UserTest.php 中建立該測試

public function testGetGravatar()
{
$user = factory(User::class)->create();
    $this->assertSame('https://www.gravatar.com/avatar/' . md5(strtolower(trim($this->email))), $user->getGravatar());
}

與其它 Model 的 Relationship

假設存在 app/Post.php 表示「用戶的貼文」
我們可以使用 php artisan make:model -m -f Post 來建立 Post Model、Migration 以及 Test Factory

// app/Post.php
class Post extends Model
{
public $fillable = ['user_id', 'title', 'content'];
}
// database/migrations/create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('user_id');
$table->string('title');
$table->text('content');
}
// database/factories/PostFactory.php
$factory->define(Post::class, function (Faker $faker) {
return [
'user_id' => factory(\App\User::class)->create(),
'title' => $faker->sentence,
'content' => implode("\n", $faker->sentences(3)),
];
});

分別在 app/User.phpapp/Post.php 中建立其關聯

// app/User.php
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
// app/Post.php
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}

然後就可以寫測試

// tests/Unit/Models/UserTest.php
public function testPostsRelation()
{
$user = factory(User::class)->create();
$posts = factory(Post::class)->times(3)->create([
'user_id' => $user,
]);
    $this->assertEquals($posts->toArray(), $user->posts->toArray());
}
// tests/Models/PostTest.php
public function testAuthorRelation()
{
$user = factory(User::class)->create();
$post = factory(Post::class)->create([
'user_id' => $user
]);
    $this->assertEquals($user->toArray(), $post->author->toArray());
}

自定義 Class 測試

有些時候,我們會自定義一些 Class,可能是 Service 或 Repository,以下進行這些自定義 Class 的測試。

關於 Service 及 Repository Pattern 可以參考 Laravel 的中大型專案架構

基礎的 Service Test Case

建立一個 PostCreateService.php 用來控制發佈貼文的行為:

// app/Services/PostCreateService.php
class PostCreateService
{
public function post(User $author, string $title, string $content): Post
{
return Post::create([
'user_id' => $author->id,
'title' => $title,
'content' => $content,
]);
}
}

與上面的 Test Case 無異,我們可以寫出下面的 Test Case:

// tests/Unit/Services/PostCreateServiceTest.php
public function testPost()
{
$user = factory(User::class)->create();
$post = factory(Post::class)->make(['user_id' => null]);
    $service = app(PostCreateService::class);
$result = $service->post($user, $post->title, $post->content);
    $this->assertInstanceOf(Post::class, $result);
$this->assertDatabaseHas('posts', [
'user_id' => $user->id,
'title' => $post->title,
'content' => $post->content,
]);
}

需要 Mock 的 Test Case

有某些國家,在張貼言論之前要先經過審核,確定該名用戶沒有反動思想。假設在 app/User.php 中有個 method 會去審核先前的言論,然後計算出該用戶的芝麻信用點數,以決定該使用者是否能夠發文。

// app/Services/PostCreateService.php
public function post(User $user, string $title, string $content): Post
{
if (!$user->canPostArticle()) {
throw new \Exception('您的芝麻信用点数不足,请充值,亲');
}
    return Post::create([
'user_id' => $user->id,
'title' => $post->title,
'content' => $post->content,
]);
}

然而這個 canPostArticle 可能依賴於外部系統(偉大的黨的中央系統),在測試時不能夠一直向其發 Request,所以我們需要模擬它。

public function testPostButCannotPost()
{
$user = $this->mock(User::class);
$user->shouldReceive('canPostArticle')->once()->andReturn(false);
$post = factory(Post::class)->make(['user_id' => null]);
    $this->expectException(\Exception::class);
$service->post($user, $post->title, $post->content);
}

總結

事實上,這篇文章中講到的東西並不多,只筆記一些我平常用的技巧。

不過有些謹記部份原則,有助於寫出更好的 Test Case:

  • 只測試 Public Method,Protected 及 Private Method 應該藉由 Public Method 去測試
  • 不要求一定要達成 100% Test Coverage,有些像是 Database Connection Failed 的東西很難在 Unit Test 中被測出來