Тестирование 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 также подгружается из контейнера (через app()->make), а не создается через new. Это работает почти как new, но позволяет при запуске тестов приложению подменять реальную зависимость на заглушку.

В то же время в документации GuzzleHttp предлагается использовать для создания заглушек GuzzleHttp\Handler\MockHandler. Он позволяет задать тестовое поведение (какие ответы должны получать http-запросы) для GuzzleHttp\Client. А также проверять (используя GuzzleHttp\Middleware) какие данные были отправлены в запросах.

Немного модифицированный пример работы с GuzzleHttp\Handler\MockHandler из документации:

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Exception\RequestException;

// Задание текстового поведения. Какие ответы будут на запросы
$mock = new MockHandler([
    new Response(200, ['X-Foo' => 'Bar'], 'Hello, World'),
    new Response(202, ['Content-Length' => 0])
]);

$handlerStack = HandlerStack::create($mock);
// Передача тестового поведения в GuzzleHttp\Client
$client = new Client(['handler' => $handlerStack]);

// Первый запрос отработает с первым Response
$response = $client->request('GET', '/');
echo $response->getStatusCode(); // 200
echo $response->getBody(); // Hello, World
// Второй запрос отработает со вторым Response
echo $client->request('GET', '/')->getStatusCode(); // 202

Однако, чтобы ваш код работал с тестовым поведением необходимо заменить в нем ваш экземпляр GuzzleHttp\Client на тот, что настроен в тесте. И тут опять же можно либо передать в нужный класс параметром (если это реализовано в этом классе) либо использовать загрузку из контейнера зависимостей.

Второй пример из документации показывает как можно проверить отправляемые в запросе данные.

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;

$container = [];
$history = Middleware::history($container);

$handlerStack = HandlerStack::create();
// Или HandlerStack::create($mock), если используете Mock handler

// Добавляете middleware истории в стек
$handlerStack->push($history);

$client = new Client(['handler' => $handlerStack]);

$client->request('GET', 'http://httpbin.org/get');
$client->request('HEAD', 'http://httpbin.org/get');

// Число запросов
echo count($container); // 2

// Проходим черезе все запросы и изучаем отвтеты
foreach ($container as $transaction) {
    echo $transaction['request']->getMethod();
    // GET, HEAD
    if ($transaction['response']) {
        echo $transaction['response']->getStatusCode(); // 200, 200
    } elseif ($transaction['error']) {
        echo $transaction['error']; // exception
    }
    var_dump($transaction['options']); // Опции отправленного запроса
}

Это достаточно удобные инструменты во многих случаях. Хотелось бы, правда, иметь возможность при задании ответов на запросы опираться не на порядок, а на URL, например.

Обращения к внешним ресурсам и тесты используя 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, то напишите, пожалуйста, о них в комментариях.

Оставить ответ

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