Príklad: testovanie
Endpoint pre článok a štyri triedy PHPUnit testov, každá testuje jednu vrstvu samostatne.
| Test | Čo overuje |
|---|---|
HandlerTest | handle() s nájdeným a chýbajúcim článkom |
ParamsTest | id sa načíta z query, chýbajúce je Field is required |
OutputTest | každá odpoveď zodpovedá výstupu, zlý tvar nie |
AuthorizationTest | BearerTokenAuthorization s tokenom, bez neho a z inej IP |
Testovaný handler
<?php
declare(strict_types=1);
namespace App\Api;
use Tomaj\NetteApi\Handlers\BaseHandler;
use Tomaj\NetteApi\Output\JsonOutput;
use Tomaj\NetteApi\Params\GetInputParam;
use Tomaj\NetteApi\Response\JsonApiResponse;
use Tomaj\NetteApi\Response\ResponseInterface;
final class ArticleDetailHandler extends BaseHandler
{
public const SCHEMA = '{"type":"object","required":["id","title"],"properties":{"id":{"type":"integer"},"title":{"type":"string"}},"additionalProperties":false}';
public const ERROR_SCHEMA = '{"type":"object","required":["status","message"],"properties":{"status":{"const":"error"},"message":{"type":"string"}}}';
public function __construct(private ArticleRepository $articles)
{
parent::__construct();
}
public function params(): array
{
// untyped: in a CLI test $_GET values skip the type cast that HTTP requests get
return [(new GetInputParam('id'))->setRequired()];
}
public function outputs(): array
{
return [new JsonOutput(200, self::SCHEMA), new JsonOutput(404, self::ERROR_SCHEMA)];
}
public function handle(array $params): ResponseInterface
{
$id = (int) $params['id'];
$title = $this->articles->find($id);
if ($title === null) {
return new JsonApiResponse(404, ['status' => 'error', 'message' => 'Article not found']);
}
return new JsonApiResponse(200, ['id' => $id, 'title' => $title]);
}
}Handler
Zavolajte handle() s parametrami tak, ako by ich odovzdal presenter.
<?php
declare(strict_types=1);
namespace Tests;
use App\Api\ArticleDetailHandler;
use App\Api\ArticleRepository;
use PHPUnit\Framework\TestCase;
use Tomaj\NetteApi\Response\JsonApiResponse;
/** handle() is a plain method: give it the params, check the response. No HTTP, no presenter. */
final class HandlerTest extends TestCase
{
public function testFound(): void
{
$handler = new ArticleDetailHandler(new ArticleRepository([5 => 'Five']));
$response = $handler->handle(['id' => '5']);
$this->assertInstanceOf(JsonApiResponse::class, $response);
$this->assertSame(200, $response->getCode());
$this->assertSame(['id' => 5, 'title' => 'Five'], $response->getPayload());
}
public function testNotFound(): void
{
$handler = new ArticleDetailHandler(new ArticleRepository([]));
$response = $handler->handle(['id' => '5']);
$this->assertSame(404, $response->getCode());
}
}Parametre
<?php
declare(strict_types=1);
namespace Tests;
use App\Api\ArticleDetailHandler;
use App\Api\ArticleRepository;
use PHPUnit\Framework\TestCase;
use Tomaj\NetteApi\Params\ParamsProcessor;
/** ParamsProcessor is what ApiPresenter runs before handle(); GetInputParam reads $_GET. */
final class ParamsTest extends TestCase
{
private ArticleDetailHandler $handler;
protected function setUp(): void
{
$this->handler = new ArticleDetailHandler(new ArticleRepository());
}
protected function tearDown(): void
{
$_GET = [];
}
public function testIdIsReadFromQuery(): void
{
$_GET = ['id' => '2'];
$processor = new ParamsProcessor($this->handler->params());
$this->assertFalse($processor->isError());
$this->assertSame(['id' => '2'], $processor->getValues());
}
public function testMissingIdIsAnError(): void
{
$_GET = [];
$processor = new ParamsProcessor($this->handler->params());
$this->assertTrue($processor->isError());
$this->assertSame(['id' => ['Field is required']], $processor->getErrors());
}
}Výstupy
<?php
declare(strict_types=1);
namespace Tests;
use App\Api\ArticleDetailHandler;
use App\Api\ArticleRepository;
use PHPUnit\Framework\TestCase;
use Tomaj\NetteApi\Response\JsonApiResponse;
/** The same check ApiPresenter does after handle(): one of outputs() has to accept the response. */
final class OutputTest extends TestCase
{
public function testEveryResponseMatchesAnOutput(): void
{
$handler = new ArticleDetailHandler(new ArticleRepository([1 => 'One']));
foreach (['1', '999'] as $id) {
$response = $handler->handle(['id' => $id]);
$accepted = false;
foreach ($handler->outputs() as $output) {
$accepted = $accepted || $output->validate($response)->isOk();
}
$this->assertTrue($accepted, "response for id $id matches no output");
}
}
public function testWrongShapeIsRejected(): void
{
$handler = new ArticleDetailHandler(new ArticleRepository());
$wrong = new JsonApiResponse(200, ['id' => '1', 'title' => 'One', 'extra' => true]);
foreach ($handler->outputs() as $output) {
$this->assertFalse($output->validate($wrong)->isOk());
}
}
}Autorizácia
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
use Tomaj\NetteApi\Authorization\BearerTokenAuthorization;
use Tomaj\NetteApi\Misc\StaticIpDetector;
use Tomaj\NetteApi\Misc\StaticTokenRepository;
/** BearerTokenAuthorization reads $_SERVER['HTTP_AUTHORIZATION'], so the test sets it. */
final class AuthorizationTest extends TestCase
{
private BearerTokenAuthorization $authorization;
protected function setUp(): void
{
$this->authorization = new BearerTokenAuthorization(
new StaticTokenRepository(['test-token' => '*', 'office-token' => '10.0.0.1']),
new StaticIpDetector('127.0.0.1'),
);
}
protected function tearDown(): void
{
unset($_SERVER['HTTP_AUTHORIZATION']);
}
public function testValidToken(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer test-token';
$this->assertTrue($this->authorization->authorized());
}
public function testMissingHeader(): void
{
$this->assertFalse($this->authorization->authorized());
$this->assertSame('Authorization header HTTP_Authorization is not set', $this->authorization->getErrorMessage());
}
public function testTokenFromAnotherIp(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer office-token';
$this->assertFalse($this->authorization->authorized());
}
}Testovací skript
#!/usr/bin/env bash
# Runs the PHPUnit tests, then one real request to show the tested pieces work together.
set -euo pipefail
cd "$(dirname "$0")"
[[ -f vendor/autoload.php ]] || { echo "fail: vendor/ is missing, run composer install first" >&2; exit 1; }
vendor/bin/phpunit
echo "ok: the PHPUnit tests pass"
source ../ci/lib.sh
serve 8096
request -H 'Authorization: Bearer test-token' "$BASE/api/v1/articles?id=1"
expect_status 200 "article"
expect_body '{"id":1,"title":"Hello nette-api"}' "article"
request "$BASE/api/v1/articles?id=1"
expect_status 401 "no token"
ok "the same handler answers over HTTP"Časté otázky
Prečo parameter id nemá typ?
GetInputParam číta filter_input(), ktorý na príkazovom riadku nič nevidí, takže použije surovú hodnotu z $_GET bez prevodu typu. Typovaný parameter INTEGER by v PHPUnit zlyhal a cez HTTP prešiel. Handler preto pretypuje cez (int).
Potrebujem na tieto testy webový server?
Nie, každý test si objekty vytvorí priamo. e2e.sh pridá jednu HTTP požiadavku, aby sa otestovalo aj zapojenie v common.neon.
Ako otestujem celý presenter?
Vytvorte ApiPresenter z kontajnera a zavolajte run() s Nette\Application\Request, alebo posielajte skutočné požiadavky ako ostatné príklady.
Prečo testovať výstupy?
V produkcii sa výstupy nekontrolujú, takže zmenenú odpoveď zachytí práve test. Pozrite si Výstupy.
Ktorá verzia PHPUnit?
11.5, nainštalovaná ako dev závislosť príkladu.