From 2c919eaea5bed980541d1f45aeff78e91ac4a2c3 Mon Sep 17 00:00:00 2001 From: Nick Harin Date: Thu, 10 Sep 2026 21:55:14 +0500 Subject: [PATCH] Add doesNotThrow() and an inspect callback for throws() --- CHANGELOG.md | 11 +++++ README.md | 2 + src/Traits/ExceptionAssertions.php | 43 ++++++++++++++++++- .../Asserts/DoesNotThrow/DoesNotThrowTest.php | 43 +++++++++++++++++++ .../Asserts/Throws/ThrowsTest.php | 28 ++++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 tests/FluentAssertions/Asserts/DoesNotThrow/DoesNotThrowTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e5727..f7efec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to this package are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [12.12.0] - 2026-09-10 + +### Added + +- `doesNotThrow()`, the counterpart of `throws()`: the subject is called and the assertion + fails if anything is thrown. It replaces the `$call(); $this->addToAssertionCount(1);` + idiom for "this input is accepted". +- `throws()` takes an optional `inspect` callback that receives the thrown exception, so a + status code or a payload can be checked with ordinary `fact()` calls instead of a + try/catch block. + ## [12.11.0] - 2026-09-09 ### Added diff --git a/README.md b/README.md index e624ba3..ab151d3 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,8 @@ Pass a message substring to also assert on the exception message. fact(fn () => throw new RuntimeException('boom'))->throws(RuntimeException::class); // Passes fact(fn () => $service->run())->throws(DomainException::class, 'invalid'); // Passes if message contains "invalid" fact(fn () => 42)->throws(RuntimeException::class); // Fails — nothing thrown +fact(fn () => $client->get())->throws(HttpException::class, inspect: fn (HttpException $e) => fact($e->status)->is(404)); // Assert on the exception itself +fact(fn () => $policy->check($certificate))->doesNotThrow(); // Passes — the call completes ``` ### Date/time assertions diff --git a/src/Traits/ExceptionAssertions.php b/src/Traits/ExceptionAssertions.php index 6d3ae16..8e7f31b 100644 --- a/src/Traits/ExceptionAssertions.php +++ b/src/Traits/ExceptionAssertions.php @@ -16,15 +16,19 @@ trait ExceptionAssertions * Asserts that calling the subject (a callable) throws the given exception. * * Pass a non-empty `$message` to also assert that the thrown message contains - * that substring. + * that substring. Pass `$inspect` to assert on the exception itself — it + * receives the thrown instance, so a status code or a payload can be checked + * with ordinary fact() calls. * * Example usage: * fact(fn () => throw new RuntimeException('boom'))->throws(RuntimeException::class); * fact(fn () => $service->run())->throws(DomainException::class, 'invalid'); + * fact(fn () => $client->get())->throws(HttpException::class, inspect: fn (HttpException $e) => fact($e->status)->is(404)); * * @param class-string $exception + * @param (callable(Throwable): void)|null $inspect */ - public function throws(string $exception, string $message = ''): self + public function throws(string $exception, string $message = '', ?callable $inspect = null): self { $subject = $this->variable; @@ -41,9 +45,44 @@ public function throws(string $exception, string $message = ''): self Assert::assertStringContainsString($message, $thrown->getMessage()); } + if ($inspect !== null) { + $inspect($thrown); + } + return $this; } Assert::fail(sprintf('Failed asserting that "%s" was thrown.', $exception)); } + + /** + * Asserts that calling the subject (a callable) completes without throwing. + * + * Example usage: + * fact(fn () => $policy->check($certificate))->doesNotThrow(); + */ + public function doesNotThrow(string $message = ''): self + { + $subject = $this->variable; + + if (! is_callable($subject)) { + Assert::fail('doesNotThrow() expects the subject to be a callable.'); + } + + try { + $subject(); + } catch (Throwable $thrown) { + Assert::fail(sprintf( + '%sFailed asserting that nothing was thrown, got %s: %s', + $message === '' ? '' : $message . "\n", + $thrown::class, + $thrown->getMessage(), + )); + } + + // Counted as an assertion so a test consisting of this check alone is not risky. + Assert::assertThat(true, Assert::isTrue(), $message); + + return $this; + } } diff --git a/tests/FluentAssertions/Asserts/DoesNotThrow/DoesNotThrowTest.php b/tests/FluentAssertions/Asserts/DoesNotThrow/DoesNotThrowTest.php new file mode 100644 index 0000000..9b18658 --- /dev/null +++ b/tests/FluentAssertions/Asserts/DoesNotThrow/DoesNotThrowTest.php @@ -0,0 +1,43 @@ + 42)->doesNotThrow(); + + // assert + $this->correctAssertionExecuted(); + } + + public function testFailsWhenTheSubjectThrows(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact(static fn () => throw new RuntimeException('boom'))->doesNotThrow(); + } + + public function testFailsWhenSubjectIsNotCallable(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act + fact('not callable')->doesNotThrow(); + } +} diff --git a/tests/FluentAssertions/Asserts/Throws/ThrowsTest.php b/tests/FluentAssertions/Asserts/Throws/ThrowsTest.php index de6b140..96b1fb3 100644 --- a/tests/FluentAssertions/Asserts/Throws/ThrowsTest.php +++ b/tests/FluentAssertions/Asserts/Throws/ThrowsTest.php @@ -5,6 +5,7 @@ namespace K2gl\PHPUnitFluentAssertions\Tests\FluentAssertions\Asserts\Throws; use DomainException; +use LogicException; use K2gl\PHPUnitFluentAssertions\FluentAssertions; use K2gl\PHPUnitFluentAssertions\Tests\FluentAssertions\FluentAssertionsTestCase; use PHPUnit\Framework\Attributes\CoversMethod; @@ -34,6 +35,33 @@ public function testThrowsWithMessageSubstring(): void $this->correctAssertionsExecuted(expected: 2); } + public function testInspectReceivesTheThrownException(): void + { + // arrange + $seen = null; + + // act + fact(static fn () => throw new DomainException('boom', 42)) + ->throws(DomainException::class, inspect: static function (DomainException $e) use (&$seen): void { + $seen = $e; + fact($e->getCode())->is(42); + }); + + // assert: instance-of, the code check inside the callback + $this->correctAssertionsExecuted(expected: 2); + fact($seen)->instanceOf(DomainException::class); + } + + public function testInspectRunsAfterTheTypeCheck(): void + { + // assert + $this->incorrectAssertionExpected(); + + // act: the callback is never reached for the wrong type + fact(static fn () => throw new RuntimeException('boom')) + ->throws(DomainException::class, inspect: static fn (): never => throw new LogicException('unreachable')); + } + public function testFailsWhenWrongExceptionType(): void { // assert