diff --git a/src/Expectation.php b/src/Expectation.php index fabc51f8..5d32c195 100644 --- a/src/Expectation.php +++ b/src/Expectation.php @@ -5,13 +5,19 @@ declare(strict_types=1); namespace Pest; use BadMethodCallException; +use Closure; +use LogicException; use Pest\Concerns\Extendable; use Pest\Concerns\RetrievesValues; use Pest\Support\Arr; +use Pest\Support\NullClosure; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Constraint\Constraint; use PHPUnit\Framework\ExpectationFailedException; +use ReflectionFunction; +use ReflectionNamedType; use SebastianBergmann\Exporter\Exporter; +use Throwable; /** * @internal @@ -749,6 +755,57 @@ final class Expectation return $this; } + /** + * Asserts that executing value throws an exception. + * + * @param string|Closure $exception string: the exception class + * Closure: first parameter = exception class + */ + public function toThrow($exception, string $exceptionMessage = null): Expectation + { + $callback = NullClosure::create(); + + if ($exception instanceof Closure) { + $callback = $exception; + $parameters = (new ReflectionFunction($exception))->getParameters(); + + if (1 !== count($parameters)) { + throw new LogicException('The "toThrow" closure must have a single parameter type-hinted as the class string'); + } + + if (!($type = $parameters[0]->getType()) instanceof ReflectionNamedType) { + throw new LogicException('The "toThrow" closure\'s parameter must be type-hinted as the class string'); + } + + $exception = $type->getName(); + } + + try { + ($this->value)(); + } catch (Throwable $e) { + if (!class_exists($exception)) { + Assert::assertStringContainsString($exception, $e->getMessage()); + + return $this; + } + + if ($exceptionMessage) { + Assert::assertStringContainsString($exceptionMessage, $e->getMessage()); + } + + Assert::assertInstanceOf($exception, $e); + $callback($e); + + return $this; + } + + if (!class_exists($exception)) { + throw new ExpectationFailedException("Exception with message \"{$exception}\" not thrown."); + } + + throw new ExpectationFailedException("Exception \"{$exception}\" not thrown."); + } + /** * Exports the given value. * diff --git a/tests/Features/Expect/toThrow.php b/tests/Features/Expect/toThrow.php new file mode 100644 index 00000000..290003eb --- /dev/null +++ b/tests/Features/Expect/toThrow.php @@ -0,0 +1,60 @@ +toThrow(RuntimeException::class); + expect(function () { throw new RuntimeException(); })->toThrow(Exception::class); + expect(function () { throw new RuntimeException(); })->toThrow(function (RuntimeException $e) {}); + expect(function () { throw new RuntimeException('actual message'); })->toThrow(function (Exception $e) { + expect($e->getMessage())->toBe('actual message'); + }); + expect(function () {})->not->toThrow(Exception::class); + expect(function () { throw new RuntimeException('actual message'); })->toThrow('actual message'); + expect(function () { throw new Exception(); })->not->toThrow(RuntimeException::class); + expect(function () { throw new RuntimeException('actual message'); })->toThrow(RuntimeException::class, 'actual message'); + expect(function () { throw new RuntimeException('actual message'); })->toThrow(function (RuntimeException $e) {}, 'actual message'); +}); + +test('failures 1', function () { + expect(function () {})->toThrow(RuntimeException::class); +})->throws(ExpectationFailedException::class, 'Exception "' . RuntimeException::class . '" not thrown.'); + +test('failures 2', function () { + expect(function () {})->toThrow(function (RuntimeException $e) {}); +})->throws(ExpectationFailedException::class, 'Exception "' . RuntimeException::class . '" not thrown.'); + +test('failures 3', function () { + expect(function () { throw new Exception(); })->toThrow(function (RuntimeException $e) {}); +})->throws(ExpectationFailedException::class, 'Failed asserting that Exception Object'); + +test('failures 4', function () { + expect(function () { throw new Exception('actual message'); }) + ->toThrow(function (Exception $e) { + expect($e->getMessage())->toBe('expected message'); + }); +})->throws(ExpectationFailedException::class, 'Failed asserting that two strings are identical'); + +test('failures 5', function () { + expect(function () { throw new Exception('actual message'); })->toThrow('expected message'); +})->throws(ExpectationFailedException::class, 'Failed asserting that \'actual message\' contains "expected message".'); + +test('failures 6', function () { + expect(function () {})->toThrow('actual message'); +})->throws(ExpectationFailedException::class, 'Exception with message "actual message" not thrown'); + +test('failures 7', function () { + expect(function () { throw new RuntimeException('actual message'); })->toThrow(RuntimeException::class, 'expected message'); +})->throws(ExpectationFailedException::class); + +test('not failures', function () { + expect(function () { throw new RuntimeException(); })->not->toThrow(RuntimeException::class); +})->throws(ExpectationFailedException::class); + +test('closure missing parameter', function () { + expect(function () {})->toThrow(function () {}); +})->throws(LogicException::class, 'The "toThrow" closure must have a single parameter type-hinted as the class string'); + +test('closure missing type-hint', function () { + expect(function () {})->toThrow(function ($e) {}); +})->throws(LogicException::class, 'The "toThrow" closure\'s parameter must be type-hinted as the class string');