├── .github └── workflows │ └── run-tests.yml ├── CHANGELOG.md ├── Hybrid-logo.svg ├── LICENSE.md ├── README.md ├── composer.json ├── docs ├── README.md ├── assertions.md ├── changelog.md ├── expectations.md ├── extending.md ├── getting-started.md ├── helpers.md ├── mocking-responses.md ├── navigation.md ├── troubleshooting.md └── why.md └── src ├── Expectation.php ├── Filters ├── WithBody.php ├── WithEndpoint.php ├── WithFile.php ├── WithForm.php ├── WithHeader.php ├── WithJson.php ├── WithOption.php ├── WithProtocol.php └── WithQuery.php ├── Helpers └── Extension.php ├── Hybrid.php ├── MockHttpClient.php ├── MockQueue.php ├── Traits └── Forms.php ├── UsesHybrid.php └── macros.php /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.3, 8.2, 8.1] 13 | phpunit: [9.6, 10.0, 11.0] 14 | dependency-version: [prefer-stable] 15 | exclude: 16 | - php: 8.1 17 | phpunit: 11.0 18 | 19 | name: P${{ matrix.php }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | coverage: xdebug 30 | 31 | - name: Install dependencies 32 | run: composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 33 | 34 | - name: Proper PHPUnit 35 | run: composer require phpunit/phpunit:^${{ matrix.phpunit }} --update-with-dependencies 36 | 37 | - name: Execute tests 38 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [2.1.0] - 2024-02-19 5 | - Add support back for PHPUnit 9.6 and PHP 8.1. Now includes support for the following combinations: 6 | 7 | | PHP | PHPUnit | 8 | | - |-------------| 9 | | 8.1 | 9.6, 10 | 10 | | 8.2 | 9.6, 10, 11 | 11 | | 8.3 | 9.6, 10, 11 | 12 | 13 | ## [2.0.2] - 2024-02-03 14 | - Add support for PHP 8.3 15 | - Add support for HTTP Client 7 16 | - Add support for PHPUnit 11 17 | - Change macros type hints to support using both Hybrid and Guzzler at once 18 | 19 | ## [2.0] - 2023-08--03 20 | - Adding support for PHPUnit 10 21 | - Updating dependencies 22 | - Dropping support for PHP 8.0, as PHPUnit 10 only supports 8.1 and up 23 | - Adding support for HttpClient 6 24 | 25 | ## [1.1.3] - 2022-12-27 26 | - Add support for PHP 8.2, remove all versions below 8.0 27 | - Updated dependencies 28 | - Corrected code with deprecation warnings 29 | 30 | ## [1.1.2] - 2020-12-04 31 | - Updating dependencies 32 | - Support for PHP 8 33 | 34 | ## [1.1.1] - 2020-03-09 35 | - Update to support PHPUnit 9 36 | - Drop support for PHPUnit below 8.2 37 | - Drop support for PHP 7.1 38 | 39 | ## [1.1.0] - 2020-01-10 40 | - Updating CI to test on 7.4 41 | - This will be the last release supporting PHP 7.1 42 | - Added new methods: withoutQuery, withQueryKey, and withQueryKeys 43 | 44 | ## [1.0.3] - 2019-12-03 45 | - Security dependency update. PR provided by Github helpbot 46 | 47 | ## [1.0.2] - 2019-10-03 48 | - Updated to the latest version of `blastcloud/chassis`. 49 | - Fix for `InvokedRecorder` being removed from PHPUnit 8.4 Type hinting was simply removed from the `expects()` method. Will have to rely on users simply reading the documentation. 50 | 51 | ## [1.0.1] - 2019-09-06 52 | - Fix for capitalization issue on `macros.php` 53 | 54 | ## [1.0.0] - 2019-09-06 55 | - Initial Release 56 | -------------------------------------------------------------------------------- /Hybrid-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Adam Kelso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 | 17 | --- 18 | 19 | > Full Documentation at [hybrid.guzzler.dev](https://hybrid.guzzler.dev) 20 | 21 | Charge up your app or SDK with a testing library specifically for Symfony/HttpClient. 22 | 23 | ## Installation 24 | 25 | ```bash 26 | composer require --dev --prefer-dist blastcloud/hybrid 27 | ``` 28 | 29 | ## Example Usage 30 | 31 | ```php 32 | hybrid->getClient([ 49 | /* Any configs for a client */ 50 | "base_uri" => "https://example.com/api" 51 | ]); 52 | 53 | // You can then inject this client object into your code or IOC container. 54 | $this->classToTest = new ClassToTest($client); 55 | } 56 | 57 | public function testSomethingWithExpectations() 58 | { 59 | $this->hybrid->expects($this->once()) 60 | ->post("/some-url") 61 | ->withHeader("X-Authorization", "some-key") 62 | ->willRespond(new MockResponse("Some body")); 63 | 64 | $this->classToTest->someMethod(); 65 | } 66 | 67 | public function testSomethingWithAssertions() 68 | { 69 | $this->hybrid->queueResponse( 70 | new MockResponse(null, ['http_code' => 204]), 71 | new \Exception("Some message"), 72 | // any needed responses to return from the client. 73 | ); 74 | 75 | $this->classToTest->someMethod(); 76 | // ... Some other number of calls 77 | 78 | $this->hybrid->assertAll(function (Expectation $expect) { 79 | return $expect->withHeader("Authorization", "some-key"); 80 | }); 81 | } 82 | } 83 | ``` 84 | 85 | ## Documentation 86 | 87 | [Full Documentation](https://hybrid.guzzler.dev) 88 | 89 | ## License 90 | 91 | Hybrid is open-source software licensed under the [MIT License](https://opensource.org/licenses/MIT). 92 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blastcloud/hybrid", 3 | "description": "Charge up your app or SDK with a testing library specifically for Symfony/HttpClient", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Adam Kelso", 9 | "email": "kelso.adam@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=8.1", 14 | "phpunit/phpunit": ">=9.6", 15 | "ext-json": "*", 16 | "blastcloud/chassis": "^1.0.6", 17 | "symfony/http-client": ">=6.0" 18 | }, 19 | "autoload": { 20 | "psr-4": { 21 | "BlastCloud\\Hybrid\\": "src" 22 | }, 23 | "files" : [ 24 | "src/macros.php" 25 | ] 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Tests\\": "tests" 30 | } 31 | }, 32 | "require-dev": { 33 | "symfony/mime": "^4.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hybrid | Official Documentation 3 | lang: en-US 4 | footer: MIT Licensed | Copyright © 2019-present Adam Kelso 5 | layout: HomeLayout 6 | --- 7 | 8 | 9 | Charge up your app or SDK with a testing library specifically for Symfony/HttpClient. Hybrid covers the process of setting up a mock handler, recording history of requests, and provides several convenience methods for creating expectations and assertions on that history. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | composer require --dev --prefer-dist blastcloud/hybrid 15 | ``` 16 | 17 | ## Example Usage 18 | 19 | ```php 20 | hybrid->getClient([ 37 | /* Any configs for a client */ 38 | "base_uri" => "https://example.com/api" 39 | ]); 40 | 41 | // You can then inject this client object into your code or IOC container. 42 | $this->classToTest = new ClassToTest($client); 43 | } 44 | 45 | public function testSomethingWithExpectations() 46 | { 47 | $this->hybrid->expects($this->once()) 48 | ->post("/some-url") 49 | ->withHeader("X-Authorization", "some-key") 50 | ->willRespond(new MockResponse("Some body")); 51 | 52 | $this->classToTest->someMethod(); 53 | } 54 | 55 | public function testSomethingWithAssertions() 56 | { 57 | $this->hybrid->queueResponse( 58 | new MockResponse(null, ['http_code' => 204]), 59 | new \Exception("Some message"), 60 | // any needed responses to return from the client. 61 | ); 62 | 63 | $this->classToTest->someMethod(); 64 | // ... Some other number of calls 65 | 66 | $this->hybrid->assertAll(function (Expectation $expect) { 67 | return $expect->withHeader("Authorization", "some-key"); 68 | }); 69 | } 70 | } 71 | ``` 72 | 73 | --- 74 | 75 |

MIT Licensed | Copyright © 2019-present Adam Kelso

76 | -------------------------------------------------------------------------------- /docs/assertions.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Assertions | Hybrid 4 | --- 5 | 6 | # Assertions 7 | 8 | While [Expectations](/expectations/) work great for cases where you don’t care about the order of requests to your client, you may find times where you want to verify either the order of requests in your client’s history, or you may want to make assertions about the entirety of its history. Hybrid provides several convenience assertions for exactly this scenario. 9 | 10 | Assertions are also intended to be made after the call to your code under test while Expectations are laid out before. 11 | 12 | ## Available Methods 13 | 14 |
15 |

16 | assertNoHistory
17 | assertHistoryCount
18 | assertFirst
19 | assertNotFirst
20 |

21 |

22 | assertLast
23 | assertNotLast
24 | assertIndexes
25 |

26 |

27 | assertNotIndexes
28 | assertAll
29 | assertNone
30 |

31 |
32 | 33 | ### assertNoHistory($message = null) 34 | 35 | To assert that your code did not make any requests at all, you can use the `assertNoHistory()` method, and pass an optional message argument. 36 | 37 | ```php 38 | public function testSomething() 39 | { 40 | // ... 41 | 42 | $this->hybrid->assertNoHistory(); 43 | } 44 | ``` 45 | 46 | ### assertHistoryCount(int $count, $message = null) 47 | 48 | This method can assert that the client received exactly the specified number of requests, regardless of what the requests were. 49 | 50 | ```php 51 | public function testSomething() 52 | { 53 | // ... 54 | 55 | $this->hybrid->assertHistoryCount(4); 56 | } 57 | ``` 58 | 59 | ### assertFirst(Closure $expect, $message = null) 60 | 61 | Assertions can be made specifically against the first item in the client history. The first argument should be a closure that receives an `Expectation` and an optional error message can be passed as the second argument. 62 | 63 | ```php 64 | use BlastCloud\Hybrid\Expectation; 65 | 66 | // ... 67 | 68 | $this->hybrid->assertFirst(function (Expectation $e) { 69 | return $e->post("/a-url") 70 | ->withProtocol(1.1) 71 | ->withHeader("XSRF", "some-string"); 72 | }); 73 | ``` 74 | 75 | ### assertNotFirst(Closure $expect, $message = null) 76 | 77 | Assert that the first request in history does not meet the given expectation. The first argument should be a closure that receives an `Expectation` and an optional error message can be passed as the second argument. 78 | 79 | ```php 80 | $this->hybrid->assertNotFirst(function ($expect) { 81 | return $expect->options('/some-url'); 82 | }); 83 | ``` 84 | 85 | ### assertLast(Closure $expect, $message = null) 86 | 87 | Assertions can be made specifically against the last item in the client history. The first argument should be a closure that receives an `Expectation` and an optional error message can be passed as the second argument. 88 | 89 | ```php 90 | $this->hybrid->assertLast(function ($expect) { 91 | return $expect->get("/some-getter"); 92 | }); 93 | ``` 94 | 95 | ### assertNotLast(Closure $expect, $message = null) 96 | 97 | Assert that the last request in history does not meet the given expectation. The first argument should be a closure that receives an `Expectation` and an optional error message can be passed as the second argument. 98 | 99 | ```php 100 | $this->hybrid->assertNotLast(function ($expect) { 101 | return $expect->post('/some-url'); 102 | }); 103 | ``` 104 | 105 | ### assertIndexes(array $indexes, Closure $expect, $message = null) 106 | 107 | Assertions can be made against any specific index or set of indexes in the client history. The first argument should be an array of integers that correspond to the indexes of history items. The second argument should be a closure that receives an `Expectation` and an optional error message can be passed as the third argument. 108 | 109 | ```php 110 | $this->hybrid->assertIndexes([2, 3, 6], function ($expect) { 111 | return $expect->withBody("some body string"); 112 | }); 113 | ``` 114 | 115 | ### assertNotIndexes(array $indexes, Closure $expect, $message = null) 116 | 117 | Assertions can be made in the negative against any specific index or set of indexes in the client history. The first argument should be an array of integers that correspond to the indexes of history items. The second argument should be a closure that receives an `Expectation` and an optional error message can be passed as the third argument. 118 | 119 | ```php 120 | $this->hybrid->assertNotIndexes([2, 3, 6], function ($expect) { 121 | return $expect->delete('/some-url') 122 | ->withJson(['id-to-delete' => 42]); 123 | }); 124 | ``` 125 | 126 | ### assertAll(Closure $expect, $message = null) 127 | 128 | This method can be used to assert that every request in the client’s history meets the expectation. For example, you may want to ensure that every request uses a certain authentication header or API token. The first argument should be a closure that receives an `Expectation` and an optional error message as the second argument. 129 | 130 | ```php 131 | $this->hybrid->assertAll(function ($expect) use ($token) { 132 | return $expect->withHeader("Authorization", $token); 133 | 134 | // Or maybe 135 | 136 | return $expect->withQuery(['api-key' => $token]); 137 | }); 138 | ``` 139 | 140 | ### assertNone(Closure $expect, $message = null) 141 | 142 | This method can be used to assert that no request, given that any have been made, meet the expectation. 143 | 144 | ```php 145 | $this->hybrid->assertNone(function ($expect) { 146 | return $expect->delete("/some-dangerous-thing-to-delete"); 147 | }); 148 | ``` 149 | 150 | > You may notice that `assertNone()` has the same effect as `expects($this->never())`. The only real difference is preference. -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [2.0] - 2023-08--03 5 | - Adding support for PHPUnit 10 6 | - Updating dependencies 7 | - Dropping support for PHP 8.0, as PHPUnit 10 only supports 8.1 and up 8 | - Adding support for HttpClient 6 9 | 10 | ## [1.1.3] - 2022-12-27 11 | - Add support for PHP 8.2, remove all versions below 8.0 12 | - Updated dependencies 13 | - Corrected code with deprecation warnings 14 | 15 | ## [1.1.2] - 2020-12-04 16 | - Updating dependencies 17 | - Support for PHP 8 18 | 19 | ## [1.1.1] - 2020-03-09 20 | - Update to support PHPUnit 9 21 | - Drop support for PHPUnit below 8.2 22 | - Drop support for PHP 7.1 23 | 24 | ## [1.1.0] - 2020-01-10 25 | - Updating CI to test on 7.4 26 | - This will be the last release supporting PHP 7.1 27 | - Added new methods: withoutQuery, withQueryKey, and withQueryKeys 28 | 29 | ## [1.0.3] - 2019-12-03 30 | - Security dependency update. PR provided by Github helpbot 31 | 32 | ## [1.0.2] - 2019-10-03 33 | - Updated to the latest version of `blastcloud/chassis`. 34 | - Fix for `InvokedRecorder` being removed from PHPUnit 8.4 Type hinting was simply removed from the `expects()` method. Will have to rely on users simply reading the documentation. 35 | 36 | ## [1.0.1] - 2019-09-06 37 | - Fix for capitalization issue on `macros.php` 38 | 39 | ## [1.0.0] - 2019-09-06 40 | - Initial Release 41 | -------------------------------------------------------------------------------- /docs/expectations.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Expectations | Hybrid 4 | --- 5 | 6 | # Expectations 7 | 8 | Expectations are the main way for you to define what you want Hybrid to search for through your HttpClient history. They are used in two separate ways: 9 | 10 | - To define the number of times you expect a match to be made before you test your code. 11 | - To assert what Hybrid should search for in your client's history after your code has run. 12 | 13 | ### expects(InvokedRecorder $times, $message = null) 14 | 15 | To mimic the chainable syntax of PHPUnit mock objects, you can create expectations with the `expects()` method and PHPUnit’s own **InvokedRecorders**. These are methods like `$this->once()`, `$this->lessThan($int)`, `$this->never()`, and so on. You may also pass an optional message to be used on failures as the second argument. 16 | 17 | ```php 18 | public function testExample() 19 | { 20 | $expectation = $this->hybrid->expects($this->once()); 21 | } 22 | ``` 23 | 24 | > All methods on expectations are chainable and can lead directly into the next method. `$expectation->oneMethod()->anotherMethod()->stillAnother();` 25 | 26 | ## Available Methods 27 | 28 |
29 |

30 | withEndpoint, verbs
31 | withBody
32 | withCallback
33 | withFile
34 | withFiles
35 | withForm
36 |

37 |

38 | withFormField
39 | withHeader
40 | withHeaders
41 | withJson
42 | withOption
43 | withOptions
44 |

45 |

46 | withProtocol
47 | withQuery
48 | withQueryKey
49 | withQueryKeys
50 | withoutQuery
51 |

52 |
53 | 54 | ### withEndpoint(string $uri, string $verb), {verb}(string $uri) 55 | 56 | To specify the endpoint and method used for an expectation, use the `endpoint()` method or any of the convenience endpoint verb methods. 57 | 58 | ```php 59 | $this->hybrid->expects($this->once()) 60 | ->withEndpoint("/some-url", "GET"); 61 | ``` 62 | 63 | The following convenience verb methods are available to shorten your code. `get`, `post`, `patch`, `put`, `delete`, `options`. 64 | 65 | ```php 66 | $this->hybrid->expects($this->any()) 67 | ->get("/a-url-for-getting"); 68 | ``` 69 | 70 | ### withBody(string $body, bool $exclusive = false) 71 | 72 | You can expect a certain body on a request by passing a `$body` string to the `withBody()` method. 73 | 74 | ```php 75 | $this->hybrid->expects($this->once()) 76 | ->withBody("some body string"); 77 | ``` 78 | 79 | By default, this method simply checks that the specified body exists somewhere in the request body, but more text may exist. By passing a boolean `true` as the second argument, the method will instead make a strict comparison. 80 | 81 | ### withCallback(Closure $callback, string $message = null) 82 | 83 | You can pass a custom, anonymous method to do ad hoc, on-the-fly, determining if a history item fits your conditions. Your closure should expect a history array, and return `true` or `false` on whether or not the history item passes your test. A history item is an array with the following structure: 84 | 85 | ```php 86 | // Hybrid history item structure 87 | [ 88 | "request" => array, 89 | "response" => Symfony\Component\HttpClient\Response\MockResponse object, 90 | "options" => array, 91 | "error" => null|string 92 | ] 93 | ``` 94 | 95 | ```php 96 | $this->hybrid->expects($this->once()) 97 | ->withCallback(function ($history) { 98 | return isset($history['request']['request_headers']['some-header']); 99 | }); 100 | ``` 101 | 102 | ::: tip 103 | By default, the error message for a callback is simply "Custom callback: \Closure". It's recommended you pass your own message as the second argument to make a more descriptive error about what exactly your closure was testing. 104 | ::: 105 | 106 | ### withFile(string $fieldName, File $file) 107 | 108 | You can make a set of expectations about a specific file that is included in a request. To do so, specify the field that the file should be under, and include an instance of the `BlastCloud\Chassis\Helpers\File` class. The `File` class allows you to specify the following attributes of a file that is uploaded via a multipart form: 109 | 110 | | Attribute | Description | 111 | |-----------|-------------| 112 | | `contents` | The raw data of a given file. | 113 | | `contentType` | The `mime` type of the file. In HTTP requests, this becomes the `Content-Type` attribute. | 114 | | `filename` | If you choose to name the file something other than its actual file name. | 115 | | `headers` | Multipart forms allow headers on individual [Content-Dispositions](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition). The same checks as `withHeaders` are used here. | 116 | 117 | #### Example 118 | 119 | There are several ways you can build out the attributes on a `File` object. 120 | 121 | ```php 122 | use BlastCloud\Hybrid\Helpers\File; 123 | 124 | // Specify attributes on instantiation. 125 | $file = new File($contents = null, string $filename = null, string $contentType = null, array $headers = null); 126 | 127 | // Pass an associative array to a factory. 128 | $file = File::create([ 129 | 'filename' => 'avatar.jpg', 130 | 'contentType' => 'image/jpeg', 131 | 'contents' => fopen('/path/to/file.jpg', 'r') 132 | ]); 133 | 134 | // Set each attribute individually. 135 | $file = new File(); 136 | $file->contents = fopen(__DIR__ . '/path/to/file.jpg', 'r'); 137 | $file->filename = 'avatar.jpg'; 138 | 139 | $this->hybrid->expects($this->once()) 140 | ->withFile('avatar', $file); 141 | ``` 142 | 143 | The `File` class can accept two different ways to specify `contents`: 144 | 145 | 1. The string contents are given directly. 146 | 2. A resource, such as `fopen()`, is given. 147 | 148 | ::: tip Be Aware 149 | Because the file given resolves down to an in-memory comparison, it's a good idea to only use reasonably small files during testing. 150 | ::: 151 | 152 | ### withFiles(array $files, bool $exclusive = false) 153 | 154 | As a shorthand for passing multiple `withFile()` calls, you can pass an associative array of field names with `File` instances as the values. 155 | 156 | ```php 157 | $this->hybrid->expects($this->once()) 158 | ->withFiles([ 159 | 'first' => File::create(['contents' => fopen('/path/to/file.png', 'r')]), 160 | 'second' => File::create(['contents' => 'some text that would be in the second file']), 161 | // ... 162 | ]); 163 | ``` 164 | 165 | By default, this method simply checks that the specified files exist somewhere in the request. By passing a boolean `true` as the second argument, the method will instead cause a failure if additional files are found in the request. 166 | 167 | ### withForm(array $formFields, bool $exclusive = false) 168 | 169 | You can expect a specific set of form fields in the body of a post. This method works with both URL encoded and multipart forms. 170 | 171 | ```php 172 | $this->hybrid->expects($this->once()) 173 | ->withForm([ 174 | 'first-name' => 'John', 175 | 'last-name' => 'Snow' 176 | ]); 177 | ``` 178 | 179 | By default, this method simply checks that all specified fields and values exist in the request body, but more may exist. By passing a boolean `true` as the second argument, the method will instead make a strict comparison and fail if additional fields are found. 180 | 181 | ### withFormField(string $key, $value) 182 | 183 | You can expect a specific form field in the body of a post. This method works with both URL encoded and multipart forms. 184 | 185 | ```php 186 | $this->hybrid->expects($this->once()) 187 | ->withFormField('first-name', 'John') 188 | ->withFormField('house-name', 'Snow'); 189 | ``` 190 | 191 | ### withHeader(string $key, string|array $value) 192 | 193 | If you would like to expect a certain header to be on a request, you can provide the header and it’s value. 194 | 195 | ```php 196 | $this->hybrid->expects($this->once()) 197 | ->withHeader("Authorization", "some-access-token"); 198 | ``` 199 | 200 | You can chain together multiple calls to `withHeader()` to individually add different headers. Headers can also be an array of values. 201 | 202 | ```php 203 | $this->hybrid->expects($this->once()) 204 | ->withHeader("Accept-Encoding", ["gzip", "deflate"]) 205 | ->withHeader("Accept", "application/json"); 206 | ``` 207 | 208 | ### withHeaders(array $headers) 209 | 210 | As a shorthand for multiple `withHeader()` calls, you can pass an array of header keys and values to `withHeaders()`. 211 | 212 | ```php 213 | $this->hybrid->expects($this->once()) 214 | ->withHeaders([ 215 | "Accept-Encoding" => ["gzip", "deflate"], 216 | "Accept" => "application/json" 217 | ]); 218 | ``` 219 | 220 | ### withJson(array $json, bool $exclusive = false) 221 | 222 | You can expect a certain `JSON` body on a request by passing an array of data to the `withJson()` method. 223 | 224 | ```php 225 | $this->hybrid->expects($this->once()) 226 | ->withJson(['first' => 'value', 'second' => 'another']); 227 | ``` 228 |
229 | 230 | ::: tip Be Aware 231 | This method tests that the passed array exists on the request by first recursively sorting all keys and values in both the request body and the `$json` argument and then making a string comparison. 232 | ::: 233 | 234 | This means the following scenarios occur: 235 | 236 | ```php 237 | // Request body 238 | [ 239 | 'first' => [ 240 | 'a value', 241 | 'another value' 242 | ], 243 | 'second' => 'second value' 244 | ] 245 | 246 | // This expectation will pass. Remember, the request body and the 247 | // argument are both recursively sorted before comparison. 248 | $this->hybrid->expects($this->once()) 249 | ->withJson(['another value', 'a value']); 250 | 251 | // This expectation will fail 252 | $this->hybrid->expects($this->once()) 253 | ->withJson(['first' => ['another value']]); 254 | 255 | // This expectation will pass 256 | $this->hybrid->expects($this->once()) 257 | ->withJson(['second' => 'second value']); 258 | ``` 259 | 260 | By default, this method checks only that the passed array of values exists somewhere in the request body. To make an exclusive comparison so that only the data passed exists on the request body, a boolean `true` can be given as the second argument. 261 | 262 | ### withOption(string $name, string $value) 263 | 264 | You can expect a certain HttpClient option by passing a name and value to this method. 265 | 266 | ```php 267 | $this->hybrid->expects($this->once()) 268 | ->withOption('base_uri', 'http://somewhere.com'); 269 | ``` 270 | 271 | ### withOptions(array $options) 272 | 273 | As a shorthand for multiple `withOption()` calls, you can pass an array of option keys and values to `withOptions()`. 274 | 275 | ```php 276 | $this->hybrid->expects($this->once()) 277 | ->withOptions([ 278 | 'auth_basic' => ['the-username'], 279 | // ... something else 280 | ]); 281 | ``` 282 | 283 | ### withProtocol($protocol) 284 | 285 | You can expect a certain HTTP protocol (1.0, 1.1, 2.0) using the `withProtocol()` method. 286 | 287 | ```php 288 | $this->hybrid->expects($this->once()) 289 | ->withProtocol(2.0); 290 | ``` 291 | 292 | ### withQuery(array $query, $exclusive = false) 293 | 294 | You can expect a set of query parameters to appear in the URL of the request by passing an array of key value pairs to match in the URL. The order of query parameters is not considered. 295 | 296 | ```php 297 | // Example URL: http://example.com/api/v2/customers?from=15&to=25&format=xml 298 | 299 | $this->hybrid->expects($this->once()) 300 | ->withQuery([ 301 | 'to' => 25, 302 | 'from' => 15 303 | ]); 304 | ``` 305 | 306 | By default any additional parameters included in the URL are ignored. To enforce only the URL parameters you specify, a boolean `true` can be passed as the second argument. 307 | 308 | ```php 309 | // Example URL: http://example.com/api/v2/customers?from=15&to=25&format=xml 310 | 311 | // With the second argument, the 'format' parameter causes the expectation to fail. 312 | $this->hybrid->expects($this->once()) 313 | ->withQuery([ 314 | 'to' => 25, 315 | 'from' => 15 316 | ], true); 317 | ``` 318 | 319 | ### withQueryKey(string $key) 320 | 321 | You can specify just the key for a query item, if you either don't care about the value or there is none. For example, ElasticSearch sometimes has a query key but no following value. 322 | 323 | ```php 324 | // Example URL: http://some-elasticsearch-url?_delete_by_query 325 | 326 | $this->guzzler->expects($this->once()) 327 | ->withQueryKey('_delete_by_query'); 328 | ``` 329 | 330 | ### withQueryKeys(array $keys) 331 | 332 | You can specify just the keys you want to appear in the query, but not specifically check any values they may have. 333 | 334 | ```php 335 | $this->guzzler->expects($this->once()) 336 | ->withQueryKeys(['first', 'second']); 337 | ``` 338 | 339 | ### withoutQuery() 340 | 341 | If you'd like to ensure no query string is provided in the request at all, this method can be used. 342 | 343 | ```php 344 | $this->guzzler->expects($this->once()) 345 | ->withoutQuery(); 346 | ``` -------------------------------------------------------------------------------- /docs/extending.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Extending | Hybrid 4 | --- 5 | 6 | # Extending Hybrid 7 | 8 | Though Hybrid tries to be as helpful as possible, there may be times when you want to extend the provided capabilities for your own needs. You can do so with custom [filters](#custom-filters) and [macros](#custom-macros). 9 | 10 | ## Custom Filters 11 | 12 | Filters are used by the `Expectation` class to eliminate history items that do not match the defined arguments. Each filter is executed by calling it on an `Expectation` instance. 13 | 14 | ```php 15 | $this->hybrid->expects($this->once()) 16 | ->withFilterMethod($argument, $another); 17 | 18 | $this->hybrid->assertAll(function ($e) use ($argument, $another) { 19 | return $e->withFilterMethod($argument, $another); 20 | }); 21 | ``` 22 | 23 | ### Class Overview 24 | 25 | Though these methods are called directly on an `Expectation` instance, they exist as separate classes that extend the `BlastCloud\Chassis\Filters\Base` class and implement the `BlastCloud\Chassis\Interfaces\With` interface. For our example, let's imagine we are working with a web API where we send user information. However, we want to ensure that the request contains only the information of specific users. 26 | 27 | Our ideal filter API would be the following method where we can pass in an array of user IDs. 28 | 29 | ```php 30 | $expectation->post("/users") 31 | ->withUserIn([1, 6, 42]); 32 | ``` 33 | 34 | To accomplish this, we can first build out a class like the following: 35 | 36 | ```php 37 | userIds = $userIds; 51 | } 52 | 53 | public function __invoke(array $history): array 54 | { 55 | return array_filter($history, function ($item) { 56 | $body = json_decode($item['request']['body'], true); 57 | 58 | return in_array($body['user']['id'], $this->userIds); 59 | }); 60 | } 61 | 62 | public function __toString(): string 63 | { 64 | // A STR_PAD constant is provided so that error messages 65 | // can be formatted similarly across filters. 66 | return str_pad("User ID:", self::STR_PAD) 67 | .json_encode($this->userIds, JSON_PRETTY_PRINT); 68 | } 69 | } 70 | ``` 71 | 72 | Every filter requires the following methods: 73 | 74 | | Method | What it does | 75 | |------|--------------| 76 | | __invoke(): array | Return all history items that pass the filter. | 77 | | __toString(): string | Return a human readable explanation. Used on failure. | 78 | 79 | In addition, you should provide any methods you want to expose to the `Expectation` class, in the case above, the `withUserIn` method. You can provide as many public methods as you like. For example, you could add another method `withUserRoleAddsDirects` to force the filter to also require a list of employees if the user's role is `admin`. Just be aware that all history if filtered through the single `__invoke` method. 80 | 81 | ### Naming Convention 82 | 83 | The following naming convention is followed for filter classes. 84 | 85 | - All classes should be named `With` followed by one word. Notice that the `W` is capitalized. For example, `WithBody`, `WithUser`, or `WithQuery`. 86 | - Each of the exposed methods on your class should follow the naming convention `with` followed by a camel-cased method name, and the first portion should match the class name. For example, `withUserIds`, `withUserRole`, or `withUserStatus`. 87 | - Each exposed method can have as many arguments as you need, and they may be type hinted, if you prefer. 88 | 89 | ### Adding a Namespace 90 | 91 | To use your filters in Hybrid, you must provide the namespace to look through to find your class. There are two ways to do this: 92 | 93 | 1. Inline before it is needed with the static `Expectation::addNamespace` method. 94 | 1. Globally with the PHPUnit extension that Hybrid provides. 95 | 96 | #### Adding a Namespace Inline 97 | 98 | ```php 99 | client = $this->hybrid->getClient(); 113 | Expectation::addNamespace("tests\\HybridFilters"); 114 | } 115 | 116 | public function testSomething() 117 | { 118 | $this->hybrid->expects($this->once()) 119 | ->withUserIn([4, 85, 199]); 120 | } 121 | } 122 | ``` 123 | 124 | ::: tip Be Aware 125 | Any namespaces you add to the `Expectation` class will be checked **before** the provided filters. So, if you name your filter the same as one provided by Hybrid, it will override the Hybrid default. This is exactly what you should do, if you want to override the provided filter. 126 | ::: 127 | 128 | #### Adding a Namepsace Globally 129 | 130 | You can ensure your filters are available throughout all your tests by adding the Hybrid PHPUnit extension to your `phpunit.xml` file. 131 | 132 | ```xml 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | ``` 143 | 144 | There are two parts to adding a namespace globally: 145 | 146 | 1. The `BlastCloud\Hybrid\Helpers\Extension` extension must be added to an `extensions` object. 147 | 1. A `php` object variable with the name `HybridFilterNamespace`. 148 | 149 | ::: tip Be Aware 150 | If you add a namespace via the extension, slashes should not be escaped. 151 | ::: 152 | 153 | ## Custom Macros 154 | 155 | Macros allow you to create convenience methods like ,`get` or `post`, that internally create `Expectation` conditions. For example, the following are the internal implementations of `get` and `post`. 156 | 157 | ```php 158 | Expectation::macro("get", function (Expectation $e, $url) { 159 | return $e->withEndpoint($url, "GET"); 160 | }); 161 | 162 | Expectation::macro("post", function (Expectation $e, $url) { 163 | return $e->withEndpoint($url, "POST"); 164 | }); 165 | ``` 166 | 167 | ::: tip Be Aware 168 | If you create a macro with the same name as one provided by Hybrid, your implementation will override the default. That is exactly what you should do, if overriding is your goal. 169 | ::: 170 | 171 | ### Use Case 172 | 173 | Sometimes you may find yourself using the same set of `Expectation` filters over and over. For example, imagine you are using an API from which you can paginate the results it returns for several GET endpoints. In the following example, you can tell the service for each endpoint how many results you want returned for each page, and what page (or multiple of the number to show) of results. 174 | 175 | ``` 176 | http://some-url.com/api/v2/customers?show=50&page=3 177 | 178 | // or 179 | 180 | http://some-url.com/api/v2/reports?page=2&show=75 181 | ``` 182 | 183 | Rather than writing the same filters for each individual endpoint, you can create a macro to make a shorthand for this scenario. 184 | 185 | ```php 186 | Expectation::macro("paginate", function (Expectation $e, $url, $show, $page) { 187 | return $e->get($url) 188 | ->withQuery([ 189 | "show" => $show, 190 | "page" => $page 191 | ]); 192 | }); 193 | ``` 194 | 195 | Now, you can use the `paginate` method on any `Expectation` instance, and it will still be chainable like all other methods on the class. 196 | 197 | ```php 198 | $this->hybrid->expects($this->once()) 199 | ->paginate("/api/v2/customers", 50, 3) 200 | ->withHeader("Authorization", $token); 201 | 202 | // or 203 | 204 | $this->hybrid->expects($this->once()) 205 | ->paginate("/api/v2/reports", 75, 2); 206 | ``` 207 | 208 | When creating a macro, the first argument should be the name of the method you'd like to add, followed by a closure that accepts an `Expectation` instance as the first argument, and any number of arguments you need following. You can even make arguments optional. 209 | 210 | ```php 211 | Expectation::macro("someName", function ($expect, $argument = false) { 212 | if ($argument) { 213 | // Do something 214 | return $expect; 215 | } 216 | 217 | // Do something else 218 | return $expect; 219 | }); 220 | ``` 221 | 222 | ### Registering Macros 223 | 224 | You can register macros in two ways: 225 | 226 | 1. Inline anywhere before you need it with the static `Expectation::macro` method. 227 | 1. Globally with the PHPUnit extension that Hybrid provides. 228 | 229 | #### Registering Macros Inline 230 | 231 | You can register a macro anywhere you like before you need to use it, using `Expectation::macro`. 232 | 233 | ```php 234 | Expectation::macro("vendorSetup", function (Expectation $e, $token) { 235 | return $e->withProtocol(2.0) 236 | ->withHeader("Authorization", $token); 237 | }); 238 | 239 | // You can then use the vendorSetup method as needed. 240 | $this->hybrid->expects($this->any()) 241 | ->vendorSetup($someAuthToken) 242 | ->get("/some-endpoint"); 243 | ``` 244 | 245 | #### Registering Macros Globally 246 | 247 | You can ensure your macros are available throughout all your tests by adding the Hybrid PHPUnit extension to your `phpunit.xml` file. 248 | 249 | ```xml 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | ``` 260 | 261 | There are two parts to adding a namespace globally: 262 | 263 | 1. The `BlastCloud\Hybrid\Helpers\Extension` extension must be added to an `extensions` object. 264 | 1. A `php` object variable with the name `HybridMacroFile`, pointing to your file that has all your macros. 265 | 266 | ::: tip Be Aware 267 | If you add a file via the extension, slashes should not be escaped. 268 | ::: 269 | 270 | An example macro file: 271 | 272 | ```php 273 | withProtocol(2.0) 279 | ->withHeader("Authorization", $token); 280 | }); 281 | 282 | Expectation::macro("second", function (Expectation $e) { 283 | // Do something 284 | }); 285 | ``` 286 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Getting Started | Hybrid 4 | --- 5 | 6 | # Getting Started 7 | 8 | ::: tip Be Aware 9 | The Symfony\HttpClient component is currently considered an "experimental feature". The underlying library may change in the future before it reaches stable. Please see the [official documentation](https://symfony.com/doc/current/components/http_client.html) for the latest. 10 | ::: 11 | 12 | ## Requirements 13 | 14 | 1. PHP 8.1+ 15 | 2. Symfony\HttpClient 6.0+ 16 | 3. PHPUnit 10+ 17 | 18 | ## Installation 19 | 20 | Add the dependency to your *composer.json* file. 21 | 22 | ```bash 23 | composer require --dev --prefer-dist blastcloud/hybrid 24 | ``` 25 | 26 | Add the `BlastCloud\Hybrid\UsesHybrid` trait to your test class. 27 | 28 | ```php 29 | use BlastCloud\Hybrid\UsesHybrid; 30 | 31 | class SomeTest extends TestCase 32 | { 33 | use UsesHybrid; 34 | ``` 35 | 36 | This trait wires up a class variable named `hybrid`. Inside that object the necessary history and mock handlers for HttpClient are instantiated and saved. You can customize the `Client` object however you like by passing in any options you would pass to a normal `MockHttpClient` in the `getClient()` method. 37 | 38 | ### getClient(array $options = []) 39 | 40 | The `getClient` method returns a new instance of the `HttpClient` class and adds any options you like to it’s constructor. Adding extra options is **not** required. 41 | 42 | ```php 43 | $client = $this->hybrid->getClient([ 44 | "base_uri" => "http://some-url.com/api/v2", 45 | // ... Any other configurations 46 | ]); 47 | ``` 48 | 49 | ## Custom Engine Name 50 | 51 | Hybrid allows you to customize the variable name of the engine, if you prefer to not use "hybrid". To use a custom name, add a constant to the class called `ENGINE_NAME` with the value set to the variable name you'd prefer. 52 | 53 | ```php 54 | use BlastCloud\Hybrid\UsesHybrid; 55 | 56 | class SomeTest extends TestCase 57 | { 58 | use UsesHybrid; 59 | 60 | public $client; 61 | 62 | // Here we define what we want the engine name to be. 63 | const ENGINE_NAME = 'engine'; 64 | 65 | public function setUp(): void 66 | { 67 | parent::setUp(); 68 | 69 | // Here, $this->hybrid has been renamed 70 | // to $this->engine 71 | $this->client = $this->engine->getClient([ 72 | 'base_uri' => 'https://some-url.com/api/v2' 73 | ]); 74 | } 75 | 76 | public function testSomething() 77 | { 78 | $this->engine->expects($this->once()) 79 | ->get('/some/api/url'); 80 | 81 | // ... 82 | } 83 | } 84 | ``` 85 | 86 | The main benefit of using a custom engine name is to abstract as much code as possible. Though it's not likely you'll have a conflicting variable named "hybrid", it's a possibility that is covered. -------------------------------------------------------------------------------- /docs/helpers.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Helpers | Hybrid 4 | --- 5 | 6 | # Helpers 7 | 8 | The following helper methods can be used in addition to expectations and assertions for any custom logic or checks that need to be made. 9 | 10 | ### getHistory(?int $index, $subIndex = null) 11 | 12 | To retrieve the client’s raw history, this method can be used. 13 | 14 | ```php 15 | $history = $this->hybrid->getHistory(); 16 | // Returns the entire history array 17 | ``` 18 | 19 | The shape of the history array Hybrid creates is is as follows: 20 | 21 | ```php 22 | $history = [ 23 | // Hybrid history item structure 24 | [ 25 | "request" => array, 26 | "response" => Symfony\Component\HttpClient\Response\MockResponse object, 27 | "options" => array, 28 | "error" => null|string 29 | ] 30 | // ... 31 | ]; 32 | ``` 33 | 34 | Individual indexes and sub-indexes of the history can also be requested directly. 35 | 36 | ```php 37 | $second = $this->hybrid->getHistory(1); 38 | /** 39 | * [ 40 | * 'request' => array 41 | * 'response' => object 42 | * 'options' => array 43 | * 'errors' => null|string 44 | * ] 45 | */ 46 | 47 | $options = $this->hybrid->getHistory(4, 'options'); 48 | /** 49 | * [ 50 | * 'base_uri' => 'http://somewhere.com', 51 | * // ... 52 | * ] 53 | */ 54 | ``` 55 | 56 | ### historyCount() 57 | 58 | Retrieve the total number of requests that were made on the client. 59 | 60 | ```php 61 | $this->client->request('GET', '/first'); 62 | $this->client->request('DELETE', '/second'); 63 | 64 | echo $this->hybrid->historyCount(); 65 | // 2 66 | ``` 67 | 68 | ### queueCount() 69 | 70 | Retrieve the total number of response items in the mock handler's queue. 71 | 72 | ````php 73 | echo $this->hybrid->queueCount(); 74 | // 0 75 | 76 | $this->hybrid->queueMany(new MockResponse(), 6); 77 | 78 | echo $this->hybrid->queueCount(); 79 | // 6 80 | ``` -------------------------------------------------------------------------------- /docs/mocking-responses.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Mocking Responses | Hybrid 4 | --- 5 | 6 | # Mocking Responses 7 | 8 | There are three main ways to provide responses to return from your client; `queueResponse()` and `queueMany()` methods directly on the `hybrid` instance, and `will()` or its alias `willRespond()` on an expectation. 9 | 10 | ### queueResponse(...$responses) 11 | 12 | The `queueResponse` method is the main way to add responses to your mock handler. All responses should conform to the standard `ResponseInterface`, or a `\Throwable` may also be used. In most cases, it will likely be a `MockResponse` object. That being said, `MockResponse` objects can accept strings, arrays of strings, or `\Iterable` objects as it's body. 13 | 14 | ```php 15 | public function testSomething() 16 | { 17 | $this->hybrid->queueResponse( 18 | new MockResponse("some body", [ 19 | 'response_headers' => [] 20 | ]) 21 | ); 22 | 23 | // Whatever the first request sent to your client is, 24 | // the response above will be returned. 25 | } 26 | ``` 27 | 28 | The method accepts variadic arguments, so you can add as many responses as you like. 29 | 30 | ```php 31 | // One call with multiple arguments 32 | $iterable = someIterableMethod(); 33 | 34 | $this->hybrid->queueResponse( 35 | new MockResponse("some body"), 36 | new MockResponse($iterable), 37 | new \Exception('Some Message') 38 | ); 39 | 40 | // Multiple calls with one response each. 41 | $this->hybrid->queueResponse(new MockResponse("some body")); 42 | $this->hybrid->queueResponse(new MockResponse($iterable)); 43 | $this->hybrid->queueResponse(new \Exception('Some Message')); 44 | ``` 45 | 46 | ::: tip Be Aware 47 | Whatever order you queue your responses is the order they will be returned from your client, no matter the URI or method of the request. This is a constraint of most mock handlers. 48 | 49 | Also, please note that any `\Throwable` object given, such as an exception, will be thrown rather than returned from the client. 50 | ::: 51 | 52 | ### queueMany($response, int $times = 1) 53 | 54 | To quickly add multiple responses to the queue without making each one individually, the `queueMany` method can repeat a specific response any number of times you specify. 55 | 56 | ```php 57 | // Add 5 responses with no body and status code 201 to the queue. 58 | $response = new MockResponse(null, [ 59 | 'http_code' => 201 60 | ]); 61 | 62 | $this->hybrid->queueMany($response, 5); 63 | ``` 64 | 65 | ### will($response, int $times = 1), willRespond($response, int $times = 1) 66 | 67 | If you are using expectations in your test, you can add responses to the expectation chain with either `will()` or its alias, `willRespond()`. In both cases, you can provide a single response and the number of times it should be added to the queue. This is so that you can make sure to add a response for each expected invocation. 68 | 69 | ```php 70 | $this->hybrid->expects($this->atLeast(9)) 71 | ->get("/some-uri") 72 | ->willRespond(new MockResponse(), 12); 73 | 74 | $this->hybrid->expects($this->twice()) 75 | ->post("/another-uri") 76 | ->will(new \Exception("some message"), 2); 77 | ``` 78 | 79 | If you’d like to return different responses from the same expectation, you can still chain your `will()` or `willRespond()` statements. 80 | 81 | ```php 82 | $this->hybrid->expects($this->exactly(2)) 83 | ->endpoint("/a-url-for-deleting", "DELETE") 84 | ->will(new MockResponse(null, ['http_code' => 204])) 85 | ->will(new MockResponse(null, ['http_code' => 210])); 86 | ``` 87 | 88 | ::: tip Be Aware 89 | Whatever order you queue your responses is the order they will be returned from your client, no matter the URI or method of the request. This is a constraint of most mock handlers. 90 | ::: -------------------------------------------------------------------------------- /docs/navigation.md: -------------------------------------------------------------------------------- 1 | - [Home](./README.md) 2 | - Guide 3 | - [Getting Started](./getting-started.md) 4 | - [Mocking Responses](./mocking-responses.md) 5 | - [Expectations](./expectations.md) 6 | - [Assertions](./assertions.md) 7 | - [Helpers](./helpers.md) 8 | - [Extending Hybrid](./extending.md) 9 | - [Troubleshooting](./troubleshooting.md) 10 | - Miscellaneous 11 | - [Why](./why.md) 12 | - [Changelog](./changelog.md) -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Troubleshooting | Hybrid 4 | --- 5 | 6 | # Troubleshooting 7 | 8 | This page lists common issues or message you might run into while building out your tests with Hybrid. 9 | 10 | ## Mock queue is empty 11 | 12 | ```bash 13 | OutOfBoundsException: Mock queue is empty 14 | ``` 15 | 16 | This exception is thrown by Hybrid's mock handler when you have either not provided a response to return from the mock queue, or have run out of responses. When Hybrid returns a response from the mock handler, it removes that response from the queue entirely. 17 | 18 | ```php{4} 19 | // In your tests 20 | $this->hybrid->expects($this->anything()) 21 | ->get('/some-url') 22 | ->willRespond(new MockResponse('Success', [ 23 | 'http_code' => 201 24 | ])); 25 | 26 | // Code under test 27 | $this->instance->doSomething(); 28 | ``` 29 | 30 | In the example above, we added only 1 response to the queue before executing our code under test. Then in that code under test we might end up making two requests. 31 | 32 | ```php 33 | // In your production code 34 | $response = $this->client->request('GET', '/some-url'); 35 | 36 | // Later in your production code 37 | $response2 = $this->client->request('GET', '/some-url'); 38 | ``` 39 | 40 | ## Hybrid's Error Messages 41 | 42 | In order to be helpful, Hybrid `Expectations` are serialized into a user-friendly list of filters that exist on a failed expectation. For example, the following `Expectation` 43 | 44 | ```php 45 | $this->hybrid->expects($this->once()) 46 | ->withHeader('Auth', 'Some-key') 47 | ->withQuery(['first' => 'value', 'second' => 'another']) 48 | ->get('/a-url-for/querying') 49 | ->will(new MockResponse()); 50 | ``` 51 | 52 | Would be serialized to a string error in the console as 53 | 54 | ```bash 55 | Method was expected to be called 1 times, actually called 0 times. 56 | 57 | Expectation: /a-url-for/querying 58 | ----------------------------- 59 | Headers: { 60 | "Auth": "Some-key" 61 | } 62 | Query: (Exclusive: false){ 63 | "first": "value", 64 | "second": "another" 65 | } 66 | Method: GET 67 | ``` -------------------------------------------------------------------------------- /docs/why.md: -------------------------------------------------------------------------------- 1 | --- 2 | lang: en-US 3 | title: Why | Hybrid 4 | --- 5 | 6 | # Why This Library? 7 | 8 | This project is a port of the [Guzzler](https://guzzler.dev) library, and is intended to service the Symfony community. Guzzler started as a personal itch to have tests for working with Guzzle that were more descriptive and documenting of what they were testing. The same is now possible for HttpClient too. 9 | 10 | ## Recording History 11 | 12 | HttpClient allows us to insert mock responses, but we're on our own to build out a way to record the history of requests. Though mocking responses is highly necessary to building API integrations, verifying the requests sent out are just as important. By using a mock client built by Hybrid, the history of the requests are recorded for later verification. 13 | 14 | ```php 15 | public $client; 16 | 17 | public function setUp(): void 18 | { 19 | parent::setUp(); 20 | 21 | // The mock client returned automatically records all requests made in your test 22 | $this->client = $this->hybrid->getClient([ 23 | 'base_uri' => 'https://some-api-url.com/api/v3' 24 | ]); 25 | 26 | $this->codeToTest = new SomeClass($this->client); 27 | } 28 | 29 | public function testSomething() 30 | { 31 | // This response will now be returned the first time a request is made to the 32 | // client that we injected into our class in the setUp method. 33 | $this->hybrid->queueResponse(new MockResponse('Some Body')); 34 | 35 | // ... Other code 36 | } 37 | ``` 38 | 39 | ## Treat Your Expectations Like PHPUnit Mocks 40 | 41 | If you were to record the history yourself, you still need a way to go into each request to verify it was made as intended. 42 | 43 | ```php 44 | // Verify it was a POST 45 | $this->assertEquals('POST', $history[0]['request']['method']); 46 | 47 | // Verify it was the correct URL 48 | $url = parse_url($history[0]['request']['url']); 49 | $this->assertEquals("/v3/company/{$this->companyId}/bill", $url['path']); 50 | 51 | // Verify the request was a JSON request and that the 52 | // body contains the required JSON data 53 | $this->assertEquals("content-type: application/json", $history[0]['request']['normalized_headers']['content-type'); 54 | $body = json_decode($history[0]['request']['body'], true); 55 | $this->assertEquals('AccountBasedExpenseLineDetail', $body['some-nested-place']['DetailType']); 56 | $this->assertEquals(200.0, $body['some-nested-place']['Amount']); 57 | $this->assertEquals($bill->id, $body['some-nested-place']['Id']; 58 | ``` 59 | 60 | Instead of tightly coupling our tests to HttpClient's configuration, it's helpful to say exactly what we are testing for, and it would be nice to copy PHPUnit’s way of saying _“we want to ensure {x} happens {y} number of times.”_ 61 | 62 | ```php 63 | // PHPUnit Mock and Invokables Syntax 64 | $mock->expects($this->once()) 65 | ->method(/* some method name */) 66 | ->with(/* some argument */) 67 | ->willReturn(/* some result */); 68 | ``` 69 | 70 | Hybrid's chainable `Expectation`s allow us to specify every aspect of the request we care about. 71 | 72 | ```php 73 | $this->hybrid->expects($this->atLeast(1)) 74 | ->post("/v3/company/{$this->companyId}/bill") 75 | ->withJson([ 76 | 'DetailType' => 'AccountBasedExpenseLineDetail', 77 | 'Amount' => 200.0, 78 | 'Id' => $bill->id 79 | ]) 80 | ->willRespond(new MockResponse( 81 | file_get_contents(__DIR__.'/quickbook-stubs/bill-created.json'), 82 | ['http_code' => 201] 83 | )); 84 | ``` 85 | 86 | ## Verify All or Part of Your Requests 87 | 88 | Hybrid provides several helper assertions that allow you to create expectations around either the entirety of your requests, or just a specific subset. Now, you don't have to iterate through all your history items by hand. 89 | 90 | ```php 91 | // Without Hybrid 92 | foreach ($history as $item) { 93 | $header = $item['request']['normalized_headers']['Authorization']; 94 | $this->assertStringContainsString($header[0], $token); 95 | } 96 | 97 | $last = end($history); 98 | $url = parse_url($last['request']['url']); 99 | $this->assertFalse( 100 | "/v3/company/{$this->companyId}/user" == $url['path'] 101 | && $last['request']['method'] == "DELETE" 102 | ); 103 | 104 | 105 | // With Hybrid 106 | $this->hybrid->assertAll(function (Expectation $e) use ($token) { 107 | return $e->withHeader('Authorization', "Bearer {$token}"); 108 | }); 109 | 110 | $this->hybrid->assertNotLast(function (Expectation $e) { 111 | return $e->delete("/v3/company/{$this->companyId}/user"); 112 | }); 113 | ``` 114 | 115 | ## Helpful Failure Messages 116 | 117 | Whenever an expectation is not met Hybrid shows a helpful, [serialized message](/troubleshooting/#hybrid-s-error-messages) of it's arguments in the console. 118 | 119 | ## Extendability 120 | 121 | Hybrid, and all Chassis based projects, allow you to create your own [filters](/extending/#custom-filters) and [macros](/extending/#custom-macros). Doing so allows you to add your own solutions for complex traversing needs and shorthands. -------------------------------------------------------------------------------- /src/Expectation.php: -------------------------------------------------------------------------------- 1 | body = $body; 16 | $this->exclusive = $exclusive; 17 | } 18 | 19 | public function __invoke(array $history): array 20 | { 21 | return array_filter($history, function ($call) { 22 | $body = $call['request']['body'] ?? null; 23 | 24 | if (!$this->exclusive && !$body) { 25 | return false; 26 | } 27 | 28 | return $this->exclusive 29 | ? $body == $this->body 30 | : str_contains($body, $this->body); 31 | }); 32 | } 33 | 34 | public function __toString(): string 35 | { 36 | return str_pad('Body:', self::STR_PAD).$this->body; 37 | } 38 | } -------------------------------------------------------------------------------- /src/Filters/WithEndpoint.php: -------------------------------------------------------------------------------- 1 | endpoint = parse_url($uri)['path']; 25 | $this->method = $method; 26 | } 27 | 28 | public function __invoke(array $history): array 29 | { 30 | return array_filter($history, function ($call) { 31 | $url = parse_url($call['request']['url']); 32 | return $call['request']['method'] == $this->method 33 | && $url['path'] == $this->endpoint; 34 | }); 35 | } 36 | 37 | public function __toString(): string 38 | { 39 | return str_pad('Method:', self::STR_PAD).$this->method; 40 | } 41 | } -------------------------------------------------------------------------------- /src/Filters/WithFile.php: -------------------------------------------------------------------------------- 1 | files[$name] = $file; 22 | } 23 | 24 | public function withFiles(array $files, bool $exclusive = false) 25 | { 26 | foreach ($files as $key => $file) { 27 | $this->withFile($key, $file); 28 | } 29 | 30 | $this->exclusive = $exclusive; 31 | } 32 | 33 | public function __invoke(array $history): array 34 | { 35 | return array_filter($history, function ($item) { 36 | $body = $item['request']['body'] ?? ''; 37 | 38 | $dispositions = []; 39 | $boundary = $this->getBoundary($item['request']['headers'] ?? []); 40 | 41 | foreach ($this->parseMultipartBody($body, $boundary) as $d) { 42 | if ($d->isFile()) { 43 | $dispositions[$d->name] = $d; 44 | } 45 | } 46 | 47 | foreach ($this->files as $name => $file) { 48 | if (!isset($dispositions[$name]) || !$file->compare($dispositions[$name])) { 49 | return false; 50 | } 51 | } 52 | 53 | return !$this->exclusive || count($dispositions) == count($this->files); 54 | }); 55 | } 56 | 57 | public function __toString(): string 58 | { 59 | $e = $this->exclusive ? 'true' : 'false'; 60 | return "Files: (Exclusive: {$e}) ".json_encode($this->files, JSON_PRETTY_PRINT); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Filters/WithForm.php: -------------------------------------------------------------------------------- 1 | form[$key] = $value; 19 | } 20 | 21 | public function withForm(array $fields, bool $exclusive = false) 22 | { 23 | foreach ($fields as $key => $value) { 24 | $this->withFormField($key, $value); 25 | } 26 | 27 | $this->exclusive = $exclusive; 28 | } 29 | 30 | public function __invoke(array $history): array 31 | { 32 | return array_filter($history, function ($call) { 33 | $body = $call['request']['body'] ?? ''; 34 | 35 | // TODO: Once HttpClient is stable, remove the excess here and just use the 36 | // proper index name and shape; whatever that is. 37 | $boundary = $this->getBoundary( 38 | $call['request']['normalized_headers'] 39 | ?? $call['request']['request_headers'] 40 | ?? [] 41 | ); 42 | 43 | if (!empty($boundary)) { 44 | $parsed = []; 45 | foreach ($this->parseMultipartBody($body, $boundary) as $d) { 46 | if (!$d->isFile()) $parsed[$d->name] = $d->contents; 47 | } 48 | } else { 49 | parse_str($body, $parsed); 50 | } 51 | 52 | return $this->verifyFields($this->form, $parsed, $this->exclusive); 53 | }); 54 | } 55 | 56 | public function __toString(): string 57 | { 58 | $e = $this->exclusive ? 'true' : 'false'; 59 | return "Form: (Exclusive: {$e}) " 60 | .json_encode($this->form, JSON_PRETTY_PRINT); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Filters/WithHeader.php: -------------------------------------------------------------------------------- 1 | headers[$key] = $value; 15 | } 16 | 17 | public function withHeaders(array $values) 18 | { 19 | foreach($values as $key => $value) { 20 | $this->withHeader($key, $value); 21 | } 22 | } 23 | 24 | public function __invoke(array $history): array 25 | { 26 | return array_filter($history, function ($call) { 27 | foreach ($this->headers as $key => $value) { 28 | // TODO: Once HttpClient is stable, remove the excess here and just use the 29 | // proper index name and shape; whatever that is. 30 | $header = $call['request']['normalized_headers'][strtolower($key)] 31 | ?? $call['request']['request_headers'][strtolower($key)] 32 | ?? $call['request']['headers'][strtolower($key)] 33 | ?? []; 34 | 35 | // This map wasn't initially required, but then the shape of the headers 36 | // was changed. The Client is experimental currently. 37 | $header = array_map(function ($value) { 38 | $x = explode(': ', $value); 39 | return end($x); 40 | }, $header); 41 | 42 | if ($header != $value && !in_array($value, $header)) { 43 | return false; 44 | } 45 | } 46 | 47 | return true; 48 | }); 49 | } 50 | 51 | public function __toString(): string 52 | { 53 | return str_pad('Headers:', self::STR_PAD) 54 | . json_encode($this->headers, JSON_PRETTY_PRINT); 55 | } 56 | } -------------------------------------------------------------------------------- /src/Filters/WithJson.php: -------------------------------------------------------------------------------- 1 | exclusive = $exclusive; 17 | // Pre-sort so it only needs to be done once. 18 | $this->json = $json; 19 | $this->sort($this->json); 20 | } 21 | 22 | // Determine if the passed array has any non-incrementing keys; associative array 23 | protected function isAssoc(array $arr) 24 | { 25 | if ([] === $arr) return false; 26 | return array_keys($arr) !== range(0, count($arr) - 1); 27 | } 28 | 29 | // Recursively sort by keys and values 30 | protected function sort(&$array) { 31 | foreach ($array as &$value) { 32 | if (is_array($value)) $this->sort($value); 33 | } 34 | 35 | return $this->isAssoc($array) 36 | ? ksort($array) 37 | : sort($array); 38 | } 39 | 40 | public function __invoke(array $history): array 41 | { 42 | return array_filter($history, function ($call) { 43 | $body = json_decode($call['request']['body'], true); 44 | 45 | $this->sort($body); 46 | 47 | $j1 = json_encode($body); 48 | $j2 = json_encode($this->json); 49 | 50 | return $this->exclusive 51 | ? $j1 == $j2 52 | : strpos($j1, trim($j2, '{}[]')) !== false; 53 | }); 54 | } 55 | 56 | public function __toString(): string 57 | { 58 | $e = $this->exclusive ? 'true' : 'false'; 59 | return "JSON: (Exclusive: {$e}) " 60 | .json_encode($this->json, JSON_PRETTY_PRINT); 61 | } 62 | } -------------------------------------------------------------------------------- /src/Filters/WithOption.php: -------------------------------------------------------------------------------- 1 | $value) { 15 | $this->withOption($key, $value); 16 | } 17 | } 18 | 19 | public function withOption($key, $value) 20 | { 21 | $this->options[$key] = $value; 22 | } 23 | 24 | public function __invoke(array $history): array 25 | { 26 | return array_filter($history, function ($call) { 27 | foreach ($this->options as $key => $value) { 28 | $option = $call['options'][$key] ?? null; 29 | 30 | if ($option !== $value) { 31 | return false; 32 | } 33 | } 34 | 35 | return true; 36 | }); 37 | } 38 | 39 | public function __toString(): string 40 | { 41 | return str_pad("Options: ", self::STR_PAD) 42 | .json_encode($this->options, JSON_PRETTY_PRINT); 43 | } 44 | } -------------------------------------------------------------------------------- /src/Filters/WithProtocol.php: -------------------------------------------------------------------------------- 1 | version = $protocol; 15 | } 16 | 17 | public function __invoke(array $history): array 18 | { 19 | return array_filter($history, function ($call) { 20 | return $call['request']['http_version'] == $this->version; 21 | }); 22 | } 23 | 24 | public function __toString(): string 25 | { 26 | return str_pad("Protocol:", self::STR_PAD).$this->version; 27 | } 28 | } -------------------------------------------------------------------------------- /src/Filters/WithQuery.php: -------------------------------------------------------------------------------- 1 | query = $query; 20 | $this->exclusive = $exclusive; 21 | } 22 | 23 | public function withQueryKeys(array $keys) 24 | { 25 | $this->keys = $keys; 26 | } 27 | 28 | public function withQueryKey(string $key) 29 | { 30 | $this->keys[] = $key; 31 | } 32 | 33 | public function __invoke(array $history): array 34 | { 35 | return array_filter($history, function ($call) { 36 | if (array_diff($this->keys, array_keys($call['request']['query']))) { 37 | return false; 38 | } 39 | 40 | return $this->verifyFields($this->query, $call['request']['query'], $this->exclusive); 41 | }); 42 | } 43 | 44 | public function __toString(): string 45 | { 46 | $e = $this->exclusive ? 'true' : 'false'; 47 | return "Query: (Exclusive: {$e})".json_encode($this->query, JSON_PRETTY_PRINT); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Helpers/Extension.php: -------------------------------------------------------------------------------- 1 | macroFiles[] = $file; 30 | } 31 | 32 | $this->loadMacros(); 33 | } 34 | 35 | /** 36 | * Load any macros based on the file specified in the PHPUnit configs 37 | * 38 | * @throws \Exception 39 | */ 40 | public function loadMacros() 41 | { 42 | foreach ($this->macroFiles as $file) { 43 | if (!is_file($file)) { 44 | throw new \Exception("The macro file {$file} cannot be found."); 45 | } 46 | 47 | if (!is_readable($file)) { 48 | throw new \Exception("The macro file {$file} cannot be read."); 49 | } 50 | 51 | require_once $file; 52 | } 53 | } 54 | 55 | public function bootstrap(Configuration $configuration, Facade $facade, ParameterCollection $parameters): void 56 | { 57 | if ($namespace = $GLOBALS[self::NAMESPACE] ?? false) { 58 | \BlastCloud\Hybrid\Expectation::addNamespace($namespace); 59 | } 60 | 61 | if ($file = $GLOBALS[self::MACRO_FILE] ?? false) { 62 | $this->macroFiles[] = $file; 63 | } 64 | 65 | $this->loadMacros(); 66 | } 67 | } -------------------------------------------------------------------------------- /src/Hybrid.php: -------------------------------------------------------------------------------- 1 | mockHandler = new MockQueue($this->history, $options); 28 | 29 | return new MockHttpClient($this->mockHandler, $options['base_uri'] ?? null); 30 | } 31 | 32 | protected function createExpectation($argument = null): Expectation 33 | { 34 | return new Expectation($argument, $this); 35 | } 36 | 37 | public function expects(mixed $argument): \BlastCloud\Chassis\Expectation 38 | { 39 | return parent::expects($argument); 40 | } 41 | } -------------------------------------------------------------------------------- /src/MockHttpClient.php: -------------------------------------------------------------------------------- 1 | options = $options; 19 | 20 | $this->history = function ($request, $ops, $response) use (&$history, $options) { 21 | $history[] = [ 22 | 'request' => $request, 23 | 'response' => $response, 24 | 'options' => array_merge($this->options, $ops), 25 | 'error' => ($response instanceof \Throwable) 26 | ? $response->getMessage() 27 | : [] 28 | ]; 29 | }; 30 | } 31 | 32 | public function append($response): void 33 | { 34 | $this->responses[] = $response; 35 | } 36 | 37 | public function count(): int 38 | { 39 | return count($this->responses); 40 | } 41 | 42 | public function __invoke($method, $url, $options) 43 | { 44 | if (empty($this->responses)) { 45 | throw new \OutOfBoundsException("Mock queue is empty"); 46 | } 47 | 48 | $h = $this->history; 49 | 50 | // Get rid of the body. It doesn't make sense to put in the options later on. 51 | $o = $options; 52 | unset($o['body']); 53 | 54 | $h([ 55 | 'method' => $method, 56 | 'url' => $url 57 | ] + $options, 58 | $o, 59 | $response = array_shift($this->responses) 60 | ); 61 | 62 | if ($response instanceof \Throwable) 63 | { 64 | throw $response; 65 | } 66 | 67 | return $response; 68 | } 69 | } -------------------------------------------------------------------------------- /src/Traits/Forms.php: -------------------------------------------------------------------------------- 1 | parseHeaderVariables('boundary', $header)) { 19 | return $boundary; 20 | } 21 | } 22 | 23 | return ''; 24 | } 25 | } -------------------------------------------------------------------------------- /src/UsesHybrid.php: -------------------------------------------------------------------------------- 1 | engineName(); 16 | 17 | $this->$engine = new Hybrid($this); 18 | } 19 | 20 | private function engineName() 21 | { 22 | return defined('self::ENGINE_NAME') 23 | ? self::ENGINE_NAME 24 | : 'hybrid'; 25 | } 26 | 27 | /** 28 | * Run through the list of expectations that were made and 29 | * evaluate all requests in the history. Closure::call() 30 | * is used to hide this method from the user APIs. 31 | * 32 | * @after 33 | */ 34 | public function runHybridExpectations() 35 | { 36 | $name = $this->engineName(); 37 | (function () { 38 | $this->runExpectations(); 39 | })->call($this->$name); 40 | } 41 | } -------------------------------------------------------------------------------- /src/macros.php: -------------------------------------------------------------------------------- 1 | withEndpoint($url, $verb); 10 | } 11 | ); 12 | } 13 | 14 | Expectation::macro('withoutQuery', function (BlastCloud\Chassis\Expectation $e) { 15 | return $e->withQuery([], true); 16 | }); --------------------------------------------------------------------------------