Статьи

Клиент TDD API с ложными ответами

В первой и второй частях мы создали некоторые базовые функции и использовали TDD с PHPUnit, чтобы убедиться, что наши классы хорошо протестированы. Мы также узнали, как тестировать абстрактный класс, чтобы убедиться, что его конкретные методы работают. Теперь давайте продолжим строить нашу библиотеку.

Наверстать

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

Я также добавил несколько более простых методов и тестирование различных экземпляров API и настраиваемых полей для API в смеси с динамическими установщиками и получателями, используя __call . Это казалось слишком сложной работой, чтобы беспокоить вас, поскольку это очень повторяющееся и, в конечном счете, бесполезное решение, но, если вам интересно, оставьте комментарий ниже, и мы рассмотрим различия part2-end> part3-start в другом посте. — Вы можете даже различать различные файлы и спрашивать о конкретных различиях на форумах, я был бы рад ответить на них, насколько мне известно, а также принять некоторые советы относительно их дизайна. Кроме того, я переместил директиву runInSeparateProcess из всего класса DiffbotTest в тест, для которого требуется пустой статический класс, что позволило сократить продолжительность всего этапа тестирования до нескольких секунд.

Если вы только что присоединились к нам, пожалуйста, скачайте стартовую ветку части 3 и узнайте больше.

Data Mocking

Мы упоминали прежде, что мы будем издеваться над данными в этой части. Это может показаться более запутанным, чем это, поэтому позвольте мне уточнить. Когда мы запрашиваем URL через Diffbot, мы ожидаем определенного результата. Например, запрашивая определенный продукт Amazon, мы ожидаем получить проанализированные значения для этого продукта. Однако, если мы опираемся на эти данные в наших тестах, мы сталкиваемся с двумя проблемами:

  1. X замедляет тесты, где X — время, необходимое для извлечения данных из Amazon
  2. Данные могут измениться и сломать наши тесты. Неожиданно некоторая информация, на которую опирались наши тесты, может сломаться из-за того, что возвращаются разные значения.

Поэтому лучше всего кэшировать весь ответ на заданный вызов API в автономном режиме — заголовки и все — и использовать его для фальсификации ответа на Guzzle (функциональность встроена в Guzzle). Таким образом, мы можем кормить Diffbot подделкой каждый раз во время тестов и следить за тем, чтобы он получал одни и те же данные, что дает нам согласованные результаты Мэтью Сеттер писал здесь о том, как обрабатывать данные с помощью Guzzle и PHPUnit, если вы хотите взглянуть.

Чтобы добраться до необходимого нам уровня тестирования, мы будем подделывать данные, которые возвращает Diffbot. Разве это не означает, что мы не проводим эффективное тестирование самого Diffbot, а только можем анализировать данные? Точно, это так. Мы не должны тестировать Diffbot — это делает команда Diffbot. Здесь мы тестируем возможность инициировать вызовы API и анализировать возвращаемые данные — вот и все.

Обновление основного класса

Во-первых, нам нужно обновить класс Diffbot. Наши подклассы API должны знать о токене и о используемом нами HTTP-клиенте. Чтобы сделать это возможным, мы будем регистрировать экземпляр Diffbot в подклассах API после их создания.

Сначала добавьте следующее свойство в абстрактный класс Api:

 /** @var Diffbot The parent class which spawned this one */ protected $diffbot; 

и затем добавьте следующий метод:

 /** * Sets the Diffbot instance on the child class * Used to later fetch the token, HTTP client, EntityFactory, etc * @param Diffbot $d * @return $this */ public function registerDiffbot(Diffbot $d) { $this->diffbot = $d; return $this; } 

Затем нам нужно немного обновить класс Diffbot.

Добавьте следующее содержимое в Diffbot.php :

 // At the top: use GuzzleHttp\Client; // As a property: /** @var Client The HTTP clients to perform requests with */ protected $client; // Methods: /** * Sets the client to be used for querying the API endpoints * * @param Client $client * @return $this */ public function setHttpClient(Client $client = null) { if ($client === null) { $client = new Client(); } $this->client = $client; return $this; } /** * Returns either the instance of the Guzzle client that has been defined, or null * @return Client|null */ public function getHttpClient() { return $this->client; } /** * Creates a Product API interface * * @param $url string Url to analyze * @return Product */ public function createProductAPI($url) { $api = new Product($url); if (!$this->getHttpClient()) { $this->setHttpClient(); } return $api->registerDiffbot($this); } /** * Creates an Article API interface * * @param $url string Url to analyze * @return Article */ public function createArticleAPI($url) { $api = new Article($url); if (!$this->getHttpClient()) { $this->setHttpClient(); } return $api->registerDiffbot($this); } /** * Creates an Image API interface * * @param $url string Url to analyze * @return Image */ public function createImageAPI($url) { $api = new Image($url); if (!$this->getHttpClient()) { $this->setHttpClient(); } return $api->registerDiffbot($this); } /** * Creates an Analyze API interface * * @param $url string Url to analyze * @return Analyze */ public function createAnalyzeAPI($url) { $api = new Analyze($url); if (!$this->getHttpClient()) { $this->setHttpClient(); } return $api->registerDiffbot($this); } 

Мы добавили возможность установить клиента и установили по умолчанию новый экземпляр Guzzle Client. Мы также улучшили прямые методы создания экземпляров подтипов API, которые почти идентичны. Это сделано специально — нам может понадобиться определенная конфигурация для каждого типа API позже, поэтому их разделение таким образом принесет нам пользу в долгосрочной перспективе. Аналогично, он содержит некоторый идентичный код, который мы позже обнаружим и исправим с помощью PHPCPD (PHP Copy Paste Detector). Класс Diffbot теперь также внедряется в порожденные классы API.

Фабрики и Предприятия

Вы можете быть удивлены — разве это не хорошее место для фабричного образца? Создание объекта, который строго отвечает за создание API и ничего более? Конечно, это может быть так, но, на мой взгляд, это слишком сложно. Класс Diffbot всегда предназначался для возврата новых экземпляров различных API, и как таковой он выполняет свою функцию через эти методы. Точно так же наша библиотека имеет очень конкретное назначение и с самого начала должна была сильно зависеть от Guzzle. Слишком много абстрагировало бы нас от сложности чего-то слишком простого и тратило бы наше время. Diffbot — это наша фабрика.

Но есть одна вещь, которую он не может и не должен делать. В конечном итоге мы хотим, чтобы библиотека могла делать это возвращать нам объект, например, объект «Продукт», с аксессорами, которые позволяют нам читать извлеченные и проанализированные поля бегло, объектно-ориентированным образом. Другими словами:

 // ... set URL etc $product = $productApi->call(); echo $product->getOfferPrice(); 

На этот раз, чтобы сделать это возможным, нам понадобится завод. Почему фабрика, а не просто классы API, сами создают экземпляры типа «Продукт»? Поскольку кто-то, вступающий в контакт с нашей библиотекой, может захотеть проанализировать результаты JSON, возвращаемые Diffbot, другим способом — он может захотеть изменить вывод так, чтобы он соответствовал их базе данных и был совместим с прямой вставкой, или он мог бы хотеть простой способ сравнить это с их собственными продуктами, например.

Чтобы иметь возможность возвращать такие объекты, им нужен интерфейс, если они должны быть взаимозаменяемыми. Однако, поскольку мы знаем некоторые из их постоянных функций, давайте вместо этого сделаем резюме. Создайте src/Abstracts/Entity.php :

 <?php namespace Swader\Diffbot\Abstracts; use GuzzleHttp\Message\Response; abstract class Entity { /** @var Response */ protected $response; /** @var array */ protected $objects; public function __construct(Response $response) { $this->response = $response; $this->objects = $response->json()['objects'][0]; } /** * Returns the original response that was passed into the Entity * @return Response */ public function getResponse() { return $this->response; } } 

Diffbot API возвращает объект JSON с двумя подобъектами: request и objects , что видно из вывода JSON из тест-драйва на целевой странице. Ответное сообщение Guzzle поддерживает вывод данных JSON в массивы, но это все. Таким образом, у этого класса есть только конструктор, в который он принимает объект ответа, а затем связывает первый элемент поля «объекты» (тот, который содержит значимые данные) с другим защищенным свойством.

Чтобы фабрика была взаимозаменяемой, давайте дадим ей интерфейс. Создайте src/Interfaces/EntityFactory.php :

 <?php namespace Swader\Diffbot\Interfaces; use GuzzleHttp\Message\Response; interface EntityFactory { /** * Returns the appropriate entity as built by the contents of $response * * @param Response $response * @return Entity */ public function createAppropriate(Response $response); } 

Теперь мы можем это реализовать. Создайте src/Factory/Entity.php :

 <?php namespace Swader\Diffbot\Factory; use GuzzleHttp\Message\Response; use Swader\Diffbot\Exceptions\DiffbotException; use Swader\Diffbot\Interfaces\EntityFactory; class Entity implements EntityFactory { protected $apiEntities = [ 'product' => '\Swader\Diffbot\Entity\Product', 'article' => '\Swader\Diffbot\Entity\Article', 'image' => '\Swader\Diffbot\Entity\Image', 'analyze' => '\Swader\Diffbot\Entity\Analyze', '*' => '\Swader\Diffbot\Entity\Wildcard', ]; /** * Creates an appropriate Entity from a given Response * If no valid Entity can be found for typoe of API, the Wildcard entity is selected * * @param Response $response * @return \Swader\Diffbot\Abstracts\Entity * @throws DiffbotException */ public function createAppropriate(Response $response) { $this->checkResponseFormat($response); $arr = $response->json(); if (isset($this->apiEntities[$arr['request']['api']])) { $class = $this->apiEntities[$arr['request']['api']]; } else { $class = $this->apiEntities['*']; } return new $class($response); } /** * Makes sure the Diffbot response has all the fields it needs to work properly * * @param Response $response * @throws DiffbotException */ protected function checkResponseFormat(Response $response) { $arr = $response->json(); if (!isset($arr['objects'])) { throw new DiffbotException('Objects property missing - cannot extract entity values'); } if (!isset($arr['request'])) { throw new DiffbotException('Request property not found in response!'); } if (!isset($arr['request']['api'])) { throw new DiffbotException('API property not found in request property of response!'); } } } 

Это наша основная фабрика. Он проверяет, является ли ответ действительным, а затем на основе этого ответа создает объект, помещает в него ответ и возвращает его. Если он не может найти действительный объект (например, поле «api» в ответе не соответствует ключу, как определено в свойстве apiEntities ), он выбирает объект с подстановочными знаками. Позже мы могли бы даже обновить эту фабрику с возможностью изменить только некоторые или все пары apiEntities , чтобы пользователям не приходилось писать совершенно новую фабрику, чтобы просто попробовать другую сущность, но давайте оставим это на данный момент. Конечно, класс Factory тоже нуждается в тестировании. Чтобы увидеть тест, обратитесь к исходному коду на Github — ссылка внизу поста.

Наконец, нам нужно построить некоторые из этих сущностей, иначе все было напрасно. Для начала создайте src/Entity/Product.php . Что мы можем ожидать от нашего вызова API продукта? Давайте взглянем.

Сосредоточив внимание только на «корневых» значениях в свойстве «объект», мы видим, что мы мгновенно получаем Title, Text, Availability, OfferPrice и Brand, среди прочих. Мы могли бы использовать что-то вроде магического метода __call для автоматического определения того, что мы ищем в массиве, но для ясности, автозаполнения IDE и возможности дополнительного анализа, давайте сделаем это вручную. Давай просто соберем тех немногих, а остальное оставлю тебе в качестве упражнения. Если вы хотите посмотреть, как я это сделал, обратитесь к исходному коду в конце статьи.

 <?php namespace Swader\Diffbot\Entity; use Swader\Diffbot\Abstracts\Entity; class Product extends Entity { /** * Checks if the product has been determined available * @return bool */ public function isAvailable() { return (bool)$this->objects['availability']; } /** * Returns the product offer price, in USD, as a floating point number * @return float */ public function getOfferPrice() { return (float)trim($this->objects['offerPrice'], '$'); } /** * Returns the brand, as determined by Diffbot * @return string */ public function getBrand() { return $this->objects['brand']; } /** * Returns the title, as read by Diffbot * @return string */ public function getTitle() { return $this->objects['title']; } } 

Здесь вы можете увидеть, что потенциально хорошим вариантом будет внедрение конвертера валют в сущность Product, возможно, даже поддержка внедрения различных конвертеров. Это позволит пользователям читать цену товара в валютах, отличных от цены по умолчанию.

Естественно, сущность Product тоже нуждается в тестировании. Для краткости я просто отсылаю вас к исходному коду в конце поста.

Наконец, нам нужно добавить EntityFactory к экземпляру Diffbot, аналогично тому, что мы сделали с клиентом Guzzle:

 /** * Sets the Entity Factory which will create the Entities from Responses * @param EntityFactory $factory * @return $this */ public function setEntityFactory(EntityFactory $factory = null) { if ($factory === null) { $factory = new Entity(); } $this->factory = $factory; return $this; } /** * Returns the Factory responsible for creating Entities from Responses * @return EntityFactory */ public function getEntityFactory() { return $this->factory; } 

Не забудьте добавить защищенное factory свойство:

 /** @var EntityFactory The Factory which created Entities from Responses */ protected $factory; 

Теперь давайте поговорим еще немного о насмешке над ресурсами.

Создание Mocks

Создание фиктивных файлов ответов для использования с Guzzle очень просто (и необходимо, чтобы иметь возможность тестировать наши классы). Они должны выглядеть примерно так — только с телом JSON вместо XML. Это легко сделать с помощью cURL. Сначала создайте папку tests/Mocks/Products . Используйте терминал, чтобы войти в него, и выполните следующую команду:

 curl -i "http://api.diffbot.com/v3/product?url=http%3A%2F%2Fwww.petsmar t.com%2Fdog%2Fgrooming-supplies%2Fgrreat-choice-soft-slicker-dog-brush-zid36-12094%2Fcat-36-catid-100016&token =demo&fields=saveAmount,mpn,prefixCode,meta,sku,queryString,saveAmountDetails,shippingAmount,productOrigin,regularPriceDetails,offerPriceDetails" > dogbrush.json 

Открытие файла dogbrush.json покажет вам полное содержание ответа — как заголовки, так и тело.

Тестирование звонка

Теперь, когда у нас dogbrush ответ « dogbrush , и все наши классы готовы, мы можем использовать его для тестирования API продукта. Для этого нам нужно разработать метод call . Как только вызов будет выполнен, мы ожидаем получить некоторые значения, которые Diffbot проанализировал, и мы ожидаем, что они будут правильными.

Начнем с теста. Отредактируйте ProductApiTest.php чтобы он выглядел так:

 <?php namespace Swader\Diffbot\Test\Api; use GuzzleHttp\Client; use GuzzleHttp\Subscriber\Mock; use Swader\Diffbot\Diffbot; class ProductApiTest extends \PHPUnit_Framework_TestCase { protected $validMock; protected function getValidDiffbotInstance() { return new Diffbot('demo'); } protected function getValidMock(){ if (!$this->validMock) { $this->validMock = new Mock( [file_get_contents(__DIR__.'/../Mocks/Products/dogbrush.json')] ); } return $this->validMock; } public function testCall() { $diffbot = $this->getValidDiffbotInstance(); $fakeClient = new Client(); $fakeClient->getEmitter()->attach($this->getValidMock()); $diffbot->setHttpClient($fakeClient); $diffbot->setEntityFactory(); $api = $diffbot->createProductAPI('https://dogbrush-mock.com'); /** @var Product $product */ $product = $api->call(); $targetTitle = 'Grreat Choice® Soft Slicker Dog Brush'; $this->assertEquals($targetTitle, $product->getTitle()); $this->assertTrue($product->isAvailable()); $this->assertEquals(4.99, $product->getOfferPrice()); $this->assertEquals('Grreat Choice', $product->getBrand()); } } 

Сначала мы получаем действительный экземпляр Diffbot, создаем и внедряем поддельный клиент Guzzle с нашим ранее загруженным ответом. Мы устанавливаем Entity Factory, и после вызова call мы ожидаем, что будет возвращен определенный заголовок. Если это утверждение не выполнено, тест не пройден. Мы также проверяем другие свойства, для которых мы знаем истинные значения.

Теперь нам нужно разработать метод call . Он будет одинаковым для всех наших API (все они делают одно и то же, по сути — удаленный запрос к заданному URL), поэтому мы помещаем его в абстрактный класс Api:

 public function call() { $response = $this->diffbot->getHttpClient()->get($this->buildUrl()); return $this->diffbot->getEntityFactory()->createAppropriate($response); } 

Здесь вы можете увидеть, что мы используем метод buildUrl . Этот метод будет использовать все настраиваемые поля API для улучшения URL-адреса по умолчанию и передачи полей вместе с запросом, чтобы вернуть дополнительные значения, которые мы запросили.

Сначала мы напишем несколько тестов для этого в ProductApiTest, чтобы мы знали, что сборка работает для данного типа API:

 public function testBuildUrlNoCustomFields() { $url = $this ->apiWithMock ->buildUrl(); $expectedUrl = 'http://api.diffbot.com/v3/product/?token=demo&url=https%3A%2F%2Fdogbrush-mock.com'; $this->assertEquals($expectedUrl, $url); } public function testBuildUrlOneCustomField() { $url = $this ->apiWithMock ->setOfferPriceDetails(true) ->buildUrl(); $expectedUrl = 'http://api.diffbot.com/v3/product/?token=demo&url=https%3A%2F%2Fdogbrush-mock.com&fields=offerPriceDetails'; $this->assertEquals($expectedUrl, $url); } public function testBuildUrlTwoCustomFields() { $url = $this ->apiWithMock ->setOfferPriceDetails(true) ->setSku(true) ->buildUrl(); $expectedUrl = 'http://api.diffbot.com/v3/product/?token=demo&url=https%3A%2F%2Fdogbrush-mock.com&fields=sku,offerPriceDetails'; $this->assertEquals($expectedUrl, $url); } 

Вот метод в полном объеме:

 protected function buildUrl() { $url = rtrim($this->apiUrl, '/') . '/'; // Add Token $url .= '?token=' . $this->diffbot->getToken(); // Add URL $url .= '&url='.urlencode($this->url); // Add Custom Fields $fields = static::getOptionalFields(); $fieldString = ''; foreach ($fields as $field) { $methodName = 'get' . ucfirst($field); $fieldString .= ($this->$methodName()) ? $field . ',' : ''; } $fieldString = trim($fieldString, ','); if ($fieldString != '') { $url .= '&fields=' . $fieldString; } return $url; } 

Если мы запустим тест сейчас, все должно пройти:

Вывод

В этой части мы сделали еще несколько TDD, двигаясь в направлении завершения. Из-за обширного содержания, которое может быть написано на тему тестирования, я решил сократить историю здесь. Мы обязательно должны проверить, работают ли настраиваемые поля, созданы ли хорошие URL-адреса и т. Д. И т. Д. — области, которые вы можете проверить, практически бесконечны, но это излишне расширит учебник за пределы внимания. Вы всегда можете увидеть полностью готовый результат на Github . Однако, если вы заинтересованы в чтении учебника по остальной логике, пожалуйста, дайте мне знать, и я сделаю все возможное, чтобы продолжить объяснение, по частям.

Не забудьте внедрить другие объекты и тесты как «домашнее задание»! Это кажется пустой тратой времени, но со временем вы полюбите тесты безопасности, которые я вам обещаю, в конце концов! Аналогичным образом, если у вас есть какие-либо идеи по улучшению моих подходов, я всегда открыт для изучения чего-то нового и с удовольствием рассмотрю запросы на выгрузку и конструктивную обратную связь.

В следующей и последней части мы завершим и развернем наш пакет на Packagist.org, чтобы каждый мог установить его по своему желанию через Composer.