Статьи

Тестирование API с RAML

В недавней статье я рассмотрел RESTful API Modeling Language (RAML). Я представил обзор того, что такое RAML, как его написать и как его использовать.

На этот раз я расскажу о некоторых способах использования RAML для тестирования. Мы начнем с использования RAML для проверки ответов от API. Затем мы рассмотрим подход, который вы могли бы использовать для имитации сервера API, используя файл RAML для создания ложных HTTP-ответов.

Проверка API-ответов

Сначала давайте определим простой файл RAML для вымышленного API. Я пропустил несколько маршрутов, но этого будет достаточно, чтобы продемонстрировать принципы.

#%RAML 0.8 title: Albums version: v1 baseUri: http://localhost:8000 traits: - secured: description: Some requests require authentication queryParameters: accessToken: displayName: Access Token description: An access token is required for secure routes required: true - unsecured: description: This is not secured /account: displayName: Account get: description: Get the currently authenticated user's account details. is: [secured] responses: 200: body: application/json: schema: | { "$schema": "http://json-schema.org/schema#", "type": "object", "description": "A user", "properties": { "id": { "description": "Unique numeric ID for this user", "type": "integer" }, "username": { "description": "The user's username", "type": "string" }, "email": { "description": "The user's e-mail address", "type": "string", "format": "email" }, "twitter": { "description": "User's Twitter screen name (without the leading @)", "type": "string", "maxLength": 15 } }, "required": [ "id", "username" ] } example: | { "id": 12345678, "username": "joebloggs", "email": "[email protected]", "twitter": "joebloggs" } put: description: Update the current user's account /albums: displayName: Albums /{id}: displayName: Album uriParameters: id: description: Numeric ID which represents the album /tracks: displayName: Album Tracklisting get: responses: 200: body: application/json: schema: | { "$schema": "http://json-schema.org/schema#", "type": "array", "description": "An array of tracks", "items": { "id": { "description": "Unique numeric ID for this track", "type": "integer" }, "name": { "description": "The name of the track", "type": "string" } }, "required": [ "id", "name" ] } example: | [ { "id": 12345, "name": "Dark & Long" }, { "id": 12346, "name": "Mmm Skyscraper I Love You" } ] 

Обратите внимание, что мы определили признак для защищенных маршрутов, который ожидает токен доступа в качестве параметра запроса. Мы определили несколько доступных маршрутов и определили некоторые схемы JSON, чтобы указать формат результатов. Мы также включили несколько примеров ответов; это то, что мы собираемся использовать для создания ложных ответов.

Давайте создадим приложение, которое мы будем использовать для обеих частей этого урока. Вы найдете это на Github .

В этой первой части я покажу, как можно проанализировать файл RAML, извлечь схему для заданного маршрута и затем использовать ее для проверки.

Создайте каталог проекта и создайте файл test/fixture/api.raml с указанным выше содержимым.

Мы собираемся использовать Guzzle для доступа к API, PHPUnit в качестве среды тестирования и этот анализатор RAML на основе PHP . Итак, создайте composer.json чтобы определить эти зависимости:

 { "name": "sitepoint/raml-testing", "description": "A simple example of testing APIs against RAML definitions", "require": { "alecsammon/php-raml-parser": "dev-master", "guzzle/guzzle": "~3.9@dev", "phpunit/phpunit": "~4.6@dev" }, "authors": [ { "name": "lukaswhite", "email": "[email protected]" } ], "autoload": { "psr-0": { "Sitepoint\\": "src/" } }, "minimum-stability": "dev" } 

Запустите composer install чтобы загрузить необходимые пакеты.

Теперь давайте создадим простой тест, который проверяет ответ от API. Начнем с файла phpunit.xml :

 <?xml version="1.0" encoding="UTF-8"?> <phpunit colors="true" convertErrorsToExceptions="false" convertNoticesToExceptions="false" convertWarningsToExceptions="false" stopOnFailure="false" bootstrap="vendor/autoload.php" strict="true" verbose="true" syntaxCheck="true"> <testsuites> <testsuite name="PHP Raml Parser"> <directory>./test</directory> </testsuite> </testsuites> <filter> <whitelist> <directory suffix=".php">./src</directory> </whitelist> </filter> </phpunit> во <?xml version="1.0" encoding="UTF-8"?> <phpunit colors="true" convertErrorsToExceptions="false" convertNoticesToExceptions="false" convertWarningsToExceptions="false" stopOnFailure="false" bootstrap="vendor/autoload.php" strict="true" verbose="true" syntaxCheck="true"> <testsuites> <testsuite name="PHP Raml Parser"> <directory>./test</directory> </testsuite> </testsuites> <filter> <whitelist> <directory suffix=".php">./src</directory> </whitelist> </filter> </phpunit> во <?xml version="1.0" encoding="UTF-8"?> <phpunit colors="true" convertErrorsToExceptions="false" convertNoticesToExceptions="false" convertWarningsToExceptions="false" stopOnFailure="false" bootstrap="vendor/autoload.php" strict="true" verbose="true" syntaxCheck="true"> <testsuites> <testsuite name="PHP Raml Parser"> <directory>./test</directory> </testsuite> </testsuites> <filter> <whitelist> <directory suffix=".php">./src</directory> </whitelist> </filter> </phpunit> 

PHP RAML Parser в настоящее время показывает ошибку устаревания. Чтобы обойти это, мы устанавливаем convertErrorsToExceptions , convertNoticesToExceptions и convertWarningsToExceptions в false .

Давайте создадим скелетный тестовый класс. Назовите этот test/AccountTest.php и начните с определения setUp() :

 <?php class AccountTest extends PHPUnit_Framework_TestCase { /** * @var \Raml\Parser */ private $parser; public function setUp() { parent::setUp(); $parser = new \Raml\Parser(); $this->api = $parser->parse(__DIR__.'/fixture/api.raml'); $routes = $this->api->getResourcesAsUri()->getRoutes(); $response = $routes['GET /account']['response']->getResponse(200); $this->schema = $response->getSchemaByType('application/json'); } } 

Здесь мы анализируем файл RAML, а затем извлекаем все определенные маршруты. Далее мы вытаскиваем маршрут, обозначенный строкой GET /account . Из этого мы извлекаем определение успешного ответа, и из этого мы получаем схему JSON, которая определяет ожидаемую структуру ответа JSON.

Теперь мы можем создать простой тест, который вызывает нашу конечную точку, проверяет, что мы возвращаем статус 200, что формат ответа — JSON и что он проверяется на соответствие схеме.

 /** @test */ public function shouldBeExpectedFormat() { $accessToken = 'some-secret-token'; $client = new \Guzzle\Http\Client(); $request = $client->get($this->api->getBaseUri() . '/account', [ 'query' => [ 'accessToken' => $accessToken, ] ]); $response = $client->send($request); // Check that we got a 200 status code $this->assertEquals( 200, $response->getStatusCode() ); // Check that the response is JSON $this->assertEquals( 'application/json', $response->getHeader('Content-Type')->__toString()); // Check the JSON against the schema $this->assertTrue($this->schema->validate($response->getBody())); } 

Это так просто; RAML Parser предоставляет валидатор для нашей определенной JSON-схемы.

Существует несколько способов использования RAML для тестирования ваших API. Как и JSON-схемы, RAML также поддерживает XML-схемы, поэтому принцип проверки результатов в формате XML будет в целом схожим. Вы можете проверить, возвращаются ли соответствующие коды состояния, существуют ли все маршруты, определенные в вашем RAML, и так далее.

В следующем разделе мы рассмотрим использование RAML для макетирования ответов API.

Дразнить API, используя RAML

Еще одна полезная вещь, которую мы можем сделать с RAML — это использовать ее для насмешки API Вероятно, существует слишком много вариаций в разных API для создания единого класса подгонки под все, поэтому давайте создадим тот, который можно настроить в соответствии с вашими требованиями.

Что мы сделаем, это создадим три вещи:

  • Класс «ответа», который инкапсулирует стандартные данные ответа HTTP, такие как код состояния и тело
  • Класс, который использует RAML для ответа на «URL»
  • Простой «сервер», который вы можете запустить на веб-сервере

Для простоты мы будем использовать тот же код, что и в предыдущем разделе. Нам просто нужно добавить дополнительную зависимость; FastRoute , простой и быстрый компонент маршрутизации, который мы будем использовать для определения маршрута, на который мы будем отвечать. Добавьте его в раздел require вашего файла composer.json :

 "nikic/fast-route": "~0.3.0" 

Теперь давайте создадим действительно простой класс Response; создайте это в src/Sitepoint/Response.php :

 <?php namespace Sitepoint; class Response { /** * The HTTP status code * @var integer */ public $status; /** * The body of the response * @var string */ public $body; /** * An array of response headers * @var array */ public $headers; /** * Constructor * * @param integer $status The HTTP status code */ public function __construct($status = 200) { $this->status = $status; $this->headers = [ 'Content-Type' => 'application/json' ]; } /** * Sets the response body * * @param string $body */ public function setBody($body) { $this->body = $body; $this->headers['Content-Length'] = strlen($body); } } 

Здесь нет ничего сложного. Обратите внимание, что мы собираемся смоделировать API, который только «говорит» на JSON, поэтому мы принудительно устанавливаем Content-Type для application/json .

Теперь давайте запустим класс с заданным HTTP-глаголом и путем, просмотрим файл RAML, чтобы найти подходящий маршрут, и вернем соответствующий ответ. Мы сделаем это, вытащив example из соответствующего типа ответа. Для целей этого компонента это всегда будет успешный (код состояния 200 ) ответ JSON.

Создайте файл src/Sitepoint/RamlApiMock.php и начните класс со следующего:

 <?php namespace Sitepoint; class RamlApiMock { /** * Constructor * * @param string $ramlFilepath Path to the RAML file to use */ public function __construct($ramlFilepath) { // Create the RAML parser and parse the RAML file $parser = new \Raml\Parser(); $api = $parser->parse($ramlFilepath); // Extract the routes $routes = $api->getResourcesAsUri()->getRoutes(); $this->routes = $routes; // Iterate through the available routes and add them to the Router $this->dispatcher = \FastRoute\simpleDispatcher(function(\FastRoute\RouteCollector $r) use ($routes) { foreach ($routes as $route) { $r->addRoute($route['method'], $route['path'], $route['path']); } }); } } 

Давайте посмотрим, что мы здесь делаем.

Как и раньше, мы разбираем файл RAML. Затем мы извлекаем все доступные маршруты. Далее мы перебираем их и добавляем в коллекцию, которая совместима с компонентом FastRoute. К счастью для нас, он использует тот же формат, так что он сможет перевести следующее:

 /albums/123/tracks 

к этому:

 /albums/{id}/tracks 

Метод addRoute() принимает в качестве третьего аргумента имя функции, предназначенной для обработки маршрута. Обычно вы определяете отдельные функции для соответствующей обработки каждого маршрута. Однако, поскольку мы собираемся обрабатывать все маршруты с использованием одного и того же кода, мы слегка переопределяем это поведение, а вместо имени функции добавляем путь во второй раз. Таким образом, мы можем извлечь путь в нашем обработчике, чтобы определить, какой ответ нам нужно отправить.

Давайте создадим метод dispatch() .

 /** * Dispatch a route * * @param string $method The HTTP verb (GET, POST etc) * @param string $url The URL * @param array $data An array of data (Note, not currently used) * @param array $headers An array of headers (Note, not currently used) * @return Response */ public function dispatch($method, $url, $data = array(), $headers = array()) { // Parse the URL $parsedUrl = parse_url($url); $path = $parsedUrl['path']; // Attempt to obtain a matching route $routeInfo = $this->dispatcher->dispatch($method, $path); // Analyse the route switch ($routeInfo[0]) { case \FastRoute\Dispatcher::NOT_FOUND: // Return a 404 return new Response(404); break; case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED: // Method not allows (405) $allowedMethods = $routeInfo[1]; // Create the response... $response = new Response(405); // ...and set the Allow header $response->headers['Allow'] = implode(', ', $allowedMethods); return $response; break; case \FastRoute\Dispatcher::FOUND: $handler = $routeInfo[1]; $vars = $routeInfo[2]; $signature = sprintf('%s %s', $method, $handler); $route = $this->routes[$signature]; // Get any query parameters if (isset($parsedUrl['query'])) { parse_str($parsedUrl['query'], $queryParams); } else { $queryParams = []; } // Check the query parameters $errors = $this->checkQueryParameters($route, $queryParams); if (count($errors)) { $response = new Response(400); $response->setBody(json_encode(['errors' => $errors])); return $response; } // If we get this far, is a successful response return $this->handleRoute($route, $vars); break; } } 

Так что здесь происходит?

Мы начинаем с разбора URL и извлечения пути, затем используем FastRoute, чтобы попытаться найти подходящий маршрут.

RouteCollection dispatch() RouteCollection возвращает массив, первый элемент которого сообщает нам, является ли это действительным маршрутом, является ли он действительным, но недопустимым методом или просто не найден.

Если мы не можем найти соответствующий маршрут, мы генерируем 404 Not Found . Если метод не поддерживается, мы генерируем 405 Method Not Allowed , помещая разрешенные методы в соответствующий заголовок.

Третий случай, когда это становится интересным. Мы генерируем «сигнатуру», объединяя метод и путь, так что это выглядит примерно так:

 GET /account 

или:

 GET /albums/{id}/tracks 

Затем мы можем использовать это, чтобы получить определение маршрута из свойства $routes routs, которое, как вы помните, мы извлекли из нашего файла RAML.

Следующим шагом является создание массива параметров запроса, а затем вызов функции, которая проверяет их — мы вскоре перейдем к этой конкретной функции. Поскольку разные API могут обрабатывать ошибки совершенно по-разному, вы можете захотеть изменить это в соответствии с вашим API — в этом примере я просто возвращаю 400 Bad Request с телом, содержащим JSON-представление конкретных ошибок проверки.

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

Наконец, мы вызываем метод handleRoute() , передавая определение маршрута и любые параметры URI. Прежде чем мы рассмотрим это, давайте вернемся к проверке параметров нашего запроса.

 /** * Checks any query parameters * @param array $route The current route definition, taken from RAML * @param array $params The query parameters * @return boolean */ public function checkQueryParameters($route, $params) { // Get this route's available query parameters $queryParameters = $route['response']->getQueryParameters(); // Create an array to hold the errors $errors = []; if (count($queryParameters)) { foreach($queryParameters as $name => $param) { // If the display name is set then great, we'll use that - otherwise we'll use // the name $displayName = (strlen($param->getDisplayName())) ? $param->getDisplayName() : $name; // If the parameter is required but not supplied, add an error if ($param->isRequired() && !isset($params[$name])) { $errors[$name] = sprintf('%s is required', $displayName); } // Now check the format if (isset($params[$name])) { switch ($param->getType()) { case 'string': if (!is_string($params[$name])) { $errors[$name] = sprintf('%s must be a string'); } break; case 'number': if (!is_numeric($params[$name])) { $errors[$name] = sprintf('%s must be a number'); } break; case 'integer': if (!is_int($params[$name])) { $errors[$name] = sprintf('%s must be an integer'); } break; case 'boolean': if (!is_bool($params[$name])) { $errors[$name] = sprintf('%s must be a boolean'); } break; // date and file are omitted for brevity } } } } // Finally, return the errors return $errors; } 

Это довольно простые вещи — и обратите внимание, что я упустил некоторые типы параметров, чтобы не усложнять ситуацию, — но это можно использовать, чтобы поиграться с параметрами запросов и отклонить запросы, когда эти параметры не соответствуют спецификациям, изложенным в нашем RAML файл.

Наконец, handleRoute() :

 /** * Return a response for the given route * * @param array $route The current route definition, taken from RAML * @param array $vars An optional array of URI parameters * @return Response */ public function handleRoute($route, $vars) { // Create a reponse $response = new Response(200); // Return an example response, from the RAML $response->setBody($route['response']->getResponse(200)->getExampleByType('application/json')); // And return the result return $response; } 

Здесь мы извлекаем пример из соответствующего маршрута, а затем возвращаем его в качестве ответа с кодом состояния 200.

На этом этапе вы можете использовать RamlApiMock в своих модульных тестах. Однако, с помощью простого добавления, мы также можем предоставить этот компонент для имитации в качестве веб-службы, просто добавив к нему вызов с помощью простой логики маршрутизации.

Для этого создайте файл index.php со следующим содержимым:

 <?php require_once 'vendor/autoload.php'; use Sitepoint\RamlApiMock; // The RAML library is currently showing a deprecated error, so ignore it error_reporting(E_ERROR | E_WARNING | E_PARSE | E_NOTICE); // Create the router $router = new RamlApiMock('./test/fixture/api.raml'); // Handle the route $response = $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']); // Set the HTTP response code http_response_code($response->status); // Optionally set some response headers if (count($response->headers)) { foreach ($response->headers as $name => $value) { header(sprintf('%s: %s', $name, $value)); } } // Print out the body of the response print $response->body; 

Надеюсь, это довольно очевидно; мы создаем наш «маршрутизатор», обрабатываем запрошенную комбинацию URL-адреса и метода, затем отправляем ответ с соответствующим кодом состояния и любыми заголовками.

Теперь запустите сервер со следующим:

 php -S localhost:8000 

Поскольку API-интерфейсы значительно различаются, вероятно, вам придется изменить этот пример в соответствии с вашей реализацией. Надеюсь, это даст вам достаточно, чтобы начать.

Резюме

В этой статье я рассмотрел RAML в контексте API тестирования и насмешек.

Поскольку RAML предоставляет однозначное и исчерпывающее заявление о том, как должен функционировать API, это очень полезно как для тестирования, так и для предоставления ложных ответов.

С RAML вы можете сделать гораздо больше, и эти примеры только касаются того, как RAML можно использовать в тестировании, но, надеюсь, я предоставил вам несколько идей.