寫 Laravel 測試程式碼 (五)

lx1036發表於2017-10-22

本文主要探討寫laravel integration/functional test cases時候,如何assert。前面幾篇文章主要聊瞭如何reseed測試資料,mock資料,本篇主要聊下assert的可行實踐,儘管laravel官方文件聊了Testing JSON APIs,並提供了一些輔助的assert方法,如assertStatus(), assertJson()等等,但可行不實用,不建議這麼做。

最佳需要是對api產生的response做更精細的assert。那如何是更精細的assertion?簡單一句就是把response code/headers/content 完整內容進行比對(assert)。 方法就是把response的內容存入json檔案裡作為baseline。OK,接下來聊下如何做。

寫一個AccountControllerTest,call的是/api/v1/accounts,AccountController的內容參照寫Laravel測試程式碼(三),然後寫上integration/functional test cases:

<?php

declare(strict_types=1);

namespace Tests\Feature;

use Tests\AssertApiBaseline;

final class AccountControllerTest extends TestCase
{
    use AssertApiBaseline;

    protected const ROUTE_NAME = 'accounts';

    public function testIndex()
    {
        $this->assertApiIndex();
    }

    public function testShow()
    {
        $this->assertApiShow(1);
    }
}

很明顯,這裡測試的是index/show api,即/api/v1/accounts和/api/v1/accounts/{account_id},AssertApiBaseline是一個自定義的trait,主要功能就是實現了assert 全部response,並儲存在json檔案裡作為baseline。所以,重點就是AssertApiBaseline該如何寫,這裡就直接貼程式碼:

<?php

declare(strict_types=1);

namespace Tests;

use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Foundation\Testing\TestResponse;

trait AssertApiBaseline
{
    private static $middlewareGroup = 'web';

    private static $cookies = [
        'web' => [
            'D' => 'DiJeb7IQHo8FOFkXulieyA',
        ],
        'api' => [
        ],
    ];

    private static $servers = [
        'web' => [
            'HTTP_ACCEPT'  => 'application/json',
            'HTTP_ORIGIN'  => 'https://test.company.com',
            'HTTP_REFERER' => 'https://test.company.com',
        ],
        'api' => [
            'HTTP_ACCEPT' => 'application/json',
        ],
    ];

    public static function assertJsonResponse(TestResponse $response, string $message = '', array $ignores = []): TestResponse
    {
        static::assertJsonResponseCode($response, $message);
        static::assertJsonResponseContent($response, $message);
        static::assertJsonResponseHeaders($response, $message);

        return $response;
    }

    public static function assertJsonResponseCode(TestResponse $response, string $message = ''): void
    {
        static::assert($response->getStatusCode(), $message);
    }

    public static function assertJsonResponseContent(TestResponse $response, string $message = '', array $ignores = []): void
    {
        static::assert($response->json(), $message);
    }

    public static function assertJsonResponseHeaders(TestResponse $response, string $message = ''): void
    {
        $headers = $response->headers->all();

        $headers = array_except($headers, [
            'date',
            'set-cookie',
        ]); // except useless headers

        static::assert($headers, $message);
    }

    public static function assert($actual, string $message = '', float $delta = 0.0, int $maxDepth = 10, bool $canonicalize = false, bool $ignoreCase = false): void
    {
        // assert $actual with $expected which is from baseline json file
        // if there is no baseline json file, put $actual data into baseline file (or -d rebase)
        // baseline file path
        // support multiple assertion in a test case

        static $assert_counters = [];
        static $baselines       = [];

        $class     = get_called_class();
        $function  = static::getFunctionName(); // 'testIndex'
        $signature = "$class::$function";

        if (!isset($assert_counters[$signature])) {
            $assert_counters[$signature] = 0;
        } else {
            $assert_counters[$signature]++;
        }

        $test_id = $assert_counters[$signature];

        $baseline_path = static::getBaselinesPath($class, $function);

        if (!array_key_exists($signature, $baselines)) {
            if (file_exists($baseline_path) && array_search('rebase', $_SERVER['argv'], true) === false) { // '-d rebase'
                $baselines[$signature] = \GuzzleHttp\json_decode(file_get_contents($baseline_path), true);
            } else {
                $baselines[$signature] = [];
            }
        }

        $actual = static::prepareActual($actual);

        if (array_key_exists($test_id, $baselines[$signature])) {
            static::assertEquals($baselines[$signature][$test_id], $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
        } else {
            $baselines[$signature][$test_id] = $actual;

            file_put_contents($baseline_path, \GuzzleHttp\json_encode($baselines[$signature], JSON_PRETTY_PRINT));

            static::assertTrue(true);

            echo 'R';
        }
    }

    /**
     * @param string|string[]|null  $route_parameters
     * @param array $parameters
     *
     * @return mixed
     */
    protected function assertApiIndex($route_parameters = null, array $parameters = [])
    {
        return static::assertApiCall('index', $route_parameters ? (array) $route_parameters : null, $parameters);
    }

    protected function assertApiShow($route_parameters, array $parameters = [])
    {
        assert($route_parameters !== null, '$route_parameters cannot be null');

        return static::assertApiCall('show', (array) $route_parameters, $parameters);
    }

    protected static function getFunctionName(): string
    {
        $stacks = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);

        do {
            $stack = array_pop($stacks);
        } while ($stack && substr($stack['function'], 0, 4) !== 'test');

        return $stack['function']; // 'testList'
    }

    protected static function getBaselinesPath(string $class, string $function): string
    {
        $class = explode('\\', $class);

        $dir = implode('/', array_merge(
            [strtolower($class[0])],
            array_slice($class, 1, -1),
            ['_baseline', array_pop($class)]
        ));

        if (!file_exists($dir)) {
            mkdir($dir, 0755, true);
        }

        return base_path() . DIRECTORY_SEPARATOR . $dir . DIRECTORY_SEPARATOR . $function . '.json';
    }

    protected static function prepareActual($actual)
    {
        if ($actual instanceof Arrayable) {
            $actual = $actual->toArray();
        }

        if (is_array($actual)) {
            array_walk_recursive($actual, function (&$value, $key): void {
                if ($value instanceof Arrayable) {
                    $value = $value->toArray();
                } elseif ($value instanceof Carbon) {
                    $value = 'Carbon:' . $value->toIso8601String();
                } elseif (in_array($key, ['created_at', 'updated_at', 'deleted_at'], true)) {
                    $value = Carbon::now()->format(DATE_RFC3339);
                }
            });
        }

        return $actual;
    }

    private function assertApiCall(string $route_action, array $route_parameters = null, array $parameters = [])
    {
        [$uri, $method] = static::resolveRouteUrlAndMethod(static::resolveRouteName($route_action), $route_parameters);

        /** @var \Illuminate\Foundation\Testing\TestResponse $response */
        $response = $this->call($method, $uri, $parameters, $this->getCookies(), [], $this->getServers(), null);

        return static::assertJsonResponse($response, '');
    }

    private static function resolveRouteName(string $route_action): string
    {
        return static::ROUTE_NAME . '.' . $route_action;
    }

    private static function resolveRouteUrlAndMethod(string $route_name, array $route_parameters = null)
    {
        $route = \Route::getRoutes()->getByName($route_name);
        assert($route, "Route [$route_name] must be existed.");

        return [route($route_name, $route_parameters), $route->methods()[0]];
    }

    private function getCookies(array $overrides = []): array
    {
        $cookies = $overrides + self::$cookies[static::$middlewareGroup];

        return $cookies;
    }

    private function getServers(array $overrides = []): array
    {
        return $overrides + self::$servers[static::$middlewareGroup];
    }
}

雖然AssertApiBaseline有點長,但重點只有assert()方法,該方法實現了:

  1. 如果初始沒有baseline檔案,就把response內容存入json檔案
  2. 如果有json檔案,就拿baseline作為expected data,來和本次api產生的response內容即actual data做assertion
  3. 如果有'rebase'指令表示本次api產生的response作為新的baseline存入json檔案中
  4. 支援一個test case裡執行多次assert()方法

所以,當執行phpunit指令後會生成對應的baseline檔案:

圖片描述

圖片描述

OK,首次執行的時候重新生成baseline檔案,檢視是不是想要的結果,以後每次改動該api後,如果手滑寫錯了api,如response content是空,這時候執行測試時會把baseline作為expected data和錯誤actual data 進行assert就報錯,很容易知道程式碼寫錯了;如果git diff知道最新的response 就是想要的(如也無需求需要把'name'換另一個),就phpunit -d rebase 把新的response作為新的baseline就行。。

這比laravel文件中說明的寫json api test cases的優點在哪?就是對response做了精細控制。對response 的status code,headers,尤其是response content做了精細控制(content的每一個欄位都行了assert對比)。
這是我們這邊寫api test cases的實踐,有疑問可留言交流。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章