Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions src/Traits/ExceptionAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Throwable> $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;

Expand All @@ -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;
}
}
43 changes: 43 additions & 0 deletions tests/FluentAssertions/Asserts/DoesNotThrow/DoesNotThrowTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace K2gl\PHPUnitFluentAssertions\Tests\FluentAssertions\Asserts\DoesNotThrow;

use K2gl\PHPUnitFluentAssertions\FluentAssertions;
use K2gl\PHPUnitFluentAssertions\Tests\FluentAssertions\FluentAssertionsTestCase;
use PHPUnit\Framework\Attributes\CoversMethod;
use RuntimeException;

use function K2gl\PHPUnitFluentAssertions\fact;

#[CoversMethod(className: FluentAssertions::class, methodName: 'doesNotThrow')]
final class DoesNotThrowTest extends FluentAssertionsTestCase
{
public function testPassesWhenNothingIsThrown(): void
{
// act
fact(static fn () => 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();
}
}
28 changes: 28 additions & 0 deletions tests/FluentAssertions/Asserts/Throws/ThrowsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down