Symfony: dlaczego StreamedResponse psuje moje testy?
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:
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 przechowuje w sobie treści — wywołanie setContent()
wyrzuci wyjątek. Klasa ta przyjmuje w konstruktorze 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
.
Aktualizacja: w trakcie pracy nad jednym z projektów odkryłem, iż obecność StreamedResponseListener
jest wymagana do poprawnego działania usługi RequestStack
, bowiem rozpoczynanie jak również zakańczanie bieżącego żądania HTTP wewnątrz RequestStack
odbywa się właśnie w $kernel->handle()
. Zaaplikowanie poniższej porady, tj. wyłączenie StreamedResponseListener
sprawi, że wewnątrz callbacka obiektu StreamedResponse
metoda RequestStack::getCurrentRequest()
zwróci null
zamiast obiektu Request
.
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.
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.
Powyższą wskazówkę sprawdziłem w Symfony 4.4 i Symfony 5.1.
Dodaj komentarz