├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── .gitignore ├── art ├── header.png ├── logo@1x.png ├── logo@2x.png ├── logo@3x.png ├── logo@4x.png └── socialcard.png ├── composer.json ├── infection.json5 ├── license.txt ├── phpstan.neon ├── phpunit.10.xml ├── phpunit.xml ├── readme.md ├── src └── CallableFake.php └── tests └── CallableFakeTest.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "19:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | static-analysis: 13 | name: Static analysis 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Get composer cache directory 21 | id: composer-cache 22 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 23 | 24 | - name: Cache dependencies 25 | uses: actions/cache@v4 26 | with: 27 | path: ${{ steps.composer-cache.outputs.dir }} 28 | key: ${{ runner.os }}-php-8.2-composer-${{ hashFiles('**/composer.json') }} 29 | restore-keys: ${{ runner.os }}-php8.2-composer- 30 | 31 | - name: Setup PHP 32 | uses: shivammathur/setup-php@v2 33 | with: 34 | php-version: 8.2 35 | tools: phpstan 36 | coverage: none 37 | 38 | - name: Install dependencies 39 | run: composer install 40 | 41 | - name: Check platform requirements 42 | run: composer check-platform-reqs 43 | 44 | - name: PHPStan 45 | run: phpstan 46 | 47 | 48 | code-style: 49 | name: Code style 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v4 55 | 56 | - name: Setup PHP 57 | uses: shivammathur/setup-php@v2 58 | with: 59 | php-version: 8.4 60 | ## Temporary version pin as 1.21 is broken 61 | tools: pint:1.20.0 62 | coverage: none 63 | 64 | - name: Pint 65 | run: pint --test 66 | 67 | 68 | mutation-tests: 69 | name: Mutation tests 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - name: Checkout code 74 | uses: actions/checkout@v4 75 | 76 | - name: Get composer cache directory 77 | id: composer-cache 78 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 79 | 80 | - name: Cache dependencies 81 | uses: actions/cache@v4 82 | with: 83 | path: ${{ steps.composer-cache.outputs.dir }} 84 | key: ${{ runner.os }}-php-8.4-composer-${{ hashFiles('**/composer.json') }} 85 | restore-keys: ${{ runner.os }}-php8.4-composer- 86 | 87 | - name: Setup PHP 88 | uses: shivammathur/setup-php@v2 89 | with: 90 | php-version: 8.4 91 | tools: infection 92 | coverage: pcov 93 | 94 | - name: Install dependencies 95 | run: composer install 96 | 97 | - name: Infection 98 | run: infection --show-mutations 99 | 100 | 101 | tests: 102 | runs-on: ubuntu-latest 103 | name: PHP ${{ matrix.php }} PHPUnit ${{ matrix.phpunit }} 104 | strategy: 105 | matrix: 106 | php: [8.2, 8.3, 8.4] 107 | phpunit: [10, 11, 12] 108 | exclude: 109 | - phpunit: 12 110 | php: 8.2 111 | 112 | steps: 113 | - name: Checkout code 114 | uses: actions/checkout@v4 115 | 116 | - name: Get composer cache directory 117 | id: composer-cache 118 | run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 119 | 120 | - name: Cache dependencies 121 | uses: actions/cache@v4 122 | with: 123 | path: ${{ steps.composer-cache.outputs.dir }} 124 | key: ${{ runner.os }}-php-${{ matrix.php }}-phpunit-${{ matrix.phpunit }}-composer-${{ hashFiles('**/composer.json') }} 125 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-phpunit-${{ matrix.phpunit }}-composer- 126 | 127 | - name: Setup PHP 128 | uses: shivammathur/setup-php@v2 129 | with: 130 | php-version: ${{ matrix.php }} 131 | coverage: none 132 | 133 | - name: Install dependencies 134 | run: | 135 | composer require --no-update \ 136 | phpunit/phpunit:^${{ matrix.phpunit }} 137 | composer update 138 | 139 | - name: Configure PHPUnit 140 | run: if [ -f "./phpunit.${{ matrix.phpunit }}.xml" ]; then cp ./phpunit.${{ matrix.phpunit }}.xml ./phpunit.xml; fi 141 | 142 | - name: PHPUnit 143 | run: ./vendor/bin/phpunit 144 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /.phpunit.cache 4 | -------------------------------------------------------------------------------- /art/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/callable-fake/612fbb3dbb603f0ffe54e268c90e74b33b49a67b/art/header.png -------------------------------------------------------------------------------- /art/logo@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/callable-fake/612fbb3dbb603f0ffe54e268c90e74b33b49a67b/art/logo@1x.png -------------------------------------------------------------------------------- /art/logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/callable-fake/612fbb3dbb603f0ffe54e268c90e74b33b49a67b/art/logo@2x.png -------------------------------------------------------------------------------- /art/logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/callable-fake/612fbb3dbb603f0ffe54e268c90e74b33b49a67b/art/logo@3x.png -------------------------------------------------------------------------------- /art/logo@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/callable-fake/612fbb3dbb603f0ffe54e268c90e74b33b49a67b/art/logo@4x.png -------------------------------------------------------------------------------- /art/socialcard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/callable-fake/612fbb3dbb603f0ffe54e268c90e74b33b49a67b/art/socialcard.png -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timacdonald/callable-fake", 3 | "description": "A testing utility that allows you to fake and capture invokations of a callable / Closure", 4 | "keywords": [ 5 | "callable", 6 | "Closure", 7 | "fake", 8 | "testing" 9 | ], 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Tim MacDonald", 14 | "email": "hello@timacdonald.me", 15 | "homepage": "https://timacdonald.me" 16 | } 17 | ], 18 | "require": { 19 | "php": "^8.2", 20 | "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "TiMacDonald\\CallableFake\\": "src" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Tests\\": "tests/" 30 | } 31 | }, 32 | "config": { 33 | "preferred-install": "dist", 34 | "sort-packages": true 35 | }, 36 | "minimum-stability": "stable", 37 | "prefer-stable": true 38 | } 39 | -------------------------------------------------------------------------------- /infection.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "vendor/infection/infection/resources/schema.json", 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | }, 8 | "mutators": { 9 | "@default": true, 10 | "CastBool": { 11 | "ignoreSourceCodeByRegex": [ 12 | "return \\(bool\\) \\$callback\\(\\.\\.\\.\\$arguments\\);" 13 | ], 14 | } 15 | }, 16 | "minMsi": 100 17 | } 18 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tim MacDonald 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. 22 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /phpunit.10.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | tests 14 | 15 | 16 | 17 | 18 | 19 | src 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

Callable Fake: a PHP package by Tim MacDonald

2 | 3 | # Callable / Closure testing fake 4 | 5 | If you have an interface who's public API allows a developer to pass a Closure / callable, but causes no internal or external side-effects, as these are left up to the developer using the interface, this package may assist in testing. This class adds some named assertions which gives you an API that is very much inspired by Laravel's service fakes. It may be a little more verbose, but it changes the language of the tests to better reflect what is going on. 6 | 7 | It also makes it easy to assert the order of invocations, and how many times a callable has been invoked. 8 | 9 | ## Installation 10 | 11 | You can install the package using [composer](https://getcomposer.org/): 12 | 13 | ``` 14 | composer require timacdonald/callable-fake --dev 15 | ``` 16 | 17 | ## Basic usage 18 | 19 | This packge requires you to be testing a pretty specfic type of API / interaction to be useful. Imagine you are developing a package that ships with the following interface... 20 | 21 | ```php 22 | interface DependencyRepository 23 | { 24 | public function each(callable $callback): void; 25 | } 26 | ``` 27 | 28 | This interface accepts a callback, and under the hood loops through all "dependecies" and passes each one to the callback for the developer to work with. 29 | 30 | ### Before 31 | 32 | Let's see what the a test for this method might look like... 33 | 34 | ```php 35 | public function testEachLoopsOverAllDependencies(): void 36 | { 37 | // arrange 38 | $received = []; 39 | $expected = factory(Dependency::class)->times(2)->create(); 40 | $repo = $this->app[DependencyRepository::class]; 41 | 42 | // act 43 | $repo->each(function (Dependency $dependency) use (&$received): void { 44 | $received[] = $dependency; 45 | }); 46 | 47 | // assert 48 | $this->assertCount(2, $received); 49 | $this->assertTrue($expected[0]->is($received[0])); 50 | $this->assertTrue($expected[1]->is($received[1])); 51 | } 52 | ``` 53 | 54 | ### After 55 | 56 | ```php 57 | public function testEachLoopsOverAllDependencies(): void 58 | { 59 | // arrange 60 | $callable = new CallableFake(); 61 | $expected = factory(Dependency::class)->times(2)->create(); 62 | $repo = $this->app[DependencyRepository::class]; 63 | 64 | // act 65 | $repo->each($callable); 66 | 67 | // assert 68 | $callable->assertTimesInvoked(2); 69 | $callable->assertCalled(function (Depedency $dependency) use ($expected): bool { 70 | return $dependency->is($expected[0]); 71 | }); 72 | $callable->assertCalled(function (Dependency $dependency) use ($expected): bool { 73 | return $dependency->is($expected[1]); 74 | }); 75 | } 76 | ``` 77 | 78 | ## Available assertions 79 | 80 | All assertions are chainable. 81 | 82 | ### assertCalled(callable $callback): self 83 | 84 | ```php 85 | $callable->assertCalled(function (Dependency $dependency): bool { 86 | return Str::startsWith($dependency->name, 'spatie/'); 87 | }); 88 | ``` 89 | 90 | ### assertNotCalled(callable $callback): self 91 | 92 | ```php 93 | $callable->assertNotCalled(function (Dependency $dependency): bool { 94 | return Str::startsWith($dependency->name, 'timacdonald/'); 95 | }); 96 | ``` 97 | 98 | ### assertCalledIndex(callable $callback, int|array $index): self 99 | 100 | Ensure the callable was called in an explicit order, i.e. it was called as the 0th and 5th invocation. 101 | ```php 102 | $callable->assertCalledIndex(function (Dependency $dependency): bool { 103 | return Str::startsWith($dependency, 'spatie/'); 104 | }, [0, 5]); 105 | ``` 106 | 107 | ### assertCalledTimes(callable $callback, int $times): self 108 | 109 | ```php 110 | $callable->assertCalledTimes(function (Dependency $dependency): bool { 111 | return Str::startsWith($dependency, 'spatie/'); 112 | }, 999); 113 | ``` 114 | 115 | ### assertTimesInvoked(int $times): self 116 | 117 | ```php 118 | $callable->assertTimesInvoked(2); 119 | ``` 120 | 121 | ### assertInvoked(): self 122 | 123 | ```php 124 | $callable->assertInvoked(); 125 | ``` 126 | 127 | ### assertNotInvoked(): self 128 | 129 | ```php 130 | $callable->assertNotInvoked(); 131 | ``` 132 | 133 | ## Non-assertion API 134 | 135 | ### asClosure(): Closure 136 | 137 | If the method is type-hinted with `\Closure` instead of callable, you can use this method to transform the callable to an instance of `\Closure`. 138 | 139 | ```php 140 | $callable = new CallableFake; 141 | 142 | $thing->closureTypeHintedMethod($callable->asClosure()); 143 | 144 | $callable->assertInvoked(); 145 | ``` 146 | 147 | ### wasInvoked(): bool 148 | 149 | ```php 150 | if ($callable->wasInvoked()) { 151 | // 152 | } 153 | ``` 154 | 155 | ### wasNotInvoked(): bool 156 | 157 | ```php 158 | if ($callable->wasNotInvoked()) { 159 | // 160 | } 161 | ``` 162 | 163 | ### called(callable $callback): array 164 | 165 | ```php 166 | $invocationArguments = $callable->called(function (Dependency $dependency): bool { 167 | return Str::startsWith($dependency->name, 'spatie/') 168 | }); 169 | ``` 170 | 171 | ## Specifying return values 172 | 173 | If you need to specify return values, this _could_ be an indicator that this is not the right tool for the job. But there are some cases where return values determine control flow, so it can be handy, in which case you can pass a "return resolver" to the named constructor `withReturnResolver`. 174 | 175 | ```php 176 | $callable = CallableFake::withReturnResolver(function (Dependency $dependency): bool { 177 | if ($dependency->version === '*') { 178 | return '🤠'; 179 | } 180 | 181 | return '😀'; 182 | }); 183 | 184 | // You would not generally be calling this yourself, this is simply to demonstate 185 | // what will happen under the hood... 186 | 187 | $emoji = $callable(new Dependecy(['version' => '*'])); 188 | 189 | // $emoji === '🤠'; 190 | ``` 191 | 192 | ## Credits 193 | 194 | - [Tim MacDonald](https://github.com/timacdonald) 195 | - [All Contributors](../../contributors) 196 | 197 | And a special (vegi) thanks to [Caneco](https://twitter.com/caneco) for the logo ✨ 198 | 199 | ## Thanksware 200 | 201 | You are free to use this package, but I ask that you reach out to someone (not me) who has previously, or is currently, maintaining or contributing to an open source library you are using in your project and thank them for their work. Consider your entire tech stack: packages, frameworks, languages, databases, operating systems, frontend, backend, etc. 202 | -------------------------------------------------------------------------------- /src/CallableFake.php: -------------------------------------------------------------------------------- 1 | > 12 | */ 13 | private $invocations = []; 14 | 15 | /** 16 | * @var callable 17 | */ 18 | private $returnResolver; 19 | 20 | public function __construct(?callable $callback = null) 21 | { 22 | $this->returnResolver = $callback ?? function (): void { 23 | // 24 | }; 25 | } 26 | 27 | public static function withReturnResolver(callable $callback): self 28 | { 29 | return new self($callback); 30 | } 31 | 32 | /** 33 | * @return mixed 34 | */ 35 | public function __invoke() 36 | { 37 | $args = func_get_args(); 38 | 39 | $this->invocations[] = $args; 40 | 41 | return call_user_func_array($this->returnResolver, $args); 42 | } 43 | 44 | public function asClosure(): Closure 45 | { 46 | return Closure::fromCallable($this); 47 | } 48 | 49 | public function assertCalled(callable $callback): self 50 | { 51 | Assert::assertNotCount(0, $this->invocations, 'The callable was never called.'); 52 | 53 | Assert::assertNotCount(0, $this->called($callback), 'The expected callable was not called.'); 54 | 55 | return $this; 56 | } 57 | 58 | public function assertNotCalled(callable $callback): self 59 | { 60 | Assert::assertCount(0, $this->called($callback), 'An unexpected callable was called.'); 61 | 62 | return $this; 63 | } 64 | 65 | public function assertCalledTimes(callable $callback, int $times): self 66 | { 67 | $timesCalled = count($this->called($callback)); 68 | 69 | Assert::assertSame($times, $timesCalled, "The expected callable was called {$timesCalled} times instead of the expected {$times} times."); 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * @param array|int $indexes 76 | */ 77 | public function assertCalledIndex(callable $callback, $indexes): self 78 | { 79 | $this->assertCalled($callback); 80 | 81 | $expectedIndexes = (array) $indexes; 82 | 83 | $actualIndexes = array_keys($this->called($callback)); 84 | 85 | $matches = array_intersect($expectedIndexes, $actualIndexes); 86 | 87 | Assert::assertSame(count($matches), count($expectedIndexes), 'The callable was not called in the expected order. Found at index: '.implode(', ', $actualIndexes).'. Expected to be found at index: '.implode(', ', $expectedIndexes)); 88 | 89 | return $this; 90 | } 91 | 92 | public function assertTimesInvoked(int $count): self 93 | { 94 | $timesInvoked = count($this->invocations); 95 | 96 | Assert::assertSame($count, $timesInvoked, "The callable was invoked {$timesInvoked} times instead of the expected {$count} times."); 97 | 98 | return $this; 99 | } 100 | 101 | public function assertInvoked(): self 102 | { 103 | Assert::assertTrue($this->wasInvoked(), 'The callable was not invoked.'); 104 | 105 | return $this; 106 | } 107 | 108 | public function assertNotInvoked(): self 109 | { 110 | Assert::assertTrue($this->wasNotInvoked(), 'The callable was invoked.'); 111 | 112 | return $this; 113 | } 114 | 115 | public function wasInvoked(): bool 116 | { 117 | return count($this->invocations) > 0; 118 | } 119 | 120 | public function wasNotInvoked(): bool 121 | { 122 | return ! $this->wasInvoked(); 123 | } 124 | 125 | /** 126 | * @return array> 127 | */ 128 | public function called(callable $callback): array 129 | { 130 | return array_filter($this->invocations, function (array $arguments) use ($callback): bool { 131 | return (bool) $callback(...$arguments); 132 | }); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /tests/CallableFakeTest.php: -------------------------------------------------------------------------------- 1 | assertCalled(function (string $arg) { 18 | return $arg === 'x'; 19 | }); 20 | $this->fail(); 21 | } catch (ExpectationFailedException $e) { 22 | $this->assertStringStartsWith('The callable was never called.', $e->getMessage()); 23 | } 24 | } 25 | 26 | public function test_assert_called_with_true_before_being_called(): void 27 | { 28 | $fake = new CallableFake; 29 | 30 | try { 31 | $fake->assertCalled(function (string $arg) { 32 | return $arg === 'a'; 33 | }); 34 | $this->fail(); 35 | } catch (ExpectationFailedException $e) { 36 | $this->assertStringStartsWith('The callable was never called.', $e->getMessage()); 37 | } 38 | } 39 | 40 | public function test_assert_called_with_true_after_being_called(): void 41 | { 42 | $fake = new callablefake; 43 | $fake('a'); 44 | 45 | $fake->assertCalled(function (string $arg) { 46 | return $arg === 'a'; 47 | }); 48 | } 49 | 50 | public function test_assert_called_with_false_after_being_called(): void 51 | { 52 | $fake = new CallableFake; 53 | $fake('a'); 54 | 55 | try { 56 | $fake->assertCalled(function (string $arg) { 57 | return $arg === 'x'; 58 | }); 59 | $this->fail(); 60 | } catch (ExpectationFailedException $e) { 61 | $this->assertStringStartsWith('The expected callable was not called.', $e->getMessage()); 62 | } 63 | } 64 | 65 | public function test_assert_not_called_with_true_before_being_called(): void 66 | { 67 | $fake = new CallableFake; 68 | 69 | $fake->assertNotCalled(function (string $arg) { 70 | return $arg === 'a'; 71 | }); 72 | } 73 | 74 | public function test_assert_not_called_with_false_before_being_called(): void 75 | { 76 | $fake = new CallableFake; 77 | 78 | $fake->assertNotCalled(function (string $arg) { 79 | return $arg === 'x'; 80 | }); 81 | } 82 | 83 | public function test_assert_not_called_with_true_after_being_called(): void 84 | { 85 | $fake = new CallableFake; 86 | $fake('a'); 87 | 88 | try { 89 | $fake->assertNotCalled(function (string $arg) { 90 | return $arg === 'a'; 91 | }); 92 | $this->fail(); 93 | } catch (ExpectationFailedException $e) { 94 | $this->assertStringStartsWith('An unexpected callable was called.', $e->getMessage()); 95 | } 96 | } 97 | 98 | public function test_assert_not_called_with_false_after_being_called(): void 99 | { 100 | $fake = new CallableFake; 101 | $fake('a'); 102 | 103 | $fake->assertNotCalled(function (string $arg) { 104 | return $arg === 'x'; 105 | }); 106 | } 107 | 108 | public function test_assert_called_times_with_true_and_expected_count(): void 109 | { 110 | $fake = new CallableFake; 111 | $fake('a'); 112 | 113 | $fake->assertCalledTimes(function (string $arg) { 114 | return $arg === 'a'; 115 | }, 1); 116 | } 117 | 118 | public function test_assert_called_times_with_true_and_unexpected_count(): void 119 | { 120 | $fake = new CallableFake; 121 | $fake('a'); 122 | 123 | try { 124 | $fake->assertCalledTimes(function (string $arg) { 125 | return $arg === 'a'; 126 | }, 2); 127 | $this->fail(); 128 | } catch (ExpectationFailedException $e) { 129 | $this->assertStringStartsWith('The expected callable was called 1 times instead of the expected 2 times.', $e->getMessage()); 130 | } 131 | } 132 | 133 | public function test_assert_called_times_with_false_and_expected_count(): void 134 | { 135 | $fake = new CallableFake; 136 | $fake('a'); 137 | 138 | $fake->assertCalledTimes(function (string $arg) { 139 | return $arg === 'x'; 140 | }, 0); 141 | } 142 | 143 | public function test_assert_called_times_with_false_and_unexpected_count(): void 144 | { 145 | $fake = new CallableFake; 146 | $fake('a'); 147 | 148 | try { 149 | $fake->assertCalledTimes(function (string $arg) { 150 | return $arg === 'x'; 151 | }, 2); 152 | $this->fail(); 153 | } catch (ExpectationFailedException $e) { 154 | $this->assertStringStartsWith('The expected callable was called 0 times instead of the expected 2 times.', $e->getMessage()); 155 | } 156 | } 157 | 158 | public function test_assert_times_invoked_with_unexpected_count(): void 159 | { 160 | $fake = new CallableFake; 161 | $fake(); 162 | 163 | try { 164 | $fake->assertTimesInvoked(2); 165 | $this->fail(); 166 | } catch (ExpectationFailedException $e) { 167 | $this->assertStringStartsWith('The callable was invoked 1 times instead of the expected 2 times.', $e->getMessage()); 168 | } 169 | } 170 | 171 | public function test_assert_times_invoked_with_expected_count(): void 172 | { 173 | $fake = new CallableFake; 174 | $fake(); 175 | $fake(); 176 | 177 | $fake->assertTimesInvoked(2); 178 | } 179 | 180 | public function test_assert_invoked_when_invoked(): void 181 | { 182 | $fake = new CallableFake; 183 | $fake(); 184 | 185 | $fake->assertInvoked(); 186 | } 187 | 188 | public function test_assert_invoked_when_not_invoked(): void 189 | { 190 | $fake = new CallableFake; 191 | 192 | try { 193 | $fake->assertInvoked(); 194 | $this->fail(); 195 | } catch (ExpectationFailedException $e) { 196 | $this->assertStringStartsWith('The callable was not invoked.', $e->getMessage()); 197 | } 198 | } 199 | 200 | public function test_assert_not_invoked_when_invoked(): void 201 | { 202 | $fake = new CallableFake; 203 | $fake(); 204 | 205 | try { 206 | $fake->assertNotInvoked(); 207 | $this->fail(); 208 | } catch (ExpectationFailedException $e) { 209 | $this->assertStringStartsWith('The callable was invoked.', $e->getMessage()); 210 | } 211 | } 212 | 213 | public function test_assert_not_invoked_when_not_invoked(): void 214 | { 215 | $fake = new CallableFake; 216 | 217 | $fake->assertNotInvoked(); 218 | } 219 | 220 | public function test_can_use_as_a_closure(): void 221 | { 222 | $fake = new CallableFake; 223 | 224 | (function (Closure $callback): void { 225 | $callback('a'); 226 | })($fake->asClosure()); 227 | 228 | $fake->assertCalled(function (string $arg) { 229 | return $arg === 'a'; 230 | }); 231 | } 232 | 233 | public function test_return_values_can_be_truthy(): void 234 | { 235 | $fake = new CallableFake; 236 | $fake('a'); 237 | 238 | $fake->assertCalled(function () { 239 | return 1; 240 | }); 241 | } 242 | 243 | public function test_return_values_can_be_falsy(): void 244 | { 245 | $fake = new CallableFake; 246 | 247 | $fake->assertNotCalled(function () { 248 | return 0; 249 | }); 250 | } 251 | 252 | public function test_can_specify_invocation_return_types(): void 253 | { 254 | $fake = CallableFake::withReturnResolver(function (int $index): string { 255 | return [ 256 | 0 => 'a', 257 | 1 => 'b', 258 | ][$index]; 259 | }); 260 | 261 | $this->assertSame('a', $fake(0)); 262 | $this->assertSame('b', $fake(1)); 263 | } 264 | 265 | public function test_null_default_invocation_return_type(): void 266 | { 267 | $fake = new CallableFake; 268 | 269 | $this->assertNull($fake(0)); 270 | } 271 | 272 | public function test_was_invoked_when_invoked(): void 273 | { 274 | $callable = new CallableFake; 275 | 276 | $this->assertFalse($callable->wasInvoked()); 277 | $this->assertTrue($callable->wasNotInvoked()); 278 | 279 | $callable(); 280 | 281 | $this->assertTrue($callable->wasInvoked()); 282 | $this->assertFalse($callable->wasNotInvoked()); 283 | } 284 | 285 | public function test_can_get_invocation_arguments(): void 286 | { 287 | $callable = new CallableFake; 288 | $callable('a', 'b'); 289 | $callable('c', 'd'); 290 | $callable('x', 'y'); 291 | 292 | $invocationArguments = $callable->called(function (string $arg) { 293 | return in_array($arg, ['a', 'c'], true); 294 | }); 295 | 296 | $this->assertSame([['a', 'b'], ['c', 'd']], $invocationArguments); 297 | } 298 | 299 | public function test_assert_called_index_with_expected_single_index(): void 300 | { 301 | $callable = new CallableFake; 302 | $callable('b'); 303 | $callable('a'); 304 | $callable('b'); 305 | 306 | $callable->assertCalledIndex(function (string $arg): bool { 307 | return $arg === 'a'; 308 | }, 1); 309 | } 310 | 311 | public function test_assert_called_index_with_expected_mutliple_index(): void 312 | { 313 | $callable = new CallableFake; 314 | $callable('b'); 315 | $callable('a'); 316 | $callable('b'); 317 | 318 | $callable->assertCalledIndex(function (string $arg): bool { 319 | return $arg === 'b'; 320 | }, [0, 2]); 321 | } 322 | 323 | public function test_assert_called_index_with_unexpected_single_index(): void 324 | { 325 | $callable = new CallableFake; 326 | $callable('b'); 327 | $callable('a'); 328 | $callable('b'); 329 | 330 | try { 331 | $callable->assertCalledIndex(function (string $arg): bool { 332 | return $arg === 'b'; 333 | }, 1); 334 | $this->fail(); 335 | } catch (ExpectationFailedException $e) { 336 | $this->assertStringStartsWith('The callable was not called in the expected order. Found at index: 0, 2. Expected to be found at index: 1', $e->getMessage()); 337 | } 338 | } 339 | 340 | public function test_assert_called_index_with_unexpected_multiple_index(): void 341 | { 342 | $callable = new CallableFake; 343 | $callable('b'); 344 | $callable('a'); 345 | $callable('b'); 346 | 347 | try { 348 | $callable->assertCalledIndex(function (string $arg): bool { 349 | return $arg === 'b'; 350 | }, [1, 3]); 351 | $this->fail(); 352 | } catch (ExpectationFailedException $e) { 353 | $this->assertStringStartsWith('The callable was not called in the expected order. Found at index: 0, 2. Expected to be found at index: 1, 3', $e->getMessage()); 354 | } 355 | } 356 | 357 | public function test_assert_called_index_when_never_called_with_expected_callable(): void 358 | { 359 | $callable = new CallableFake; 360 | 361 | try { 362 | $callable->assertCalledIndex(function (string $arg): bool { 363 | return $arg === 'b'; 364 | }, 1); 365 | $this->fail(); 366 | } catch (ExpectationFailedException $e) { 367 | $this->assertStringStartsWith('The callable was never called', $e->getMessage()); 368 | } 369 | } 370 | } 371 | --------------------------------------------------------------------------------