Тестирование PHP кода с HTTP запросами к сторонним сервисам

При написании автоматических тестов к PHP приложению могут возникать различные сложности. Одной из таких сложностей может быть зависимость от внешнего сервиса, к которому (используя его API) происходит обращение по HTTP. Конечно делать реальные запросы к сторонним ресурсам в тестах – не выход. Поэтому в определенный момент придется прибегнуть к созданию различных заглушек. В этой статье попробуем собрать различные способы использования заглушек.

Содержание статьи

Виды заглушек

Разберем кратко какие виды заглушек бывают.

  • Пустышки (dummies) могут быть полезны, если работаете со старым кодом, где не используются подсказки типа (type hints), а вам в тесте нужна зависимость, методы которой вы не будете вызывать.
  • Стабы пригодятся, если вам нужна пустышка в типизированном коде (если методы также не будут вызываться). Тоесть тестируемый код зависит от некоторого класса, методы которого вызываться не будут.
  • Шпионы — если вам нужен стаб с вызовом методов, которые ничего делают (важно, чтобы они могли вызываться и только)
  • Моки — если вам нужна зависимость, где будут вызываться определённые методы, которые должны что-то вернуть или выполнить согласно тесту

Правда в реальной жизни (при использовании фреймворков для тестирования) можно встретить как одно называют другим, добавляют дополнительную функциональность и размывают границы между этими видами заглушек. Но для глубокого понимания, а также случая, когда понадобится тестировать код без любимого фреймворка, полезно знать исходный вариант и понимать, что заглушка это не всегда Mockery::mock. Заглушку можно сотворить и создав пустой класс или класс наследник от рабочей реализации. Главное, чтобы ваш контейнер зависимостей умел подменить рабочую реализацию на тестовую.

Создание заглушки для GuzzleHttp\Client

Вернемся к вопросу тестирования кода с HTTP запросами к внешним сайтам. Первое, что может помочь, в этом – проектирование тестируемого кода так, чтобы классы отвечающие за обращения к внешним API были четко выделены и позволяли заменить их на заглушки. Тогда все относительно просто. Мы подменяем наши классы на другие, которые возвращают такой результат как и классы, обращающиеся к сторонним URL.

Но не всегда тесты приходят в проект с его началом. Как и хорошая архитектура не всегда приходит сразу. Если классы в проекте не так грамотно спроектированы, то есть надежда, что в нем хотя бы используется для HTTP запросов популярная и продуманная библиотека.

Допустим, используется Guzzle. В коде делается HTTP запрос через метод post() класса GuzzleHttp\Client. Тогда код для создания заглушки в тесте с помощью Mockery::mock может выглядеть примерно так:

use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
//...
app()->bind(Client::class, function() {

    $client = Mockery::mock(Client::class);
    $stream = Utils::streamFor('SUCCESS');
    $response = new Response(200, [], $stream);
    $client->shouldReceive('post')
        ->times(1)
        ->andReturn($response);

    return $client;
});

Здесь правда используется контейнер зависимостей Laravel, который с помощью app()->bind() заменяет реальный класс GuzzleHttp\Client на заглушку, которая при вызове метода post() вернет HTTP код 200 и строку ‘SUCCESS’ как тело ответа. Но в общем-то контейнер зависимостей можно применить любой другой, если проект не на Laravel.

Отмечу, что, если надо чтобы метод у заглушки возвращал разные ответы для разных URL, то при создании заглушки необходимо будет дописать немного кода, чтобы это учесть.

Сам тестируемый код для примера выше мог бы выглядеть примерно так:

use GuzzleHttp\Client;
//...

$data = [
    // ....
];
$client = app()->make(Client::class, ['http_errors' => false]);
// Обращение к внешнему ресурсу через HTTP POST
$response = $client->post($url, [ 'verify' => false, RequestOptions::JSON => $data ]);

$httpCode = $response->getStatusCode();
$responseText = $response->getBody();
if ($httpCode !== 200 or $responseText !== 'SUCCESS') {
    $this->log()
        ->error("Результат - ERROR. URL: {$url}: [$httpCode] $responseText");
}

Здесь важно, что класс GuzzleHttp\Client также подгружается из контейнера, а не создается через new. Это позволяет при запуске тестов приложению подменять реальную зависимость на заглушку.

Обращения к внешним ресурсам и тесты используя Laravel

Если же проект на Laravel, то можно использовать и его удобную обертку к Guzzle HTTP client.

Фасад Http позволяет творить маленькие чудеса:

use Illuminate\Support\Facades\Http;

// Простой запрос
$response = Http::get('http://example.com');

// Сразу работаем с JSON
$userName = Http::get('http://example.com/users/1')['name'];

// Отправляем данные (по-умолчанию в JSON)
$response = Http::post('http://example.com/users', [
    'name' => 'Steve',
    'role' => 'Network Administrator',
]);

// Шаблоны URI
Http::withUrlParameters([
    'endpoint' => 'https://laravel.com',
    'page' => 'docs',
    'version' => '9.x',
    'topic' => 'validation',
])->get('{+endpoint}/{page}/{version}/{topic}');

// Отправка файла
$response = Http::attach(
    'attachment', file_get_contents('photo.jpg'), 'photo.jpg'
)->post('http://example.com/attachments');

// Повторные запросы
$response = Http::retry(3, 100)->post(/* ... */);

Но самое важное для нас это то, что мы получим при написании тестов. Так вот здесь становится жить гораздо легче. Можно сразу указать для какого URL какой ответ возвращать. В адресе можно использовать задание маски с помощью звездочек:

Http::fake([
    // Stub a JSON response for GitHub endpoints...
    'github.com/*' => Http::response(['foo' => 'bar'], 200, ['Headers']),
 
    // Stub a string response for all other endpoints...
    '*' => Http::response('Hello World', 200, ['Headers']),
]);

Можно описывать порядок следования запросов:

Http::fake([
    // Stub a series of responses for GitHub endpoints...
    'github.com/*' => Http::sequence()
                            ->push('Hello World', 200)
                            ->push(['foo' => 'bar'], 200)
                            ->pushStatus(404),
]);

А более сложную логику можно задавать при помощи функций обратного вызова:

use Illuminate\Http\Client\Request;
 
Http::fake(function (Request $request) {
    return Http::response('Hello World', 200);
});

Такой инструментарий сильно уменьшает рутину при написании автоматических тестов. А значит тестов успеем написать больше.

Если вы знаете какие-либо полезные инструменты при тестировании кода обращающегося к внешним ресурсам по HTTP, то напишите, пожалуйста, о них в комментариях.

Leave a Reply

Ваш адрес email не будет опубликован. Обязательные поля помечены *