写 Laravel 测试代码 (五)
本文主要探讨写 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 () 方法,该方法实现了:
- 如果初始没有 baseline 文件,就把 response 内容存入 json 文件
- 如果有 json 文件,就拿 baseline 作为 expected data,来和本次 api 产生的 response 内容即 actual data 做 assertion
- 如果有 'rebase' 指令表示本次 api 产生的 response 作为新的 baseline 存入 json 文件中
- 支持一个 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 协议》,转载必须注明作者和本文链接
推荐文章: