PHP 的靜態方法難以測試?

許多寫 PHP 的人應該都聽過這句話:

「PHP 的靜態方法會讓測試不好寫。」

這聽起來像是在告誡我們盡量別用靜態方法,但這並不太正確。有些工具類別或是 Helper 方法,因為使用時並不需要儲存狀態,所以就沒有建立實體的需求。以 Laravel 的 Helper 為例,你可以看到很多靜態方法的使用。

use Illuminate\Support\Arr;

$array = ['products' => ['desk' => ['price' => 100]]];

$price = Arr::get($array, 'products.desk.price');

// 100
echo $price;

如果想測試靜態方法,我們可以使用單元測試。

#[Test]
public function returnsCorrectPrice()
{
    $array = ['products' => ['desk' => ['price' => 100]]];

    $price = Arr::get($array, 'products.desk.price');

    $this->assertSame($price, 100);
}

但是在現實場景中,相較於單元測試,功能測試是比較貼近現實使用的方式,所以在應用程式開發的場景下,功能測試照理說會比單元測試更為重要。

為什麼測試會不好寫?

既然靜態方法可以測試,為什麼會說靜態方法不好測試呢?

有一個情況是,因為靜態方法不會產生實體,所以我們無法使用 Mock 去改變靜態方法原本的行為,當我們意圖在測試中模擬靜態方法發生錯誤的行為時,就會很困難。

以我寫的密碼金鑰登入功能為例。在用戶使用密碼金鑰登入時,會先透過 API 取得憑證請求選項。

在 API 的處理邏輯中,有一個步驟,是將憑證請求選項的物件轉換為 JSON 字串。我會使用 Serializer 的靜態方法 make() 建立一個 $serializer 實體,再使用 $serializertoJson() 方法將憑證請求選項的物件轉換為 JSON 字串。

use App\Services\Serializer;

class GeneratePasskeyAuthenticationOptionsController extends Controller
{
    public function __invoke()
    {
        // ...

        try {
            // 建立一個 serializer 實體
            $serializer = Serializer::make();
            // 將憑證請求選項的物件轉換為 JSON 字串
            $optionsJson = $serializer->toJson($options);
        } catch (SerializerExceptions $e) {
            Log::error('Webauthn 認證選項序列化失敗', [
                'exception' => $e->getMessage(),
            ]);

            return response()->json([
                'error' => '發生錯誤,無法序列化認證選項。',
            ], 400);
        }

        // ...

        return $optionsJson;
    }
}

這邊有一點需要注意,toJson() 方法是有可能會拋出例外的,所以如果你想要測試 toJson() 拋出例外的情況。上面程式碼的寫法就會讓你很難去測試。

為什麼呢?因為我們很難使用 Mock 去替換掉 Serializer::make() 產生的實體。

雖然 Mockery 有一招可以讓你去 Mock 靜態方法,我們可以透過這個方式讓 Serializer::make() 直接拋出錯誤。

$serializerException = new class extends Exception implements SerializerExceptionInterface {};

// 使用 alias 來 mock 靜態方法
$serializerMock = Mockery::mock('alias:App\Services\Serializer');

// 讓 make() 靜態方法拋出例外
$serializerMock->shouldReceive('make')
    ->andThrow(new $serializerException('Serialization failed'));

但這麼做有一個問題,那就是會影響到其他測試。一旦使用 alias,如果在後續的測試也有使用到 Serializer::make(),那麼都會直接拋出例外。

所以在執行測試時,我們需要加上 #[RunInSeparateProcess] 的註解,讓這個測試在一個獨立的程序中執行。

#[Test]
#[RunInSeparateProcess]
public function returns400AndLogsErrorWhenSerializationFailsInAuthentication()
{
    // ...

    $serializerException = new class extends Exception implements SerializerExceptionInterface {};

    $serializerMock = Mockery::mock('alias:App\Services\Serializer');
    $serializerMock->shouldReceive('make')
        ->andThrow(new $serializerException('Serialization failed'));

    // ...
}

透過 Laravel 的 Service Container 使用依賴注入

如果你是使用 Laravel 框架開發,相較於直接在程式碼中使用靜態方法建立實體,我們可以在 Laravel 的 Service Container 中設定如何建立 Serializer 的實體。

// app/Providers/AppServiceProvider.php

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(Serializer::class, function () {
            return Serializer::make();
        });
    }

    // ...
}

之後我們就可以透過依賴注入的方式,將 Serializer 的實體傳入到 Controller 中。

use App\Services\Serializer;

class GeneratePasskeyAuthenticationOptionsController extends Controller
{
    // Laravel 會根據我們在 AppServiceProvider 中的設定,建立一個 Serializer 實體
    // 並透過依賴注入的方式,將這個實體傳入到 Controller 中
    public function __invoke(Serializer $serializer)
    {
        // ...

        try {
            // 將憑證請求選項的物件轉換為 JSON 字串
            $optionsJson = $serializer->toJson($options);
        } catch (SerializerExceptions $e) {
            Log::error('Webauthn 認證選項序列化失敗', [
                'exception' => $e->getMessage(),
            ]);

            return response()->json([
                'error' => '發生錯誤,無法序列化認證選項。',
            ], 400);
        }

        // ...

        return $optionsJson;
    }
}

在測試中,我們不再需要 Mock 靜態方法,只需要將 Service Container 中的 Serializer 實體替換成 Mock 實體即可。

#[Test]
public function returns400AndLogsErrorWhenSerializationFailsInAuthentication()
{
    // ...

    $serializerException = new class extends Exception implements SerializerExceptionInterface {};

    // 建立一個 Serializer 的 Mock 物件
    $serializerMock = Mockery::mock(Serializer::class);
    // 預期 toJson 方法會被呼叫,並拋出例外
    $serializerMock->shouldReceive('toJson')
        ->andThrow(new $serializerException('Serialization failed'));

    // 將 Mock 的 serializer 實體替換掉 Service Container 中的 serializer 實體
    $this->app->instance(Serializer::class, $serializerMock);

    // ...
}

使用依賴注入的方式,我們可以更輕鬆的替換掉目標實體,更方便的在測試中去模擬特殊應用場景。

參考資料


This site uses Just the Docs, a documentation theme for Jekyll.