直接使用 MySQL 與 Test Factory Generator 測試 Repository

使用 SQLite In-Memory 的方式雖然又快又方便,但若使用了 DB::rawwhereRaw() 的寫法,由於直接使用了 MySQL 的函式,可能 SQLite 並不支援,就必須直接在 MySQL 跑測試。

雖然我們會另外開一個資料庫做測試,但也有可能我們想直接對正式有資料的資料庫做測試,我們該怎麼快速無痛切換呢?

Version


Laravel 5.2.30
PHPUnit 4.8.24
PhpStorm 2016.1

Repository模式


初學者常會商業邏輯與資料庫邏輯同時寫在 controller 內,如我們想將最新的 3 筆文章顯示在 view。

app/Http/Controllers/PostsController.php
1
2
3
4
5
6
7
8
9
10
class PostsController extends Controller {
{
$posts = Post::orderBy('id', 'desc')
->take(3)
->get();

$data = compact($posts);

return View('posts.index', $data);
}

這段程式碼在執行上沒有問題,但在設計上有幾個問題 :

  1. 違反 SOLID 的單一職責原則,controller 原本該有的職責應該是商業邏輯,但現在卻將資料庫邏輯直接寫在 controller 內,這已經超出原本 controller 的職責,將會導致日後 controller 過於肥大而難以維護。1 1單一職責原則 : 應該且僅有一個原因引起 class 的變更。

  2. 將資料庫邏輯直接寫在 controller 內,將來若有不同 controller 使用相同的資料庫邏輯,將無法重複使用。

  3. 由於 controller 內直接使用 Eloquent,表示 controller 直接相依於Post model,若我們要對 controller 做單元測試,必須直接存取資料庫,這違反了隔離測試 ( isolated test ) 原則。2 2Isolated Test : 1.執行速度快 2.關注點分離 3.單一職責 4.可測試性 5.測試程式的健壯性

  4. 為了隔離測試,我們會希望 mock 掉資料庫邏輯,然後透過依賴注入將 mock 物件注入 contoller,但在 controller 直接使用 model,導致無法使用依賴注入,因此無法執行單元測試。

比較好的方式是使用 repository 模式,將資料庫邏輯從 controller 中獨立出來寫在 repository,透過依賴注入將 repository注入controller。3 3詳細請參考如何使用 Repository 模式?

使用 repository 模式有以下好處 :

  1. Repository 專心負責資料庫邏輯,符合單一職責原則,可避免 controller 過於肥大而難以維護。

  2. 資料庫邏輯從 controller 搬到 repository,因此不同的 controller 可以重複使用。

  3. controller 不再直接相依於 model,而是透過依賴注入將 repository 注入 controller,符合依賴反轉原則,且測試時不用直接存取資料庫,達到隔離測試要求。4 4依賴反轉原則 : 高階模組不該依賴低階模組,兩者都該依賴其抽象。抽象不要依賴細節,細節要依賴抽象。

  4. 單元測試時直接 mock 掉 repository 即可,並透過依賴注入將 repository 注入到 controller,不用特別去 mock Eloquent model。

將資料庫邏輯從 controller 搬到 repository 之後,本文的重點就是討論該如何測試 repository 內的資料庫邏輯。

Test Factory Generator


Laravel 5 提出了 model factory,直接整合了 faker,讓我們在 seeding 與 testing 時更為方便,而 Test Factory Generator 會自動根據 migration 產生 model factory,讓我們連 model factory 都不用寫。

安裝

1
oomusou@mac:~/MyProject$ composer require mpociot/laravel-test-factory-helper --dev

使用 composer 安裝 Test Factory Generator,因為此套件只會在開發使用,可以加上 --dev 參數。5 5關於 --dev 參數,詳細請參考如何使用 Laravel Debugbar #使用 Composer 安裝

Service Provider

1
Mpociot\LaravelTestFactoryHelper\TestFactoryHelperServiceProvider::class,

config/app.php 中加入 TestFactoryHelperServiceProvider6 6GitHub Commit : 安裝 Laravel Test Factory Generator

建立 Model 與 Migration


1
oomusou@mac:~/MyProject$ php artisan make:model Post -m

建立 Post model 與 migration,-m 讓我們在建立 model 時一併建立 migrarion。

會在 app 目錄下建立 Post.php,並在 database/migrations 目錄建立 migration 檔。

Post.php 7 7GitHub Commit : 建立Post.php

app/Post.php
1
2
3
4
5
6
7
8
9
10
11
12
namespace App;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{

protected $fillable = [
'title',
'description',
'content'
];
}

第7行

1
2
3
4
5
protected $fillable = [
'title',
'description',
'content'
];

定義當使用 mass assignment 時可以被修改的欄位,進而保護其他欄位不被修改。

create_posts_table.php 8 8GitHub Commit : create_posts_table.php

database/migrations/2015_10_14_113810_create_posts_table.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreatePostsTable extends Migration
{

/**
* Run the migrations.
*
* @return void
*/

public function up()
{

Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->string('description');
$table->text('content');
$table->timestamps();
});
}

/**
* Reverse the migrations.
*
* @return void
*/

public function down()
{

Schema::drop('posts');
}
}

13行

1
2
3
4
5
6
7
 Schema::create('posts', function (Blueprint $table) {
$table->increments('id');
$table->string('title');
$table->string('description');
$table->text('content');
$table->timestamps();
});

加入 titledescriptioncontent 3 個欄位。

正式資料庫連線

將來 Test Factory Generator 必須連上資料庫才能產生 model factory,必須西確定專案已經與資料庫順利連線。

正式資料庫執行 Migrate

1
oomusou@mac:~/MyProject$ php artisan migrate

執行 migrate 將 table 建立在 MySQL 的正式資料庫。

建立 Model Factory


測試 repository 時,將使用 model factory 幫我們建立測試資料。

1
oomusou@mac:~/MyProject$ php artisan test-factory-helper:generate

Test Factory Generator 幫我們自動建立 model factory。9 9GitHub Commit : 建立Model Factory

新增 MySQL 測試資料庫連線


之前設定的是 MySQL 正式資料庫連線,但在單元測試時,我們希望讀寫在另外一個 MySQL 資料庫。

新增資料庫連線

先在 MySQL 建立 homestead_testing 資料庫,並在 .env 增加 DB_DATABASE_TESTING,設定為 homestead_testing

DB_CONNECTION 先改成 mysql_testing,稍後會建立此連線,目的在替測試資料庫跑 migration,跑完 migration 之後會再切回來。

database.php10 10GitHub Commit : 新增 mysql_testing 連線

config/database.php
1
2
3
4
5
6
7
8
9
10
11
12
13
'mysql_testing' => [
'driver' => 'mysql',
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE_TESTING', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
'strict' => false,
'engine' => null,
],

config/database.php 新增 mysql_testing 連線。

database key 的 value 改為 env('DB_DATABASE_TESTING', 'forge')

測試資料庫執行 Migrate

1
oomusou@mac:~/MyProject$ php artisan migrate

執行 migrate 將 table 建立在 MySQL 的測試資料庫。

目前為止,在正式資料庫與測試資料庫都已經跑過 migration。

.env 的資料庫連線從測試資料庫改為正式資料庫的 mysql

修改 phpunit.xml
phpunit.xml11 11GitHub Commit : 在 phpunit.xml 新增 DB_CONNECTION

phpunit.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="bootstrap/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">

<testsuites>
<testsuite name="Application Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./app</directory>
<exclude>
<file>./app/Http/routes.php</file>
</exclude>
</whitelist>
</filter>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="mysql_testing"/>
</php>
</phpunit>

24行

1
2
3
4
5
6
7
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="mysql_testing"/>
</php>

可在此建立 APP_ENVtesting 時的全域變數。

設定 DB_CONNECTIONmysql_testing,當跑測試時,將會使用 mysql_testing 資料庫連線。

測試 MySQL 測試資料庫連線


ExampleTest.php 為 Laravel 預設的測試範例,其中包含了 testBasicExample(),示範了如何測試預設的 welcome.blade.php 是否正確執行。

ExampleTest.php12 12GitHub Commit : 新增 MySQL 測試資料庫連線

tests/ExampleTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use App\Post;

class ExampleTest extends TestCase
{

/**
* A basic functional test example.
*
* @return void
*/

public function testBasicExample()
{

$this->visit('/')
->see('Laravel 5');
}

/**
* @test
*/

public function MySQL測試料庫連線()
{

/** arrange */
$expected = 0;

/** act */
$actual = Post::all();

/** assert */
$this->assertCount($expected, $actual);
}
}

16行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* @test
*/

public function MySQL測試料庫連線()
{

/** arrange */
$expected = 0;

/** act */
$actual = Post::all();

/** assert */
$this->assertCount($expected, $actual);
}

由於只是測試連線是否成功,尚未跑 model factory,因此預期 Post model 的資料筆數為 0

實際跑測試,綠燈 表示連線成功。13 13關於如何在 PhpStorm 跑單元測試,詳細請參考如何使用 PhpStorm 測試與除錯?

為什麼不需在 setUp() 下 Artisan::call('migrate:migrate')?

若使用 SQLite In-Memory,只要資料庫連線一斷,SQLite 會自動釋放記憶體,也就是說,當每個測試案例執行開始時,因為資料庫重新建立,所以必須重新跑一次 migration,但若使用 MySQL 測試,因為測試資料庫一直存在於 MySQL,因此不需再跑 migration。

以 TDD 建立 Repository


之前的所有動作都只是為了建立 repository 的測試環境,接下來將以 TDD 的方式建立 PostRepository

建立 PostRepository 的單元測試

1
oomusou@mac:~/MyProject$ php artisan make:test Unit/Repositories/PostRepositoryTest

PostRepositoryTest.php14 14GitHub Commit : 建立 PostRepositoyTest.php

tests/Unit/Repositories/PostRepositoryTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;

class PostRepositoryTest extends TestCase
{

/**
* A basic test example.
*
* @return void
*/

public function testExample()
{

$this->assertTrue(true);
}
}

PostRepositoryTest.php 建立在 tests/Unit/Repositories 目錄下。

實務上測試分 3 種,有單元測試整合測試驗收測試

Repository 測試屬於單元測試,故建立在 Unit 目錄下,將來還有 Integration 目錄放整合測試,與 Acceptance 放驗收測試。

所建立的 PostRepositoryTest 也繼承於 TestCase

最新 3 筆文章
TDD 要我們先寫測試再寫程式,在 PostRepository 實作抓最新的 3 筆文章前,必須先將其測試先寫好。

PostRepositoryTest.php15 15GitHub Commit : 建立最新 3 筆文章

tests/Unit/Repositories/PostRepositoryTest.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use App\Post;
use Illuminate\Foundation\Testing\DatabaseTransactions;

class PostRepositoryTest extends TestCase
{

use DatabaseTransactions;

/**
* @test
*/

public function 最新3筆文章()
{

/** arrange */
factory(Post::class, 100)->create();
$target = App::make(PostRepository::class);
$expected = 3;

/** act */
$actual = $target->getLatest3Posts()->count();

/** assert */
$this->assertEquals($expected, $actual);
}
}

Arrange
負責建立要測試的資料,因為我們想要抓最新 3 筆文章,所以先使用 model factory 新增 100 筆測試資料進 MySQL。

建立 $target 待測物件,一律使用 App::make() 建立物件,不再使用new建立物件。

建立 $expected,也就我們預期會傳回最新 3 筆文章的結果。

Act
實際執行 $targetgetLatest3Posts(),由於我們只想測試是否能傳回 3 筆資料,使用 collection 的 count() 計算筆數。

Assert
使用 PHPUnit 的 assertEquals(),判斷 $expected$actual 是否相等,若相等則 綠燈,不相等則 紅燈

第 7 行

1
use DatabaseTransactions;

若使用SQLite In-Memory,因為每次資料庫連線一斷,SQLite 就會自動釋放記憶體,因此每次使用 model factory 建立的假資料,並不會殘存在 SQLite In-Memory 內,但目前使用的是 MySQL,資料是實際存在 MySQL 內,並不會自動刪除,若一直跑測試,則 MySQL 的測試資料將越來越多,這樣每次跑測試時,由於資料庫的資料並不相同,所以可能每次跑的結果都不一樣。

Laravel 提供了 DatabaseTransactions trait,只要 use 以後,每次測試完,就會自動幫我們將假資料從 MySQL 刪除,這樣就可以確保我們每次執行測試的結果都是相同的。

到底 use DatabaseTransactions; 有什麼黑魔法,能使得每次測試完就會自動刪除資料呢?
vendor/laravel/framework/src/Illuminate/Foundation/Testing/DatabaseTransactions.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
namespace Illuminate\Foundation\Testing;

trait DatabaseTransactions
{
/**
* Handle database transactions on the specified connections.
*
* @return void
*/

public function beginDatabaseTransaction()
{

$database = $this->app->make('db');

foreach ($this->connectionsToTransact() as $name) {
$database->connection($name)->beginTransaction();
}

$this->beforeApplicationDestroyed(function () use ($database) {
foreach ($this->connectionsToTransact() as $name) {
$database->connection($name)->rollBack();
}
});
}

/**
* The database connections that should have transactions.
*
* @return array
*/

protected function connectionsToTransact()
{

return property_exists($this, 'connectionsToTransact')
? $this->connectionsToTransact : [null];
}
}

第 10 行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Handle database transactions on the specified connections.
*
* @return void
*/

public function beginDatabaseTransaction()
{

$database = $this->app->make('db');

foreach ($this->connectionsToTransact() as $name) {
$database->connection($name)->beginTransaction();
}

$this->beforeApplicationDestroyed(function () use ($database) {
foreach ($this->connectionsToTransact() as $name) {
$database->connection($name)->rollBack();
}
});
}

當每個測試案例執行時,Laravel 會以 beginTransaction() 處理,當測試案例結束時,會再以 rollBack() 處理,因此最後假資料不會寫進資料庫。

執行測試

得到第 1 個 紅燈 : PostRepository 不存在,因為我們還沒有建立。

事實上 PhpStorm 也將 PostRepository 反白,警告我們 PostRepository 並不存在。

PostRepository.php16 16GitHub Commit : 建立 PostRepository.php

app/Repositories/PostRepository.php
1
2
3
4
5
6
namespace App\Repositories;

class PostRepository
{


}

補上 PostRepository.php 後,繼續執行測試。

得到第 2 個 紅燈 : getLatest3Posts() 不存在,因為我們還沒有建立。

事實上 PhpStorm 也將 getLatest3Posts() 反白,警告我們 getLatest3Posts() 並不存在。

PostRepository.php

app/Repositories/PostRepository.php
1
2
3
4
5
6
7
8
namespace App\Repositories;

class PostRepository
{

public function getLatest3Posts()
{

}
}

補上 getLatest3Posts() 後,繼續執行測試。

得到第 3 個 紅燈 : 對 一個 null 去執行 count(),因為我們還沒寫 getLatest3Post() 內的程式碼。

事實上 PhpStorm也將pluck() 反白,警告我們 getLatest3Posts() 尚未傳回任何物件。

PostRepository.php17 17GitHub Commit : 回傳 collection

app/Repositories/PostRepository.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace App\Repositories;

use App\Post;
use Illuminate\Database\Eloquent\Collection;

class PostRepository
{

/**
* @return Collection
*/

public function getLatest3Posts()
{

return Post::orderBy('id', 'desc')
->take(3)
->get();
}
}

getLatest3Posts() 補上 query 後回傳 collection 後,繼續執行測試。

得到第 1 個 綠燈

隨著 綠燈 的出現,我們也完成了 repository。

切換到 MySQL 正式資料庫


既然都是在 MySQL 測試,我們可能想將測試實際跑在有正式上線資料的資料庫,而不只是跑在測試資料庫。

新增 phpunit_acceptance.xml
phpunit.xml 複製一個新的 phpunit_acceptance.xml

phpunit_acceptance.xml 18 18GitHub Commit : 新增 phpunit_acceptance.xml

phpunit_acceptance.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="bootstrap/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">

<testsuites>
<testsuite name="Application Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./app</directory>
<exclude>
<file>./app/Http/routes.php</file>
</exclude>
</whitelist>
</filter>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="mysql"/>
</php>
</phpunit>

24行

1
2
3
4
5
6
7
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="DB_CONNECTION" value="mysql"/>
</php>

DB_CONNECTIONmysql_testing 改成 mysql,也就是我們想將測試直接跑在正式資料庫。

切換 phpunit.xml

PhpStorm -> Preferences -> Languages & Frameworks -> PHP -> PHPUnit

Default configuration file 改成剛剛建立的 phpunit_acceptance.xml

重新跑測試,目前的 綠燈 是跑在 mysql 這個資料庫連線。

使用 CLI 方式執行 phpunit


若你不習慣在 PhpStorm 內跑測試也沒關係,也可以使用 CLI 方式執行 phpunit。

1
oomusou@mac:~/MyProject$ vendor/bin/phpunit -c phpunit_acceptance.xml

只要加上 -c 參數,並指定你要使用的 phpunit_acceptance.xml 即可。

Conclusion


  • 由於單元測試的資料量很少,其實直接跑在 MySQL 的速度也是很快,而且也不用擔心 SQLite 與 MySQL 的差異。
  • 若直接跑在 MySQL,則建議不必每次在 setup() 去跑 migration,直接先跑 migration,然後搭配 DatabaseTransactions trait。

Sample Code


完整的範例可以在我的GitHub上找到。