Symfony: dlaczego StreamedResponse psuje moje testy?

Kategoria: Webmastering Data publikacji:

Ostatnio napotkałem ciekawy problem podczas pisania testów automatycznych do aplikacji PHP opartej na frameworku Symfony. Zmiana typu obiektu odpowiedzi HTTP rozsypała część moich testów i mocno zabolała mój terminal. W dzisiejszym wpisie podzielę się z wami opisem przyczyn tego problemu oraz moim rozwiązaniem.

Po przygotowaniu nowej funkcji aplikacji polegającej na eksportowaniu zbioru danych do arkusza kalkulacyjnego przy użyciu znakomitej biblioteki PhpSpreadsheet, napisałem niewielki test funkcjonalny, aby upewnić się, że żądanie wygeneruje dokument tylko dla użytkownika o wymaganym poziomie uprawnień. Jakież było moje zdziwinie, gdy w trakcie wykonywania PHPUnit ujrzałem mniej więcej coś takiego:

Cierpienia mojego terminala.

Standardowo kontroler w typowej aplikacji Symfony zwraca instancję klasy Response. Wewnątrz niej zapisany jest ciąg znaków określający treść odpowiedzi HTTP — podajemy ją w konstruktorze lub poprzez metodę setContent(). Front controller Symfony, tj. plik index.php, wypluwa treść z obiektu Response, wywołując jego metodę send().

class ExampleController extends AbstractController
{
    /**
     * @Route("/example")
     */
    public function index()
    {
        return new Response('💩 Fascinating placeholder here. 💩');
    }
}

Takie podejście sprawdza się w standardowych odpowiedziach aplikacji. Nie jest ono optymalne dla generowanych w locie binarnych dokumentów PDF lub arkuszy kalkulacyjnych, bowiem między wygenerowaniem a wysłaniem treści musi ona zostać przechowana w pamięci.

class ExampleController extends AbstractController
{
    /**
     * @Route("/example")
     */
    public function index()
    {
        return new StreamedResponse(function(){
            echo '💩 Fascinating placeholder here. 💩';
        });
    }
}

Dla takich zastosowań framework Symfony oferuje klasę StreamedResponse, która nie przecho­wuje w sobie treści — wywołanie setContent() wyrzuci wyjątek. Klasa ta przyjmuje w konstru­ktorze wywołanie zwrotne, którego zadaniem jest nie zwrócić, a od razu wyświetlić treść na ekranie. Framework Symfony gwarantuje, że callback zostanie wywołany w odpowiednim momencie realizacji żądania. Czyli właściwie kiedy?

Tu pojawia się niespodzianka. Tak jak bazowa klasa Response, tak i obiekt StreamedResponse posiada metodę send(), za pomocą której można jednokrotnie „wypluć” treść odpowiedzi na ekran. Okazuje się jednak, że framework Symfony posiada dedykowany event listener dla obiektów StreamedResponse. Zadaniem StreamedResponseListener jest przedwczesne wysłanie treści tak, aby z perspektywy pliku index.php uruchomienie callbacka odbyło się jeszcze w ramach wywołania $kernel->handle(), które ma miejsce linijkę przed wywołaniem metody send() obiektu Response.

Wyjaśnienia twórcy frameworka dla takiej decyzji są dla mnie trochę mgliste i trudne do zrozumienia, ale domyślam się, że może mieć to związek z obsługą ewentualnych wyjąków, jakie mogą zostać wyrzucone w trakcie dzialania callbacka z obiektu StreamedResponse.

class ExampleControllerTest extends WebTestCase
{
    public function testShowsPileOfPoo()
    {
        $client = static::createClient();
        $client->request('GET', '/example');

        $response = $client->getResponse();

        $this->assertStringContainsString('💩', $response->getContent());
    }
}

Zastąpienie obiektu klasy Response obiektem klasy StreamedResponse, jak również obecność StreamedResponseListener mają negatywny wpływ na testy funkcjonalne oparte na bibliotece Symfony BrowserKit. Co tu dużo mówić, psują je.

Pile of poo — jest, a jakby nie było.

Domyślny KernelBrowser nie wykonuje żądań HTTP, a jedynie symuluje je, korzystając z możliwości kernela frameworka Symfony. Ze względu na wbudowany listener, treść odpowiedzi StreamedResponse jest wypluwana w terminalu w trakcie wykonywania testów.

Można oczywiście zastąpić fake’owy klient HTTP biblioteką Symfony Panther, która oferuje to samo API, ale korzysta z prawdziwej przeglądarki internetowej. Ja jednak chciałem uniknąć takiego podejścia ze względu na fakt, że strona internetowa, nad którą pracowałem, pozbawiona jest dynamiki po stronie klienta i wpychanie do testów przeglądarki w praktyce nie wniosłoby w moim odczuciu nic do ich jakości.

Aby rozwiązać problem, wyłączyłem StreamedResponseListener dla środowiska testowego. W tym celu w pliku config/services_test.yaml wpisałem:

services:
    streamed_response_listener:
        class: Symfony\Component\HttpKernel\EventListener\StreamedResponseListener
        tags: ~

W podanym wyżej przykladzie trzeba też wprowadzić drobną zmianę w kodzie testu. Metoda getResponse() klienta BrowserKit zwraca obiekt odpowiedzi zwrócony przez kontroler — w tym przypadku jest to instancja klasy StreamedResponse. Ze względu na swoją specyfikę, nie przechowuje ona treści, dlatego wywołanie na niej getContent() nic nie zwróci.

class ExampleControllerTest extends WebTestCase
{
    public function testShowsPileOfPoo()
    {
        $client = static::createClient();
        $client->request('GET', '/example');

        $response = $client->getInternalResponse();

        $this->assertStringContainsString('💩', $response->getContent());
    }
}

Konieczne jest zastąpienie getResponse() metodą getInternalResponse(), która zwraca odpowiedź z perspektywy biblioteki BrowserKit, a nie z perspektywy kodu naszej aplikacji. Taki obiekt również posiada metodę getContent(), ale tym razem zwróci ona faktyczną treść odpowiedzi aplikacji.

No i teraz działa!

Powyższą wskazówkę sprawdziłem w Symfony 4.4 i Symfony 5.1.

Dodaj komentarz