PHPUnit – testowanie aplikacji używającej zewnętrznego API
Załóżmy że mamy napisaną lub dopiero piszemy aplikację w PHP, która używa zewnętrznego API i z zwraca dane poprzez swoje własne API, czyli jest pośrednikiem.
Chcemy napisać testy naszych endpointów tak, żeby nie wykonywać zapytań do zewnętrznego API w czasie testów.
Jeden z naszych endpointów zwraca informacje o produkcie, jego adres to GET /product/{id}.
Nasz test mógłby wyglądać tak:
public function test_it_should_return_product() { $response = $this->get('/products/' . $this->faker->randomNumber()); $response->assertStatus(200); $response->assertJsonStructure([ "id", "name", "price" ]); }
W naszym kontrolerze używamy interfejsu ProductInterface który jest implementowany przez klasę ProductRepository, a ta z kolei używa klasy ProductProxy do wykonania zapytania do API. Klasas ProductProxy dziedziczy ogólną klasę BaseProxy, która zawiera metodę sendRequest(), to właśnie ta metoda odpowiada za wszelkie requesty HTTP do zewnętrznego API. Na początek więc zróbmy mock tej metody, aby upewnić się, że żaden z naszych testów nie wykona prawdziwego zapytania do zewnętrznego API.
protected function mockProxy() { $mock = Mockery::mock(BaseProxy::class)->makePartial(); $mock->shouldReceive(['sendRequest', 'post', 'get', 'delete'])->andThrow( new \Exception('Do not send real API calls in tests!') ); $this->app->instance(BaseProxy::class, $mock); }
Metodę tą umieściłem w klasie BaseApiTest, której definicja wygląda następująco:
abstract class BaseApiTest extends TestCase
Klasę tą dziedziczyć będą wszystkie moje klasy testów, np.:
class ProductsApiTest extends BaseApiTest
Od tego momentu wszystkie testy, które wywołują endpointy używające BaseProxy::sendRequest będą zwracały błąd, a więc zgodnie z podejściem TDD należy zmienić kod aby testy przechodziły. W naszym przypadku dodamy na początku naszego testu linijkę:
$this->mockJsonResponseMethod(ProductInterface::class, 'find', 'single_product.json');
Zatem nasz test teraz wygląda tak:
public function test_it_should_return_product() { $this->mockJsonResponseMethod(ProductInterface::class, 'find', 'single_product.json'); $response = $this->get('/products/' . $this->faker->randomNumber()); $response->assertStatus(200); $response->assertJsonStructure([ "id", “name”, “price” ]); }
Oczywiście nadal test się nie wykona, ponieważ metoda mockJsonResponseMethod nie istnieje. Dodajmy ją w klasie BaseApiTest.
protected function mockJsonResponseMethod(string $className, string $methodName, string $resultJsonFileName) { $mock = Mockery::mock($className)->makePartial(); $mock->shouldReceive($methodName)->andReturn( $this->getMockJson($resultJsonFileName) ); $this->app->instance($className, $mock); }
Dodatkowo dodamy również metodę getMockJson:
protected function getMockJson(string $fileName) { return json_decode( file_get_contents(base_path() . '/tests/Mocks/' . $fileName), false ); }
Co to robi?
Argumenty $className i $methodName metody mockJsonResponseMethod definiują jaką metodę jakiej klasy chcemy zamockować, natomiast argument $resultJsonFileName określa z jakiego pliku chcemy wczytać przykładową odpowiedź serwera. Odpowiedzi serwera trzymam w katalogu base_path() . ‚/tests/Mocks/’ w plikach w formacie json, a przykładowa odpowiedź dla endpointu zwracającego produkt może wyglądać następująco:
{ "product": { "id": 6178423, "name": "TDD Tutorial [e-book]", "price": "9.99" } }
Plik ma nazwę single-product.json i zawiera odpowiedź w dokładnie takiej strukturze jaka jest zwracana z zewnętrznego API.
Metoda getMockJson wczytuje plik i zwraca go w postaci obiektu, następnie metoda mockJsonResponseMethod używa tego obiektu jako odpowiedzi na wywołanie mockowanej metody. W naszym przypadku jest to metoda find() z klasy implementujacej ProductInterface.
Małe wyjaśnienie jak rozpoznawana jest klasa implementująca ProductInterface. W moim projekcie, który jest napisany w Laravelu, używam do tego bindowania w ServiceProvider (a konkretnie w RepositoryServiceProvider):
$this->app->bind( \App\Repositories\Interfaces\ProductInterface::class, \App\Repositories\Api\ProductRepository::class );
W tym momencie mamy zamockowaną odpowiedź zewnętrznego API i możemy raz jeszcze uruchomić test, tym razem zobaczymy że został on wykonany poprawnie.
W moich projektach katalog base_path() . ‚/tests/Mocks/’ zawiera kilkadziesiąt plików json zawierających różne rodzaje odpowiedzi: listy encji, szczegóły pojedynczej encji, listy encji z metadanymi do paginacji. Podejście to jest wygodne, ponieważ po jednorazowym zaimplementowaniu metody mockJsonResponseMethod wystarczy że dodamy jej wywołanie do naszego testu i stworzymy nowy plik json. Dodatkowo metoda mockProxy będzie nam pilnować abyśmy nigdzie nie przeoczyli wywołania mockJsonResponseMethod. Jedyne o czym musimy pamiętać to dziedziczenie klasy BaseApiTest przez nasze klasy z testami.
Powyższe rozwiązanie jest tylko jedną z wielu możliwych implementacji, podchodząc do tematu nieco inaczej można na przykład utworzyć trait i w nim umieścić przedstawione metody.
Skomentuj