Preskočiť na obsah

Príklad: testovanie ​

Endpoint pre článok a štyri triedy PHPUnit testov, každá testuje jednu vrstvu samostatne.

Otvoriť diagram v plnej veľkosti
TestČo overuje
HandlerTesthandle() s nájdeným a chýbajúcim článkom
ParamsTestid sa načíta z query, chýbajúce je Field is required
OutputTestkaždá odpoveď zodpovedá výstupu, zlý tvar nie
AuthorizationTestBearerTokenAuthorization s tokenom, bez neho a z inej IP

Testovaný handler ​

php
<?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
<?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
<?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
<?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
<?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 ​

bash
#!/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.

Vydané pod licenciou MIT.