При написании автоматических тестов к PHP приложению могут возникать различные сложности. Одной из таких сложностей может быть зависимость от внешнего сервиса, к которому (используя его API) происходит обращение по HTTP. Конечно делать реальные запросы к сторонним ресурсам в тестах – не выход. Поэтому в определенный момент придется прибегнуть к созданию различных заглушек. В этой статье попробуем собрать различные способы использования заглушек.
Содержание статьи
- Виды заглушек
- Создание заглушки для GuzzleHttp\Client
- Обращения к внешним ресурсам и тесты используя Laravel
Виды заглушек
Разберем кратко какие виды заглушек бывают.
- Пустышки (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, то напишите, пожалуйста, о них в комментариях.