Laravel: Một số phương thức viết Unit Test cho các ca siêu khó

Trong quá trình viết Unit Test cho các dự án chắc hẳn bạn sẽ gặp phải một số ca Unit Test cực khó, hoặc là bạn phải refactor lại khá nhiều, hoặc là phải chuyển qua viết Feature Test. Nhưng không phải lúc nào Feature Test cũng hoạt động, ví dụ như bạn cần viết test cho Jobs hoặc Models chẳng hạn.

Dưới đây là một số ca test chéo ngoe mình gặp phải, note lại dưới đây để về sau cho dễ tìm

Unit Test khi Model gọi static function

Ví dụ đây là Controller của bạn:

use App\Post;

$posts = Post::where('title', 'like', "%$keyword%")
    ->orderBy('created_at', 'ASC')
    ->get();

Trong thực tế khi chạy Unit Test với lời gọi static function như thế này, model sẽ trực tiếp query đến Database khiến cho dữ liệu khi lấy ra là dữ liệu thật chứ không phải dữ liệu được fake bằng Mockery, khiến cho test của bạn không chạy được.

Giải pháp cho vấn đề này như sau:

Unit Test

use Mockery;

/**
 * @runInSeparateProcess
 * @preserveGlobalState disabled
 */
public function test_it_can_get_posts()
{
    $mockPost = Mockery::mock('alias:App\Post');
    $mockPost->shouldReceive('where')->andReturn(Mockery::mock(Builder::class));
    //...
}

Bằng việc sử dụng từ khóa alias, bạn sẽ cho phép object Post mới được mock ghi đè lên object Post trong controller và đừng quên 2 dòng comment ở đầu function, 2 dòng comment đó sẽ giúp bạn tránh được lỗi "Object đã được tạo".

Trong một số trường hợp cách ở trên không hoạt động, bạn có thể tạo mock như cách thông thường sau đó inject nó vào Service Container, bắt nó phải thay thế Object Post thật đang được sử dụng:

public function test_it_can_get_posts()
{
    $mockPost = Mockery::mock(Post::class);
    $this->app->instance('App\Post', $mockPost);
    //...
}

Unit Test với callback function

Hãy xét qua một query thế này:

$post = Post::where('title', "%$keyword%")
    ->whereHas('comments', function ($query) {
        $query->where('name', "%$author%");
    })
    ->get();

Như trong ví dụ trên, tham số thứ 2 của whereHas là một callback function, trong trường hợp nếu bạn chỉ shouldReceive("whereHas") thì dòng query bên trong callback sẽ không được test. Giờ chúng ta sẽ cùng đi test cho cú callback này:

/**
 * @runInSeparateProcess
 * @preserveGlobalState disabled
 */
public function test_it_can_get_posts()
{
    $mockPost = Mockery::mock('alias:App\Post');
    $builder = Mockery::mock(Builder::class);
    $mockPost->shouldReceive('where')->andReturn($builder);
    $mockPost->shouldReceive('whereHas')->with('comments', Mockery::on(function ($closure) use ($builder) {
        $builder->shouldReceive("where");
        $closure($builder);
        
        return true;
    }))->andReturnSelf();
    //...
}

Sử dụng with để fake tham số thứ 2 cho hàm whereHas, bởi vì tham số này là một callback nên tôi sử dụng Mocker::on() để tạo ra một callback tương tự, tham số mặc định của callback này là $closure, nếu các bạn dd($closure) ra, các bạn sẽ thấy một thứ giống như thế này

Closure($query)^ {#6135
  class: "App\Repositories\Implementations\Post"
  this: class@anonymous\x00/home/shroud/MyApp/myapp/tests/Unit/Repositories/Implementations/PostTest.php0x7f1770a82006 {#4608 …}
  file: "./app/Repositories/Implementations/Post.php"
  line: "121 to 123"
}

Đây là closure fake được Mockery tạo ra, nó có cấu trúc giống với closure của hàm whereHas, với tham số mặc định cũng là một query builder. Do đó ta mới cần truyền biến $builder vào bên trong của $closure. Giải thích thì hơi trừu tượng loằng ngoằng mì tôm khó hiểu, nhưng đại khái là nó giúp fake được cái query bên trong callback đó 🤷♂

Unit Test với một mảng callback function

Trường hợp này sẽ thường xảy ra khi bạn query với hàm with() hơn cả, ví dụ điển hình sẽ trông như này: lấy ra một list các comments của một bài post

$post = Post::where('title', "%$keyword%")
    ->with([
        'comments' => function ($query) {
            $query->where('name', "%$author%");
        },
    ])
    ->get();

Trường hợp này chúng ta cũng sẽ xử lý bằng cách sử dụng Mockery::on() để fake một callback đến hàm with(), nhưng sẽ có thay đổi một chút như sau:

//..
$mockPost->shouldReceive('with')->with(Mockery::on(function ($closure) use ($builder) {
        $builder->shouldReceive("where");
        $closure['comments']($builder);
        
        return is_callable($closure['comments']);
    }))->andReturnSelf();

Lúc này $closure không phải là một instance của callback function nữa, mà là một mảng các instance, do đó bạn cần chỉ đến đúng cái closure của comments bằng cách trỏ đến key 'comments' như trên, rồi sau đó vẫn truyền vào query builder như bình thường. Với return chúng ta sẽ kiểm tra xem $closure với key hiện tại có đúng là callback hay không, vì có thể Mockery sẽ loop qua rất nhiều closure khác nhau, nên cần kiểm tra với đúng closure của comments thì mới thực hiện trả về kết quả.

Một số trường hợp khác

Unit Test với callback trong collection

Xét ví dụ dưới đây:

$post = Post::where('title', "%$keyword%")
    ->get()
    ->map(function ($item) {
        $item->title = $newTitle;
        return $item;
    });

Thật ra các trường hợp test với collection rất đơn giản, miễn là đầu vào của các hàm collection có giá trị thì test trong collection sẽ mặc định được chạy. Ví dụ ở trên, nếu query của bạn trả về đúng giá trị fake thì hàm map() sẽ tự động được test, nếu bạn fake giá trị trả về là rỗng thì hàm map() sẽ không được test, như sau:

//...
$builder->shouldReceive("get")->andReturn(collect()); // trả về collection rỗng

Như thế này thì map() sẽ không được test, hãy trả về một collection có cấu trúc giống với kết quả trả về từ query thật, cách hay nhất là sử dụng factory như sau:

$posts = factory(Post::class, 2)->make(); //lưu ý khi dùng: hàm make() sẽ khởi tạo object Post, còn hàm create() sẽ vừa khởi tạo đối tượng vừa thêm mới một bản ghi vào database

$builder->shouldReceive("get")->andReturn($posts); 

Như vầy là xong 👍

Tương tự với các hàm collection có callback khác như filter(), reduce(),...

Unit Test với query trong vòng lặp

Lại xét ví dụ sau:

foreach($foo => $bar) {
    $post = Post::where('title', "%$keyword%")
        ->get();
}

Thật ra trường hợp này cũng không có gì phức tạp, bạn không cần thiết phải sử dụng vòng lặp trong Unit Test, chỉ cần viết test như bình thường là được

$mockPost = Mockery::mock('alias:App\Post');
$builder = Mockery::mock(Builder::class);
$mockPost->shouldReceive('where')->andReturn($builder);
$builder->shouldReceive('get')->andReturn();

Tuy nhiên hãy cẩn thận khi trong vòng lặp có nhiều query phức tạp hơn, có thể bạn cũng nên tính đến chuyện refactor lại code nếu như có quá nhiều vấn đề không thể giải quyết được

Fake auth user cho request

Trong một số trường hợp bạn cần sử dụng thông tin của người đăng nhập để làm một cái gì đó chẳng hạn, thì trong unit test bạn có thể làm như sau để fake một ca đăng nhập cho user:

public function get(Request $request) {
    $user = $request->user();
    //...
}

Unit Test

use App\Models\User;

public function test_it_can_get_something()
{
    $user = new User();
    $user->id = 1;
    $request = new Request();
    $request->headers->set('content-type', 'application/json');
    $request->setUserResolver(function () use ($user) {
        return $user;
    })
    
    $response = $this->controller->get($request)
}

Ở trường hợp trên, vì function get() nhận vào tham số Request nên bạn có thể set auth user cho request được, còn đối với trường hợp function không nhận tham số request mà sử dụng facade request() để lấy thông tin đăng nhập thì sao?

public function doSth() {
    $user = request()->user();
    //...
}

Lúc này chúng cần ghi đè lên instance request đang chạy trong service container bằng request fake của chúng ta, như sau:

use App\Models\User;

public function test_it_can_get_something()
{
    $user = new User();
    $user->id = 1;
    $request = new Request();
    $this->app->instance('request', $request); // ghi đè instance request
    $request->headers->set('content-type', 'application/json');
    $request->setUserResolver(function () use ($user) {
        return $user;
    })
    
    $response = $this->controller->get($request)
}

Vậy là xong!

Cuối cùng

Còn rất nhiều các ca test khó đỡ nữa mà tôi chưa thể kể hết ra được, sau này gặp phải ca nào khó nữa thì chắc chắn tôi sẽ viết thêm một bài bổ sung. Cám ơn các bạn đã đọc bài viết!


All Rights Reserved