網站開發

Laravel| 如何用 PHPUnit 測試 Laravel Controller

前言

在試著將PHPUnit導入Laravel Project之後遇到的第一個問題是,要測試什麼?

對於一般的Web application來說,最大宗的動作所在就是對於資料庫的新增、讀取、修改和刪除,再加上一些邏輯判斷跟動線的串接。而基本的資料庫存取操作,除非要到一些較客製化的動作,否則將Model Create好之後,不必再多寫任何一行code就可以對table進行操作,我的意思是,所以資料庫操作的層面(Model)也沒什麼好測試的了。

那到底對一個功能不複雜不特殊的Laravel web application而言,最大宗的邏輯會存在於哪裡?我認為是答案是Contoller中。

Controller的Request該怎麼來?

開始要用PHPUnit測試Laravel Controller時,遇到的第一個問題是,那input的Request要怎麼來?

光這個問題就卡了很久,若彙整一下從Google大神那問來的資料,大致上可以分為以下幾個方向:

  1. 直接送HTTP Request,讓資料跑過Routing再到達Controller,Request自然就出現
  2. 用mock來處理
  3. 手動new一個 \Illuminate\Http\Request object出來

想了一下之後,覺得直接送HTTP Request的方法是最貼近現實狀況,而且測試code寫起來似乎也是最簡潔簡單的,所以就直接選定了這個方法,其餘兩個方法暫且就沒有再多研究。

至於使用方法,可以看我為大家寫的實際範例應該就很清楚。

先看 CustomerController 的程式碼,裡面有 show、store、destroy 三個 methods,是對 Customers 這個Model做操作:

class CustomerController extends Controller
{
    public function show($id)
    {
        return view('customer', [
            'customer' => Customers::find($id)
        ]);
    }

    public function store(Request $request)
    {
        $customer = new Customers();

        $customer->name = $request->name;
        $customer->phone = $request->phone;

        $customer->save();

        return redirect()->action([CustomerController::class, 'show'], ['id' => $customer->id]);
    }

    public function destroy($id)
    {
        $customer = Customers::find($id);
        $customer->delete();

        return view('welcome');
    }
}

再來是 CustomerControllerTest 的程式碼,分別對應測試 CustomerController 的三個 methods:

class CustomerControllerTest extends TestCase
{
    protected static $customerIDs = [];

    public function testStore()
    {
        // 打HTTP Post,去request CustomerController store method (儲存一筆資料)
        $response = $this->postJson(action([CustomerController::class, 'store']), [
            'name' => 'name-A',
            'phone' => '0911123456'
        ]);

        // 確認store method是return 302 redirect無誤
        $response->assertStatus(302);

        // 取最新insert的customer id
        // 因為最新的customer id被store method帶在redirect url中,所以這裡從url中拆解出來
        $redirectUrlExploded = explode('/', $response->headers->get('location'));
        $customerID = array_pop($redirectUrlExploded);

        // 確認customer id都是數字無誤
        $this->assertStringMatchesFormat('%d', $customerID);

        // 確認insert的資料內容無誤
        $customer = Customers::find($customerID);
        $this->assertSame('name-A', $customer->name);
        $this->assertSame('0911123456', $customer->phone);

        // 將customer id存到 self::$customerIDs 中,後續的test會需要
        self::$customerIDs[] = $customerID;
    }

    /**
     * @depends testStore
     */
    public function testShow()
    {
        // 從 self::$customerIDs 取回customer id
        // 因為測試動作是有連貫性而非獨立的,所以customer id才需要互相傳遞
        $customerID = self::$customerIDs[0];

        // 打HTTP Get,去request CustomerController show method (顯示一筆資料)
        $response = $this->get(action([CustomerController::class, 'show'], ['id' => $customerID]));
        
        // 確認HTTP status 200無誤
        $response->assertOk();
        
        // 確認view為"customer"無誤
        $response->assertViewIs('customer');
        
        // 確認會傳遞$customer給view,而其內容應該就是要等於剛剛 testStore() 中儲存的無誤
        $customer = Customers::find($customerID);
        $response->assertViewHas('customer', $customer);
    }

    /**
     * @depends testShow
     */
    public function testDestroy()
    {
        $customerID = self::$customerIDs[0];

        // 打HTTP Get(Delete),去request CustomerController destroy method (刪除一筆資料)
        $response = $this->deleteJson(action([CustomerController::class, 'destroy'], ['id' => $customerID]));

        // 確認HTTP status 200無誤
        $response->assertOk();

        // 確認view為"welcome"無誤
        $response->assertViewIs('welcome');

        // 確認此筆資料確實已被刪除無誤
        $customer = Customers::find($customerID);
        $this->assertNull($customer);
    }
}

以上是 Laravel 8.x 的作法,若是較舊的版本基本上概念是相同的,只不過因為版本的關係程式碼需要改過才能成功運作。有機會我再補上舊版本的程式碼範例。

想要實際上跑跑看,或看更完整程式碼的,可以到我的GitHub上把整個Laravel Project抓下來。

這個Test的是連貫動作,一個牽著一個的。以人的角度來看就是,首先插入一筆資料看是否正確(testStore),接著再看剛插入的資料是否正常讀取顯示(testShow),最後再試試能否正確把這筆資料正確刪除(testDestroy)。

所以test function需要 @depends 去標示彼此是如何串聯的,另外還需要 self::$customerIDs 在 test functions 之間互相傳遞測試資料。我都把unit test的每一行在做什麼盡量都寫註解在旁了,所以應該算不難理解。

議題

這樣的測試方法,其實已經包含了HTTP Request以及Routing,也就是說雖然貼近實際運作的狀況,不過這個「Unit」已經不「Unit」了。

如果以Laravel對於Test的分類來說,這個已經從Unit偏向Feature,但就我看又還沒真正到Feature,所以有點卡在一個尷尬的定位點。

但是如果因為定位含糊不清的問題,因而捨棄掉這樣的方法不用,我個人是覺得有點可惜,而且成本有點高,畢竟這樣的測試code真的是直覺、好懂又不拐彎抹角。

不過,要是因為測試的涵蓋範圍過大導致問題不容易點出,例如test controller fail但實際上是routung出錯導致的fail。如果是這類問題過於嚴重,我就偏向於不採用此篇文章所說的方法,讓「Unit」可以真正是「Unit」。

不知道大家的看法如何?又或是你有更好的做法,都歡迎提出討論。

傻熊

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料