介紹兩種方法對 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 : 第一種測試方法

tests/Unit/PostServiceTest.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
use 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此時 PostServicedisplayAllPost() 都還沒建立,TDD 會等待測試亮 紅燈 時,才去新增 PostServicedisplayAllPost()

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_atupdated_at 兩個欄位,若使用 assertEquals() 一定失敗,必須改用 assertArraySubset()

也就是說,$expected 並不用包含 Collection 的所有欄位,只要包含你想測試的欄位即可。

剩下的 PostServicePostRepository 就以 TDD 的方式建立,在此不再贅述。3 3若對剩下的步驟有興趣,詳細請參考 如何使用 PhpStorm 重構 Namespace?

使用 Collection Macro


使用 assertArraySubset() 雖然可行,但為了配合 Collection->toArray() 的格式,$expected 必須寫的比較繁瑣,連 key 都要交代,若 Collection只傳會我要驗證欄位的資料給 $actual,且 $expected 也只包含要驗證的資料,那測試程式就非常的簡潔。

PostServiceTest.php4 4GitHub Commit : 第二種測試方法

tests/Unit/PostServiceTest.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
36
37
38
39
40
use 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()

app/Providers/AppServiceProvider.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
namespace 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
8
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();
});
});

我們將使用 map()$columns 所要的欄位顯示 map() 回去。

因為 $columns 是陣列,我們要處理的是 $columns 每個元素的值,依此還要再用一層 map()

重點在於只回傳 $column 欄位的值,因此我們使用 PHP 的變數讀取屬性的方式,以 $value->$column 的技巧,只回傳所需要欄位的值。6 6詳細請參考如何使用變數讀取 property?

因為牽涉到兩層巢狀 closure,所以程式可讀性較差,實務上建議 closure 只寫一層,不要寫兩層以上。

不過因為這段程式碼不需要維護,只要複製貼上到 AppServiceProvider.phpregister()即可,我們的目的是要用 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