Skip to content

Instantly share code, notes, and snippets.

@mRoca
Last active December 29, 2015 15:10
Show Gist options
  • Select an option

  • Save mRoca/ef08a1004e04ca864ed4 to your computer and use it in GitHub Desktop.

Select an option

Save mRoca/ef08a1004e04ca864ed4 to your computer and use it in GitHub Desktop.
API mocks

See https://github.com/mRoca/MrocaRequestLogBundle

The theory :

  • A developer runs behat tests on the api project
  • Behat tests generate api mock files (@Then save the response as mock file)
  • Api mock files are saved in a specific path (%behat_mock_responses_dir%)
  • Another git repository contains all mock files, updated from the api directory with a script
  • In the front, a bash script and a private ssh key in the repo allow to get the mocks repository before tests
  • In the front, a fake Guzzle Client is created : for each Guzzle call, if a mock file matching the request exists, its content is returned, else a 404 error us thrown

The problem :

  • All used mocks in the front tests must be generated in api tests
<?php
namespace AppBundle\Tests\Behat\Context;
use AppBundle\Tests\Behat\Context\Traits\ClientContextTrait;
use AppBundle\Tests\Behat\Context\Traits\KernelContextTrait;
use Behat\MinkExtension\Context\RawMinkContext;
use Behat\Symfony2Extension\Context\KernelAwareContext;
use Sanpi\Behatch\Json\Json;
use Symfony\Component\BrowserKit\Request;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation\JsonResponse;
class ApiMockContext extends RawMinkContext implements KernelAwareContext
{
use ClientContextTrait;
use KernelContextTrait;
const MOCKS_DIR_PARAM_NAME = 'behat_mock_responses_dir';
/**
* Saves the last response as json file, if the `behat_mock_responses_dir` parameter is set.
*
* @Then save the response as mock file
*
* @param bool $overrideExistingFile
*
* @throws \Behat\Mink\Exception\UnsupportedDriverActionException
*/
public function saveResponseAsMock($overrideExistingFile = true)
{
// Skip the test if no dump dir is set
if (null === $mockDir = $this->getResponseMocksDir()) {
return;
}
$filename = $this->getFilenameByRequest($this->getClient()->getHistory()->current(), $mockDir, true);
if (!$overrideExistingFile && file_exists($filename)) {
return;
}
/** @var JsonResponse $response */
$response = $this->getClient()->getResponse();
/** @var \Symfony\Component\HttpFoundation\Request $request */
$request = $this->getClient()->getRequest();
$responseJsonContent = new Json($response->getContent());
$requestJsonContent = new Json($request->getContent());
$dumpFileContent = [
'response' => [
'statusCode' => $response->getStatusCode(),
'content' => $responseJsonContent->getContent(),
],
'request' => [
'uri' => $request->getBaseUrl().$request->getPathInfo().($request->getQueryString() ? '?'.$request->getQueryString() : ''),
'method' => $request->getMethod(),
'content' => $requestJsonContent->getContent(),
],
];
$fs = new Filesystem();
$fs->dumpFile($filename, json_encode($dumpFileContent, JSON_PRETTY_PRINT + JSON_UNESCAPED_UNICODE + JSON_UNESCAPED_SLASHES));
}
/**
* Saves the last response as json file, if not exists, and if the `behat_mock_responses_dir` parameter is set.
*
* @Then save the response as mock file if doesn't exist
*/
public function saveResponseAsMockIfNotExists()
{
$this->saveResponseAsMock(false);
}
/**
* @return string|null
*/
private function getResponseMocksDir()
{
if (!$this->kernel->getContainer()->hasParameter(self::MOCKS_DIR_PARAM_NAME)) {
return;
}
return $this->kernel->getContainer()->getParameter(self::MOCKS_DIR_PARAM_NAME);
}
/**
* Creates a filename string from a request object, with the following schema :
* `uri/segments?query=string&others#METHOD-md5Content-md5JsonParams.json`.
*
* @param Request $request
* @param string $parentDir
* @param bool $useFirstUriSegmentAsDir
*
* @return string
*/
private function getFilenameByRequest(Request $request, $parentDir = null, $useFirstUriSegmentAsDir = true)
{
$requestUri = trim(parse_url($request->getUri(), PHP_URL_PATH), '/');
$requestQueryString = parse_url($request->getUri(), PHP_URL_QUERY);
$requestMethod = $request->getMethod();
$requestContent = $request->getContent();
$requestParameters = $request->getParameters();
$filename = $requestUri;
if ($useFirstUriSegmentAsDir && 1 === count(explode('/', $filename))) {
$filename .= '/';
}
if (null !== $requestQueryString) {
$qs = self::sortArray(explode('&', $requestQueryString));
$requestQueryString = implode('&', $qs);
$filename .= '?'.$requestQueryString;
}
$filename .= '#'.$requestMethod;
if ($requestContent) {
try {
$content = (new Json($requestContent))->getContent();
$content = json_encode(self::sortArray($content));
} catch (\Exception $e) {
$content = $requestContent;
}
$filename .= '-'.substr(md5($content), 0, 5);
}
if ($requestParameters) {
$filename .= '-'.substr(md5(json_encode(self::sortArray($requestParameters))), 0, 5);
}
$filename .= '.json';
if ($parentDir) {
return rtrim($parentDir, DIRECTORY_SEPARATOR).'/'.$filename;
}
return $filename;
}
private static function sortArray($data)
{
$data = (array) $data;
if (array_keys($data) === range(0, count($data) - 1)) {
sort($data);
} else {
ksort($data);
}
return $data;
}
}
Scenario: Get a collection
Given I am authenticated as "user"
When I send a "GET" request to "/currencies"
Then the response status code should be 200
And the response should be valid as JSON
And the JSON should be an hydra paged collection
Then save the response as mock file
<?php
namespace AppBundle\Tests\Api;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Event\Emitter;
use GuzzleHttp\Event\EmitterInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Message\MessageFactory;
use GuzzleHttp\Message\Request;
use GuzzleHttp\Message\RequestInterface;
use GuzzleHttp\Message\Response;
use GuzzleHttp\Stream\Stream;
use Sanpi\Behatch\Json\Json;
use AppBundle\Hydra\HydraClient;
use AppBundle\Hydra\HydraFactory;
class FakeGuzzleClient implements ClientInterface
{
/**
* @var EmitterInterface
*/
private $emitter;
/**
* {@inheritdoc}
*/
public function createRequest($method, $url = null, array $options = [])
{
throw new \RuntimeException(sprintf('FakeGuzzleClient::createRequest is not implemented !'));
}
/**
* {@inheritdoc}
*/
public function get($url = null, $options = [])
{
return $this->getResponse('GET', $url, $options);
}
/**
* {@inheritdoc}
*/
public function head($url = null, array $options = [])
{
return $this->getResponse('HEAD', $url, $options);
}
/**
* {@inheritdoc}
*/
public function delete($url = null, array $options = [])
{
return $this->getResponse('DELETE', $url, $options);
}
/**
* {@inheritdoc}
*/
public function put($url = null, array $options = [])
{
return $this->getResponse('PUT', $url, $options);
}
/**
* {@inheritdoc}
*/
public function patch($url = null, array $options = [])
{
return $this->getResponse('PATCH', $url, $options);
}
/**
* {@inheritdoc}
*/
public function post($url = null, array $options = [])
{
return $this->getResponse('POST', $url, $options);
}
/**
* {@inheritdoc}
*/
public function options($url = null, array $options = [])
{
return $this->getResponse('OPTIONS', $url, $options);
}
/**
* {@inheritdoc}
*/
public function send(RequestInterface $request)
{
throw new \RuntimeException(sprintf('FakeGuzzleClient::send is not implemented !'));
}
/**
* {@inheritdoc}
*/
public function getDefaultOption($keyOrPath = null)
{
throw new \RuntimeException(sprintf('FakeGuzzleClient::getDefaultOption is not implemented !'));
}
/**
* {@inheritdoc}
*/
public function setDefaultOption($keyOrPath, $value)
{
throw new \RuntimeException(sprintf('FakeGuzzleClient::setDefaultOption is not implemented !'));
}
/**
* {@inheritdoc}
*/
public function getBaseUrl()
{
throw new \RuntimeException(sprintf('FakeGuzzleClient::getBaseUrl is not implemented !'));
}
/**
* {@inheritdoc}
*/
public function getEmitter()
{
if (!$this->emitter) {
$this->emitter = new Emitter();
}
return $this->emitter;
}
/**
* This method will find the file corresponding to the URL, and return a response based on it.
*
* @param $method
* @param $url
* @param array $options
*
* @return Response
*/
private function getResponse($method, $url, array $options = [])
{
$parentDir = realpath(dirname(__FILE__).'/../../../../tmp/thom-api-mock/mocks/');
list($url, $options) = $this->cleanData($url, $options);
$request = (new MessageFactory())->createRequest($method, $url, $options);
$path = $this->getFilenameByRequest($request, $parentDir);
if (!file_exists($path)) {
throw new \RuntimeException(sprintf('Mock not found: %s', $path));
}
$data = json_decode(file_get_contents($path));
$response = new Response($data->response->statusCode, [], Stream::factory(json_encode($data->response->content)));
if ($data->response->statusCode >= 400) {
$e = new RequestException('', $request, $response);
HydraClient::generateException($e);
}
return HydraFactory::generate($response);
}
private function cleanData($url, array $options)
{
if ('/get_token' === $url && isset($options['json']['refresh_token_key'])) {
$options['json']['refresh_token_key'] = 'foobar';
}
$baseUri = parse_url($url, PHP_URL_PATH);
$query = parse_url($url, PHP_URL_QUERY);
$url = '';
if ($query) {
$query = explode('&', $query);
sort($query);
$url = '?'.implode('&', $query);
}
$url = $baseUri.$url;
return [
$url,
$options,
];
}
/**
* Creates a filename string from a request object, with the following schema :
* `uri/segments?query=string&others#METHOD-md5Content-md5JsonParams.json`.
*
* @param Request $request
* @param string $parentDir
* @param bool $useFirstUriSegmentAsDir
*
* @return string
*/
private function getFilenameByRequest(Request $request, $parentDir = null, $useFirstUriSegmentAsDir = true)
{
$requestUri = trim(parse_url($request->getUrl(), PHP_URL_PATH), '/');
$requestQueryString = parse_url($request->getUrl(), PHP_URL_QUERY);
$requestMethod = $request->getMethod();
$content = json_decode((string) $request->getBody(), true);
if ($content) {
ksort($content);
}
$requestContent = $content
? json_encode($content)
: $requestContent = '';
$filename = $requestUri;
if ($useFirstUriSegmentAsDir && 1 === count(explode('/', $filename))) {
$filename .= '/';
}
if (null !== $requestQueryString) {
$filename .= '?'.$requestQueryString;
}
$filename .= '#'.$requestMethod;
if ($requestContent) {
try {
$content = (new Json($requestContent))->encode(false);
} catch (\Exception $e) {
$content = $requestContent;
}
$filename .= '-'.substr(md5($content), 0, 5);
}
$filename .= '.json';
if ($parentDir) {
return rtrim($parentDir, DIRECTORY_SEPARATOR).'/'.$filename;
}
return $filename;
}
}
{
"response": {
"statusCode": 200,
"content": {
"@type": "PagedCollection",
"metadata": {
"totalItems": 7,
"totalPages": 2,
"itemsPerPage": 5,
"firstPage": "itemsPerPage=5&page=1",
"curentPage": "itemsPerPage=5&page=1",
"lastPage": "itemsPerPage=5&page=2",
"nextPage": "itemsPerPage=5&page=2"
},
"member": [
{
"@id": "DEM",
"@type": "App\/Currency",
"code": "DEM",
"label": "DM",
"rate": "1.95583"
},
{
"@id": "ESP",
"@type": "App\/Currency",
"code": "ESP",
"label": "Pesetas",
"rate": "166.386"
},
{
"@id": "EUR",
"@type": "App\/Currency",
"code": "EUR",
"label": "Euro",
"rate": "1"
},
{
"@id": "FRF",
"@type": "App\/Currency",
"code": "FRF",
"label": "Francs",
"rate": "6.55957"
},
{
"@id": "HKD",
"@type": "App\/Currency",
"code": "HKD",
"label": "HK Dollar",
"rate": "10"
}
]
}
},
"request": {
"uri": "\/currencies",
"method": "GET",
"content": null
}
}
{
"response": {
"statusCode": 201,
"content": {
"@id": "ARS",
"@type": "App\/Currency",
"code": "ARS",
"label": "Peso AR.",
"rate": "10.7851"
}
},
"request": {
"uri": "\/currencies",
"method": "POST",
"content": {
"code": "ARS",
"label": "Peso AR.",
"rate": "10.7851"
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment