Navigation Menu

Skip to content

Commit

Permalink
feature #30604 [HttpClient] add MockHttpClient (nicolas-grekas)
Browse files Browse the repository at this point in the history
This PR was merged into the 4.3-dev branch.

Discussion
----------

[HttpClient] add MockHttpClient

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | -
| License       | MIT
| Doc PR        | -

This PR introduces `MockHttpClient` and `MockResponse`, to be used for testing classes that need an HTTP client without making actual HTTP requests.

`MockHttpClient` is configured via its constructor: you provide it either with an iterable or a callable, and these will be used to provide responses as the consumer requests them.

Example:
```php
$responses = [
    new MockResponse($body1, $info1),
    new MockResponse($body2, $info2),
];

$client = new MockHttpClient($responses);
$response1 = $client->request(...); // created from $responses[0]
$response2 = $client->request(...); // created from $responses[1]
```

Or alternatively:
```php
$callback = function ($method, $url, $options) {
    return new MockResponse(...);
};

$client = new MockHttpClient($callback);
$response = $client->request(...); // calls $callback internal
```

The responses provided to the client don't have to be instances of `MockResponse` - any `ResponseInterface` works (e.g. `$this->getMockBuilder(ResponseInterface::class)->getMock()`).

Using `MockResponse` allows simulating chunked responses and timeouts:
```php
$body = function () {
    yield 'hello';
    yield ''; // the empty string is turned into a timeout so that they are easy to test
    yield 'world';
};
$mockResponse = new Mockresponse($body);
```

Last but not least, the implementation simulates the full lifecycle of a properly behaving `HttpClientInterface` contracts implementation: error handling, progress function, etc. This is "proved" by `MockHttpClientTest`, who implements and passes the reference test suite in `HttpClientTestCase`.

Commits
-------

8fd7584 [HttpClient] add MockHttpClient
  • Loading branch information
fabpot committed Mar 19, 2019
2 parents 72fa2b3 + 8fd7584 commit d5d1b50
Show file tree
Hide file tree
Showing 10 changed files with 549 additions and 53 deletions.
17 changes: 10 additions & 7 deletions src/Symfony/Component/HttpClient/Chunk/ErrorChunk.php
Expand Up @@ -21,19 +21,14 @@
*/
class ErrorChunk implements ChunkInterface
{
protected $didThrow;

private $didThrow = false;
private $offset;
private $errorMessage;
private $error;

/**
* @param bool &$didThrow Allows monitoring when the $error has been thrown or not
*/
public function __construct(bool &$didThrow, int $offset, \Throwable $error = null)
public function __construct(int $offset, \Throwable $error = null)
{
$didThrow = false;
$this->didThrow = &$didThrow;
$this->offset = $offset;
$this->error = $error;
$this->errorMessage = null !== $error ? $error->getMessage() : 'Reading from the response stream reached the inactivity timeout.';
Expand Down Expand Up @@ -96,6 +91,14 @@ public function getError(): ?string
return $this->errorMessage;
}

/**
* @return bool Whether the wrapped error has been thrown or not
*/
public function didThrow(): bool
{
return $this->didThrow;
}

public function __destruct()
{
if (!$this->didThrow) {
Expand Down
4 changes: 3 additions & 1 deletion src/Symfony/Component/HttpClient/HttpClientTrait.php
Expand Up @@ -117,7 +117,7 @@ private static function prepareRequest(?string $method, ?string $url, array $opt

// Finalize normalization of options
$options['headers'] = $headers;
$options['http_version'] = (string) ($options['http_version'] ?? '');
$options['http_version'] = (string) ($options['http_version'] ?? '') ?: null;
$options['timeout'] = (float) ($options['timeout'] ?? ini_get('default_socket_timeout'));

return [$url, $options];
Expand All @@ -128,6 +128,8 @@ private static function prepareRequest(?string $method, ?string $url, array $opt
*/
private static function mergeDefaultOptions(array $options, array $defaultOptions, bool $allowExtraOptions = false): array
{
unset($options['raw_headers'], $defaultOptions['raw_headers']);

$options['headers'] = self::normalizeHeaders($options['headers'] ?? []);

if ($defaultOptions['headers'] ?? false) {
Expand Down
85 changes: 85 additions & 0 deletions src/Symfony/Component/HttpClient/MockHttpClient.php
@@ -0,0 +1,85 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\HttpClient;

use Symfony\Component\HttpClient\Exception\TransportException;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpClient\Response\ResponseStream;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Symfony\Contracts\HttpClient\ResponseStreamInterface;

/**
* A test-friendly HttpClient that doesn't make actual HTTP requests.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
class MockHttpClient implements HttpClientInterface
{
use HttpClientTrait;

private $responseFactory;
private $baseUri;

/**
* @param callable|ResponseInterface|ResponseInterface[]|iterable $responseFactory
*/
public function __construct($responseFactory, string $baseUri = null)
{
if ($responseFactory instanceof ResponseInterface) {
$responseFactory = [$responseFactory];
}

if (!\is_callable($responseFactory) && !$responseFactory instanceof \Iterator) {
$responseFactory = (function () use ($responseFactory) {
yield from $responseFactory;
})();
}

$this->responseFactory = $responseFactory;
$this->baseUri = $baseUri;
}

/**
* {@inheritdoc}
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
[$url, $options] = $this->prepareRequest($method, $url, $options, ['base_uri' => $this->baseUri], true);
$url = implode('', $url);

if (\is_callable($this->responseFactory)) {
$response = ($this->responseFactory)($method, $url, $options);
} elseif (!$this->responseFactory->valid()) {
throw new TransportException('The response factory iterator passed to MockHttpClient is empty.');
} else {
$response = $this->responseFactory->current();
$this->responseFactory->next();
}

return MockResponse::fromRequest($method, $url, $options, $response);
}

/**
* {@inheritdoc}
*/
public function stream($responses, float $timeout = null): ResponseStreamInterface
{
if ($responses instanceof ResponseInterface) {
$responses = [$responses];
} elseif (!\is_iterable($responses)) {
throw new \TypeError(sprintf('%s() expects parameter 1 to be an iterable of MockResponse objects, %s given.', __METHOD__, \is_object($responses) ? \get_class($responses) : \gettype($responses)));
}

return new ResponseStream(MockResponse::stream($responses, $timeout));
}
}

0 comments on commit d5d1b50

Please sign in to comment.