diff --git a/src/Expectation.php b/src/Expectation.php index 253b1e1e..b229416a 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -154,9 +154,10 @@ final class Expectation throw new BadMethodCallException('Expectation value is not iterable.'); } - $value = is_array($this->value) ? $this->value : iterator_to_array($this->value); - $keys = array_keys($value); - $values = array_values($value); + $value = is_array($this->value) ? $this->value : iterator_to_array($this->value); + $keys = array_keys($value); + $values = array_values($value); + $callbacksCount = count($callbacks); $index = 0; @@ -165,6 +166,10 @@ final class Expectation $index = $index < count($values) - 1 ? $index + 1 : 0; } + if ($callbacksCount > count($values)) { + Assert::assertLessThanOrEqual(count($value), count($callbacks)); + } + foreach ($values as $key => $item) { if (is_callable($callbacks[$key])) { call_user_func($callbacks[$key], new self($item), new self($keys[$key])); @@ -177,6 +182,88 @@ final class Expectation return $this; } + /** + * If the subject matches one of the given "expressions", the expression callback will run. + * + * @template TMatchSubject of array-key + * + * @param callable(): TMatchSubject|TMatchSubject $subject + * @param array): mixed)|TValue> $expressions + */ + public function match($subject, array $expressions): Expectation + { + $subject = is_callable($subject) + ? $subject + : function () use ($subject) { + return $subject; + }; + + $subject = $subject(); + + $matched = false; + + foreach ($expressions as $key => $callback) { + if ($subject != $key) { + continue; + } + + $matched = true; + + if (is_callable($callback)) { + $callback(new self($this->value)); + continue; + } + + $this->and($this->value)->toEqual($callback); + + break; + } + + if ($matched === false) { + throw new ExpectationFailedException('Unhandled match value.'); + } + + return $this; + } + + /** + * Apply the callback if the given "condition" is falsy. + * + * @param (callable(): bool)|bool $condition + * @param callable(Expectation): mixed $callback + */ + public function unless($condition, callable $callback): Expectation + { + $condition = is_callable($condition) + ? $condition + : static function () use ($condition): mixed { + return $condition; + }; + + return $this->when(!$condition(), $callback); + } + + /** + * Apply the callback if the given "condition" is truthy. + * + * @param (callable(): bool)|bool $condition + * @param callable(Expectation): mixed $callback + */ + public function when($condition, callable $callback): Expectation + { + $condition = is_callable($condition) + ? $condition + : static function () use ($condition): mixed { + return $condition; + }; + + if ($condition()) { + $callback($this->and($this->value)); + } + + return $this; + } + /** * Asserts that two variables have the same type and * value. Used on objects, it asserts that two diff --git a/src/PendingObjects/TestCall.php b/src/PendingObjects/TestCall.php index be839bff..287ca7a9 100644 --- a/src/PendingObjects/TestCall.php +++ b/src/PendingObjects/TestCall.php @@ -78,6 +78,26 @@ final class TestCall return $this; } + /** + * Asserts that the test throws the given `$exceptionClass` when called if the given condition is true. + * + * @param (callable(): bool)|bool $condition + */ + public function throwsIf($condition, string $exception, string $exceptionMessage = null): TestCall + { + $condition = is_callable($condition) + ? $condition + : static function () use ($condition): mixed { + return $condition; + }; + + if ($condition()) { + return $this->throws($exception, $exceptionMessage); + } + + return $this; + } + /** * Runs the current test multiple times with * each item of the given `iterable`. diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index d7a866b9..addf3536 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -107,6 +107,11 @@ ✓ it catch exceptions ✓ it catch exceptions and messages ✓ it can just define the message + ✓ it not catch exceptions if given condition is false + ✓ it catch exceptions if given condition is true + ✓ it catch exceptions and messages if given condition is true + ✓ it can just define the message if given condition is true + ✓ it can just define the message if given condition is 1 PASS Tests\Features\Expect\HigherOrder\methods ✓ it can access methods @@ -158,6 +163,17 @@ ✓ it properly parses json string ✓ fails with broken json string + PASS Tests\Features\Expect\matchExpectation + ✓ it pass + ✓ it failures + ✓ it runs with truthy + ✓ it runs with falsy + ✓ it runs with truthy closure condition + ✓ it runs with falsy closure condition + ✓ it can be passed non-callable values + ✓ it fails with unhandled match + ✓ it can be used in higher order tests + PASS Tests\Features\Expect\not ✓ not property calls @@ -168,7 +184,7 @@ ✓ an exception is thrown if the the type is not iterable ✓ allows for sequences of checks to be run on iterable data ✓ loops back to the start if it runs out of sequence items - ✓ it works if the number of items in the iterable is smaller than the number of expectations + ✓ fails if the number of iterable items is greater than the number of expectations ✓ it works with associative arrays ✓ it can be passed non-callable values ✓ it can be passed a mixture of value types @@ -481,6 +497,24 @@ ✓ closure missing parameter ✓ closure missing type-hint + PASS Tests\Features\Expect\unless + ✓ it pass + ✓ it failures + ✓ it runs with truthy + ✓ it skips with falsy + ✓ it runs with truthy closure condition + ✓ it skips with falsy closure condition + ✓ it can be used in higher order tests + + PASS Tests\Features\Expect\when + ✓ it pass + ✓ it failures + ✓ it runs with truthy + ✓ it skips with falsy + ✓ it runs with truthy closure condition + ✓ it skips with falsy closure condition + ✓ it can be used in higher order tests + PASS Tests\Features\Helpers ✓ it can set/get properties on $this ✓ it throws error if property do not exist @@ -623,10 +657,6 @@ ✓ it show the actual dataset of multiple non-named datasets in their description ✓ it show the correct description for mixed named and not-named datasets - PASS Tests\Unit\Plugins\Context - ✓ environment is set to CI when --ci option is used - ✓ environment is set to Local when --ci option is not used - PASS Tests\Unit\Plugins\Version ✓ it outputs the version when --version is used ✓ it do not outputs version when --version is not used @@ -652,7 +682,6 @@ ✓ it alerts users about tests with arguments but no input ✓ it can return an array of all test suite filenames ✓ it can filter the test suite filenames to those with the only method - ✓ it does not filter the test suite filenames to those with the only method when working in CI pipeline PASS Tests\Visual\Help ✓ visual snapshot of help command output @@ -686,5 +715,5 @@ ✓ it is a test ✓ it uses correct parent class - Tests: 4 incompleted, 9 skipped, 450 passed + Tests: 4 incompleted, 9 skipped, 475 passed \ No newline at end of file diff --git a/tests/Features/Exceptions.php b/tests/Features/Exceptions.php index 37eaaeb9..9970c2a9 100644 --- a/tests/Features/Exceptions.php +++ b/tests/Features/Exceptions.php @@ -17,3 +17,23 @@ it('catch exceptions and messages', function () { it('can just define the message', function () { throw new Exception('Something bad happened'); })->throws('Something bad happened'); + +it('not catch exceptions if given condition is false', function () { + $this->assertTrue(true); +})->throwsIf(false, Exception::class); + +it('catch exceptions if given condition is true', function () { + throw new Exception('Something bad happened'); +})->throwsIf(function () { return true; }, Exception::class); + +it('catch exceptions and messages if given condition is true', function () { + throw new Exception('Something bad happened'); +})->throwsIf(true, Exception::class, 'Something bad happened'); + +it('can just define the message if given condition is true', function () { + throw new Exception('Something bad happened'); +})->throwsIf(true, 'Something bad happened'); + +it('can just define the message if given condition is 1', function () { + throw new Exception('Something bad happened'); +})->throwsIf(1, 'Something bad happened'); diff --git a/tests/Features/Expect/matchExpectation.php b/tests/Features/Expect/matchExpectation.php new file mode 100644 index 00000000..2bea1be5 --- /dev/null +++ b/tests/Features/Expect/matchExpectation.php @@ -0,0 +1,148 @@ +matched = null; +}); + +it('pass', function () { + expect('baz') + ->match('foo', [ + 'bar' => function ($value) { + $this->matched = 'bar'; + + return $value->toEqual('bar'); + }, + 'foo' => function ($value) { + $this->matched = 'baz'; + + return $value->toEqual('baz'); + }, + ] + ) + ->toEqual($this->matched); + + expect(static::getCount())->toBe(2); +}); + +it('failures', function () { + expect(true) + ->match('foo', [ + 'bar' => function ($value) { + return $value->toBeTrue(); + }, + 'foo' => function ($value) { + return $value->toBeFalse(); + }, + ] + ); +})->throws(ExpectationFailedException::class, 'true is false'); + +it('runs with truthy', function () { + expect('foo') + ->match(1, [ + 'bar' => function ($value) { + $this->matched = 'bar'; + + return $value->toEqual('bar'); + }, + true => function ($value) { + $this->matched = 'foo'; + + return $value->toEqual('foo'); + }, + ] + ) + ->toEqual($this->matched); + + expect(static::getCount())->toBe(2); +}); + +it('runs with falsy', function () { + expect('foo') + ->match(false, [ + 'bar' => function ($value) { + $this->matched = 'bar'; + + return $value->toEqual('bar'); + }, + false => function ($value) { + $this->matched = 'foo'; + + return $value->toEqual('foo'); + }, + ] + ) + ->toEqual($this->matched); + + expect(static::getCount())->toBe(2); +}); + +it('runs with truthy closure condition', function () { + expect('foo') + ->match( + function () { return '1'; }, [ + 'bar' => function ($value) { + $this->matched = 'bar'; + + return $value->toEqual('bar'); + }, + true => function ($value) { + $this->matched = 'foo'; + + return $value->toEqual('foo'); + }, + ] + ) + ->toEqual($this->matched); + + expect(static::getCount())->toBe(2); +}); + +it('runs with falsy closure condition', function () { + expect('foo') + ->match( + function () { return '0'; }, [ + 'bar' => function ($value) { + $this->matched = 'bar'; + + return $value->toEqual('bar'); + }, + false => function ($value) { + $this->matched = 'foo'; + + return $value->toEqual('foo'); + }, + ] + ) + ->toEqual($this->matched); + + expect(static::getCount())->toBe(2); +}); + +it('can be passed non-callable values', function () { + expect('foo') + ->match('pest', [ + 'bar' => 'foo', + 'pest' => 'baz', + ] + ); +})->throws(ExpectationFailedException::class, 'two strings are equal'); + +it('fails with unhandled match', function () { + expect('foo')->match('bar', []); +})->throws(ExpectationFailedException::class, 'Unhandled match value.'); + +it('can be used in higher order tests') + ->expect(true) + ->match( + function () { return true; }, [ + false => function ($value) { + return $value->toBeFalse(); + }, + true => function ($value) { + return $value->toBeTrue(); + }, + ] + ); diff --git a/tests/Features/Expect/sequence.php b/tests/Features/Expect/sequence.php index b4f82cb5..4f11b3a3 100644 --- a/tests/Features/Expect/sequence.php +++ b/tests/Features/Expect/sequence.php @@ -1,5 +1,7 @@ each->sequence(); })->throws(BadMethodCallException::class, 'Expectation value is not iterable.'); @@ -26,16 +28,14 @@ test('loops back to the start if it runs out of sequence items', function () { expect(static::getCount())->toBe(16); }); -test('it works if the number of items in the iterable is smaller than the number of expectations', function () { +test('fails if the number of iterable items is greater than the number of expectations', function () { expect([1, 2]) ->sequence( function ($expectation) { $expectation->toBeInt()->toEqual(1); }, function ($expectation) { $expectation->toBeInt()->toEqual(2); }, function ($expectation) { $expectation->toBeInt()->toEqual(3); }, ); - - expect(static::getCount())->toBe(4); -}); +})->throws(ExpectationFailedException::class); test('it works with associative arrays', function () { expect(['foo' => 'bar', 'baz' => 'boom']) diff --git a/tests/Features/Expect/unless.php b/tests/Features/Expect/unless.php new file mode 100644 index 00000000..9f11c7ef --- /dev/null +++ b/tests/Features/Expect/unless.php @@ -0,0 +1,101 @@ +unlessObject = new stdClass(); + $this->unlessObject->trueValue = true; + $this->unlessObject->foo = 'foo'; +}); + +it('pass', function () { + expect('foo') + ->unless( + true, + function ($value) { + return $value->toEqual('bar'); + } + ) + ->toEqual('foo'); + + expect(static::getCount())->toBe(1); +}); + +it('failures', function () { + expect('foo') + ->unless( + false, + function ($value) { + return $value->toBeTrue(); + } + ) + ->toEqual('foo'); +})->throws(ExpectationFailedException::class, 'is true'); + +it('runs with truthy', function () { + expect($this->unlessObject) + ->unless( + 0, + function ($value) { + return $value->trueValue->toBeTrue(); + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(2); +}); + +it('skips with falsy', function () { + expect($this->unlessObject) + ->unless( + 1, + function ($value) { + return $value->trueValue->toBeFalse(); // fails + } + ) + ->unless( + true, + function ($value) { + return $value->trueValue->toBeFalse(); // fails + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(1); +}); + +it('runs with truthy closure condition', function () { + expect($this->unlessObject) + ->unless( + function () { return '0'; }, + function ($value) { + return $value->trueValue->toBeTrue(); + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(2); +}); + +it('skips with falsy closure condition', function () { + expect($this->unlessObject) + ->unless( + function () { return '1'; }, + function ($value) { + return $value->trueValue->toBeFalse(); // fails + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(1); +}); + +it('can be used in higher order tests') + ->expect(true) + ->unless( + function () { return false; }, + function ($value) { + return $value->toBeFalse(); + } + ) + ->throws(ExpectationFailedException::class, 'true is false'); diff --git a/tests/Features/Expect/when.php b/tests/Features/Expect/when.php new file mode 100644 index 00000000..db9fa4f1 --- /dev/null +++ b/tests/Features/Expect/when.php @@ -0,0 +1,101 @@ +whenObject = new stdClass(); + $this->whenObject->trueValue = true; + $this->whenObject->foo = 'foo'; +}); + +it('pass', function () { + expect('foo') + ->when( + true, + function ($value) { + return $value->toEqual('foo'); + } + ) + ->toEqual('foo'); + + expect(static::getCount())->toBe(2); +}); + +it('failures', function () { + expect('foo') + ->when( + true, + function ($value) { + return $value->toBeTrue(); + } + ) + ->toEqual('foo'); +})->throws(ExpectationFailedException::class, 'is true'); + +it('runs with truthy', function () { + expect($this->whenObject) + ->when( + 1, + function ($value) { + return $value->trueValue->toBeTrue(); + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(2); +}); + +it('skips with falsy', function () { + expect($this->whenObject) + ->when( + 0, + function ($value) { + return $value->trueValue->toBeFalse(); // fails + } + ) + ->when( + false, + function ($value) { + return $value->trueValue->toBeFalse(); // fails + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(1); +}); + +it('runs with truthy closure condition', function () { + expect($this->whenObject) + ->when( + function () { return '1'; }, + function ($value) { + return $value->trueValue->toBeTrue(); + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(2); +}); + +it('skips with falsy closure condition', function () { + expect($this->whenObject) + ->when( + function () { return '0'; }, + function ($value) { + return $value->trueValue->toBeFalse(); // fails + } + ) + ->foo->toEqual('foo'); + + expect(static::getCount())->toBe(1); +}); + +it('can be used in higher order tests') + ->expect(false) + ->when( + function () { return true; }, + function ($value) { + return $value->toBeTrue(); + } + ) + ->throws(ExpectationFailedException::class, 'false is true');