如何對 Collection 做 Assertion?
Collection
並非 PHP 原生的型別,是 Laravel 所擴充,因此 PHPUnit 並無法直接對其做 assertion,本文介紹兩種方式,一種是使用 PHPUnit 的 assertArraySubset()
,一種是自己寫 collection macro,各有其優缺點,可視需求決定要使用哪種方法。
Motivation
實務上天天都有驗證 Collection
的需求,在如何測試 Repository 模式(使用 MySQL)?與如何測試 Repository 模式(使用 SQLite)? 一文中,對 Collection
都沒有提出系統化的 assertion 方式,本文整理出實務上我最常用的兩種測試手法。
Version
PHP 7.0.0
Laravel 5.2.37
實際案例
我們將以 Post
model 為例,顯示所有文章
,並寫單元測試判斷結果是否如預期。
單元測試
無論是對 repository 或 service 做單元測試,當其 field 或 method 回傳值為 Collection
時,就必須面對如何 assertion 的問題。
使用 assertArraySubset()
PostServiceTest.php1 1GitHub Commit : 第一種測試方法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
34use App\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class PostServiceTest extends TestCase
{
use DatabaseMigrations;
/** @test */
public function 顯示所有文章1()
{
/** arrange */
collect(range(1, 3))->each(function ($value) {
Post::create([
'title' => "title{$value}",
'description' => "desc{$value}",
'content' => "content{$value}"
]);
});
/** act */
$actual = app(PostService::class)
->displayAllPosts()
->toArray();
/** assert */
$expected = [
['title' => 'title1', 'description' => 'desc1', 'content' => 'content1'],
['title' => 'title2', 'description' => 'desc2', 'content' => 'content2'],
['title' => 'title3', 'description' => 'desc3', 'content' => 'content3'],
];
$this->assertArraySubset($expected, $actual);
}
}
12 行1
2
3
4
5
6
7
8/** arrange */
collect(range(1, 3))->each(function ($value) {
Post::create([
'title' => "title{$value}",
'description' => "desc{$value}",
'content' => "content{$value}"
]);
});
由於單元測試是使用 SQLite in Memory 為資料庫,只要測試一結束,記憶體就會釋放,因此每次測試都要重新新增資料。
使用 Collection->each()
將假資料透過 Post::create()
新增。
21 行1
2
3
4
5/** act */
/** act */
$actual = app(PostService::class)
->displayAllPosts()
->toArray();
測試 PostService->displayAllPosts()
。2 2此時 PostService
與 displayAllPost()
都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostService
與 displayAllPost()
。
displayAllPosts()
回傳的是 Collection
,但 PHPUnit 無法對 Collection
做 assertion,必須先轉成 array。
26 行1
2
3
4
5
6$expected = [
['title' => 'title1', 'description' => 'desc1', 'content' => 'content1'],
['title' => 'title2', 'description' => 'desc2', 'content' => 'content2'],
['title' => 'title3', 'description' => 'desc3', 'content' => 'content3'],
];
$this->assertArraySubset($expected, $actual);
這裡不能使用 assertEquals()
,因為 posts
table 還包含 created_at
與 updated_at
兩個欄位,若使用 assertEquals()
一定失敗,必須改用 assertArraySubset()
。
也就是說,$expected
並不用包含 Collection
的所有欄位,只要包含你想測試的欄位即可。
剩下的 PostService
與 PostRepository
就以 TDD 的方式建立,在此不再贅述。3 3若對剩下的步驟有興趣,詳細請參考 如何使用 PhpStorm 重構 Namespace?
使用 Collection Macro
使用 assertArraySubset()
雖然可行,但為了配合 Collection->toArray()
的格式,$expected
必須寫的比較繁瑣,連 key 都要交代,若 Collection
只傳會我要驗證欄位的資料給 $actual
,且 $expected
也只包含要驗證的資料,那測試程式就非常的簡潔。
PostServiceTest.php4 4GitHub Commit : 第二種測試方法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
36
37
38
39
40use App\Post;
use App\Services\PostService;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class PostServiceTest extends TestCase
{
use DatabaseMigrations;
/** @test */
public function 顯示所有文章2()
{
/** arrange */
collect(range(1, 3))->each(function ($value) {
Post::create([
'title' => "title{$value}",
'description' => "desc{$value}",
'content' => "content{$value}"
]);
});
/** act */
$actual = app(PostService::class)
->displayAllPosts()
->pick([
'title',
'description',
'content'
])
->all();
/** assert */
$expected = [
['title1', 'desc1', 'content1'],
['title2', 'desc2', 'content2'],
['title3', 'desc3', 'content3'],
];
$this->assertEquals($expected, $actual);
}
}
21 行1
2
3
4
5
6
7
8
9/** act */
$actual = app(PostService::class)
->displayAllPosts()
->pick([
'title',
'description',
'content'
])
->all();
使用 pick()
從 Collection
只抓回要驗證欄位的資料,只要將欄位名稱
以陣列方式傳入 pick()
即可。
但 Collection
並沒有內建 pick()
,稍後會自己建立 collection macro。
all()
為 Collection
內建 method,將 Collection
轉成陣列。
31 行1
2
3
4
5
6
7
8/** assert */
$expected = [
['title1', 'desc1', 'content1'],
['title2', 'desc2', 'content2'],
['title3', 'desc3', 'content3'],
];
$this->assertEquals($expected, $actual);
因為已經被 pick()
轉成簡單的陣列,且不含 key,可以簡單的使用 assertEquals()
來做 assertion。
AppServiceProvider.php5 5GitHub Commit : 新增 Collection::macro(), pick()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
34namespace App\Providers;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
//
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
Collection::macro('pick', function ($columns) {
return collect($this->items)->map(function ($value) use ($columns) {
return collect($columns)
->map(function ($column) use ($value) {
return $value->$column;
})->all();
});
});
}
}
因為 pick()
並非 Collection
內建的 method,因此我們必須自己在 service provider 建立 pick
這個 collection macro。
25 行1
2
3
4
5
6
7
8Collection::macro('pick', function ($columns) {
return collect($this->items)->map(function ($value) use ($columns) {
return collect($columns)
->map(function ($column) use ($value) {
return $value->$column;
})->all();
});
});
我們將使用 map()
將 $columns
所要的欄位顯示 map()
回去。
因為 $columns
是陣列,我們要處理的是 $columns
每個元素的值,依此還要再用一層 map()
。
重點在於只回傳 $column
欄位的值,因此我們使用 PHP 的變數讀取屬性的方式,以 $value->$column
的技巧,只回傳所需要欄位的值。6 6詳細請參考如何使用變數讀取 property?
因為牽涉到兩層巢狀 closure,所以程式可讀性較差,實務上建議 closure 只寫一層,不要寫兩層以上。
不過因為這段程式碼不需要維護,只要複製貼上到 AppServiceProvider.php
的 register()
即可,我們的目的是要用 pick()
,讓我們的測試程式更加精簡,不會去維護這段程式碼,因此還可以接受。
Conclusion
- 由於 PHPUnit 無法直接對
Collection
做 assertion,assertArraySubset()
算是不滿意但可以接受的方式。 pick()
比較接近我理想中對Collection
的 assertion 方式,但必須使用 collection macro 在 service provider 對Collection
做擴充,但只要複製貼上即可,以後就可以當成Collection
原生的 method 使用。- 目前
pick()
只支援Illuminate\Database\Eloquent\Collection
,並不支援Illuminate\Support\Collection
,實務上無論是 repository 的單元測試,或是 service 的單元測試或整合測試,多半面對的是從 Eloquent 來的Collection
,也就是Illuminate\Database\Eloquent\Collection
,所以在實務上pick()
非常好用。
Sample Code
完整的範例可以在我的 GitHub 上找到。
Reference
- Freek Ven der Herten, Using collection macros in Laravel