Example: testing
An article endpoint and four PHPUnit test classes, each testing one layer on its own.
| Test | What it checks |
|---|---|
HandlerTest | handle() with a found and a missing article |
ParamsTest | id is read from the query, a missing one is Field is required |
OutputTest | every response matches an output, a wrong shape does not |
AuthorizationTest | BearerTokenAuthorization with a token, without one and from another IP |
The tested 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
Call handle() with the params as the presenter would pass them.
<?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());
}
}Params
<?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());
}
}Outputs
<?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());
}
}
}Authorization
<?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());
}
}The test script
#!/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"FAQ
Why is the id param untyped?
GetInputParam reads filter_input(), which sees nothing on the command line, so it falls back to the raw $_GET value without converting the type. A typed INTEGER param would fail in PHPUnit and pass over HTTP. The handler casts with (int) instead.
Do I need a web server for these tests?
No, every test builds the objects directly. e2e.sh adds one HTTP request so the wiring in common.neon is tested too.
How do I test the whole presenter?
Create ApiPresenter from the container and call run() with a Nette\Application\Request, or send real requests as the other examples do.
Why test outputs?
In production outputs are not checked, so a test is the place where a changed response is caught. See Outputs.
Which PHPUnit version?
11.5, installed as a dev dependency of the example.