Как запустить функциональные тесты Codeception для пользовательского веб-приложения без рамок? (которое реализует PSR-15 RequestHandlerInterface)

#codeception

#codeception

Вопрос:

Предположим, что следующее простое веб-приложение:

 <?php
// src/App/App.php

namespace PracticeSourcesApp;

use Closure;
use LaminasDiactorosServerRequestFactory;
use LaminasHttpHandlerRunnerEmitterSapiEmitter;
use PsrHttpMessageRequestInterface;
use PsrHttpMessageResponseFactoryInterface;
use PsrHttpMessageResponseInterface;
use PsrHttpServerRequestHandlerInterface;

class App implements RequestHandlerInterface
{
    private Closure $requestProvider;
    private ResponseFactoryInterface $responseFactory;
    private SapiEmitter $responseEmitter;

    public function __construct(ResponseFactoryInterface $responseFactory)
    {
        $this->requestProvider = fn() => ServerRequestFactory::fromGlobals();
        $this->responseFactory = $responseFactory;
        $this->responseEmitter = new SapiEmitter();
    }

    public function run(): void
    {
        $request = ($this->requestProvider)();
        $response = $this->handle($request);
        $this->responseEmitter->emit($response);
    }

    public function handle(RequestInterface $request): ResponseInterface
    {
        $response = $this->responseFactory->createResponse();
        $response->getBody()->write('hello world');
        return $response;
    }
}
 

Его можно легко запустить, поместив следующий код в свой веб-интерфейс (например) public_html/index.php :

 <?php
// web-root/front-controller.php

use LaminasDiactorosResponseFactory;
use PracticeSourcesAppApp;

require_once __DIR__.'/../vendor/autoload.php';

$responseFactory = new ResponseFactory();
$app = new App($responseFactory);
$app->run();
 

Теперь я хотел бы запустить для него тесты функций Codeception, написанные на Gherkin. Рассмотрим следующий простой тест:

 # tests/Feature/run.feature
# (also symlink'ed from tests/Codeception/tests/acceptance/ )

Feature: run app

  Scenario: I run the app
    When I am on page '/'
    Then I see 'hello world'
 

Чтобы запустить приемочные тесты против него, я должен предоставить реализацию своих шагов. Для этого я повторно использую стандартные шаги, предоставленные Codeception:

 <?php
// tests/Codeception/tests/_support/FeatureTester.php

namespace PracticeTestsCodeception;

use CodeceptionActor;

abstract class FeatureTester extends Actor
{
//    use _generatedAcceptanceTesterActions;
//    use _generatedFunctionalTesterActions;

    /**
     * @When /^I am on page '([^']*)'$/
     */
    public function iAmOnPage($page)
    {
        $this->amOnPage($page);
    }

    /**
     * @Then /^I see '([^']*)'$/
     */
    public function iSee($what)
    {
        $this->see($what);
    }
}
 
 <?php
// tests/Codeception/tests/_support/AcceptanceTester.php

namespace PracticeTestsCodeception;

class AcceptanceTester extends FeatureTester
{
    use _generatedAcceptanceTesterActions;
}
 
 # tests/Codeception/codeception.yml

namespace: PracticeTestsCodeception
paths:
    tests: tests
    output: tests/_output
    data: tests/_data
    support: tests/_support
    envs: tests/_envs
actor_suffix: Tester
extensions:
    enabled:
        - CodeceptionExtensionRunFailed
 
 # tests/Codeception/tests/acceptance.suite.yml

actor: AcceptanceTester
modules:
    enabled:
        - PhpBrowser:
            url: http://practice.local
gherkin:
    contexts:
        default:
            - PracticeTestsCodeceptionAcceptanceTester
 

But now, I want to use same tests code to run these same tests as functional tests using Codeception. For this, I have to enable the module that implements these same steps in functional way. Which one do I use? Codeception provides several, but they’re for 3rd party frameworks, e.g. Laravel, Yii2, Symphony etc. What do I do for such a simple app that doesn’t use any 3rd party framework?

Here’s what I’ve managed to do. I’ve created my own CodeceptionLibInnerBrowser implementation that inherits from CodeceptionModulePhpBrowser provided by Codeception, in which I substitute the web client that Codeception uses (it uses Guzzle) with my own implementation (which also inherits from the Guzzle client) that doesn’t perform any web requests but requests my app instead:

 # tests/Codeception/tests/functional.suite.yml

actor: FunctionalTester
modules:
    enabled:
        - PracticeTestsCodeceptionHelperCustomInnerBrowser:
            url: http://practice.local
gherkin:
    contexts:
        default:
            - PracticeTestsCodeceptionFunctionalTester
 
 <?php
// tests/Codeception/tests/_support/FunctionalTester.php

namespace PracticeTestsCodeception;

class FunctionalTester extends FeatureTester
{
    use _generatedFunctionalTesterActions;
}
 

In order for this to work, I have to make my app return Guzzle Response s (which implement PSR’s ResponseInterface as well) — because PhpBrowser expects its web client to return them — which is why I had to make the ResponseFactory a constructor parameter to be able to substitute it in tests.

 <?php
// tests/Codeception/tests/_support/Helper/CustomInnerBrowser.php

namespace PracticeTestsCodeceptionHelper;

use CodeceptionModulePhpBrowser;
use HttpFactoryGuzzleResponseFactory;
use PracticeSourcesAppApp;

class CustomInnerBrowser extends PhpBrowser
{
    private App $app;

    public function __construct(...$args)
    {
        parent::__construct(...$args);
        $responseFactory = new ResponseFactory();
        $this->app = new App($responseFactory);
    }

    public function _prepareSession(): void
    {
        parent::_prepareSession();
        $this->guzzle = new CustomInnerBrowserClient($this->guzzle->getConfig(), $this->app);
        $this->client->setClient($this->guzzle);
    }
}
 
 <?php
// tests/Codeception/tests/_support/Helper/CustomInnerBrowserClient.php

namespace PracticeTestsCodeceptionHelper;

use GuzzleHttpClient as GuzzleClient;
use PracticeSourcesAppApp;
use PsrHttpMessageRequestInterface;
use PsrHttpMessageResponseInterface;

class CustomInnerBrowserClient extends GuzzleClient
{
    private App $app;

    public function __construct(array $config, App $app)
    {
        parent::__construct($config);
        $this->app = $app;
    }

    public function send(RequestInterface $request, array $options = []): ResponseInterface
    {
        return $this->app->handle($request);
    }
}
 

В такой конфигурации, кажется, все работает нормально.

Но есть проблема. Обратите внимание на App::handle() подпись:

     public function handle(RequestInterface $request): ResponseInterface
 

— он отличается от того, который он реализует, который объявлен в RequestHandlerInterface :

     public function handle(ServerRequestInterface $request): ResponseInterface;
 

Технически это полностью законно, потому что не нарушает контравариантность параметров, требуемую принципом подстановки Лискова.
Проблема, с которой я столкнулся, заключается в том, что PhpBrowser предполагается, что он отправляет (на стороне клиента) RequestInterface s (по сети), но вместо этого моему приложению требуется (на стороне сервера) ServerRequestInterface , чтобы иметь возможность доступа к параметрам, которые установлены на стороне сервера, таким как ServerRequestInterface::getParsedBody() сеанс и т.д.

Как мне обойти это? Модули фреймворка, предоставляемые Codeception, уже каким-то образом это делают… И КСТАТИ, разве Codeception (или кто-то другой) еще не предоставил простой способ запуска функциональных тестов для пользовательского кода?

Вот composer.json КСТАТИ:

 {
  "require": {
    "php": "~7.4",
    "laminas/laminas-diactoros": "^2.5",
    "laminas/laminas-httphandlerrunner": "^1.3"
  },
  "require-dev": {
    "codeception/codeception": "^4.1",
    "codeception/module-phpbrowser": "^1.0.0",
    "http-interop/http-factory-guzzle": "^1.0"
  },
  "autoload": {
    "psr-4": {
      "Practice\Sources\": "src"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Practice\Tests\Unit\": "tests/Unit/",
      "Practice\Tests\Support\": "tests/Support/",
      "Practice\Tests\Codeception\": "tests/Codeception/tests/_support/",
      "Practice\Tests\Codeception\_generated\": "tests/Codeception/tests/_support/_generated/",
      "Practice\Tests\Codeception\Helper\": "tests/Codeception/tests/_support/Helper/"
    }
  },
  "scripts": {
    "test-feature": "codecept run --config tests/Codeception/codeception.yml"
  }
}
 

Комментарии:

1. Вы пробовали использовать codeception / module-laminas? github.com/Codeception/module-laminas

2. @Naktibalda нет, у меня нет. Это вообще актуально? Похоже, он пытается запустить какое-то стороннее приложение внутри: github.com/Codeception/module-laminas/blob /… — в то время как мое приложение не использует никаких фреймворков…

3. @Naktibalda почему вообще это так сложно? насколько я понимаю, модуль framework должен выполнять 2 вещи: 1. преобразовать шаги, специфичные для фреймворка, в http-запрос, который будет отправлен по сети. 2. преобразуйте http-запрос в результат, зависящий от фреймворка, который может быть использован внутри теста. кроме того, для запуска того же кода с помощью функциональных тестов должна существовать функция, имитирующая отправку http-запроса, которая на самом деле не отправляет его, а вместо этого заполняет внутренние структуры php, такие как массив $GLOBALS, таким же образом, как он заполняется самим php, когда он получает такой запрос.

4. На самом деле у нас есть такая вещь в github.com/Codeception/util-universalframework но он используется только для запуска некоторых тестов самого Codeception.

5. Для этого нет рынка 🙂 Вы можете реализовать его самостоятельно и опубликовать в packagist. Интеграция Psr не будет иметь никаких преимуществ для модулей Symfony и Laravel, потому что они используют HttpKernelBrowser вместо BrowserKit, поэтому интеграция очень проста.