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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ This file outlines the requirements and best practices for adding new assertion
- `BooleanAssertions`: True/false assertions.
- `NullAssertions`: Null checks.
- `NumericAssertions`: Numeric comparisons (e.g., isPositive, isBetween).
- `StringAssertions`: String operations (e.g., startsWith, hasLength).
- `StringAssertions`: String operations (e.g., startsWith, hasLength, ulid).
- `JsonAssertions`: JSON documents (e.g., matchesJson, containsJson, jsonPath).
- `ArrayAssertions`: Array checks (e.g., contains, hasSize).
- `TypeCheckingAssertions`: Type validation (e.g., isInt, instanceOf, hasProperty).
- `SpecialAssertions`: Specialized checks (e.g., ULID).
- `ExceptionAssertions`: Thrown exceptions (throws).
- `DateTimeAssertions`: Date/time comparisons (e.g., isBefore, isSameDate).
- `EnumAssertions`: Native enum cases (e.g., isEnum, hasValue).
- Traits are imported into `FluentAssertions` class using `use` statements.
- Place new methods in the appropriate trait based on functionality.

Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,44 @@ 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.11.0] - 2026-09-09

### Added

- Numeric assertions `isCloseTo()` / `notCloseTo()`, comparing within a tolerance that
defaults to `PHP_FLOAT_EPSILON` — enough for accumulated float noise, while money and
percentages pass a delta of their own.
- The non-strict comparisons `isGreaterThanOrEqual()` / `isLowerThanOrEqual()`, plus
`isNotZero()`, `isFinite()` and `isNan()`.
- JSON subset matching with `containsJson()` / `notContainsJson()`: only the keys named in
the expectation are compared, so volatile fields in a response no longer have to be
spelled out. Lists are matched by membership — an expected element may sit anywhere in
the document, since an index is not the identity of an element the way a key is the
identity of a value.
- JSON path assertions `jsonPath()`, `hasJsonPath()` and `notHasJsonPath()`, addressing a
single value by a dot-separated path (`data.0.id`).
- `matchesJsonFile()`, the fixture-file counterpart of `matchesJson()`.
- `matchesJson()` and `notMatchesJson()` now also accept the expectation as an array or
object, which keeps `json_encode()` out of the test.
- The PHPStan extension narrows the subject of every JSON assertion to `string`, of
`isCloseTo()` / `notCloseTo()` / `isFinite()` to `int|float`, and of `isNan()` to `float`.

### Changed

- `arrayContainsAssociativeArray()` now answers the subset question the same way
`containsJson()` does. Inside a list an expected element may sit at any position instead
of having to line up by index, so assertions that used to fail on reordered data now
pass. It also tells a missing key apart from a key holding `null`: expecting
`['parent' => null]` against `['id' => 1]` used to pass and now fails, which turns tests
that were asserting nothing red.
- The JSON assertions moved from `StringAssertions` into a `JsonAssertions` trait. The
public API is unchanged; only code using the trait directly is affected.

### Fixed

- `isJson()` no longer calls `json_validate()`, which only exists on PHP 8.3+ while the
package supports 8.1. It now validates the same way the other JSON assertions do.

## [12.10.0] - 2026-07-07

### Added
Expand Down Expand Up @@ -52,6 +90,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Fixed latent static-analysis errors and ran the CI matrix across PHP 8.2–8.5
(with a PHP 8.1 source/static-analysis job).

[12.11.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.10.0...12.11.0
[12.10.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.9.0...12.10.0
[12.9.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.8.0...12.9.0
[12.8.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.7.0...12.8.0
[12.7.0]: https://github.com/k2gl/phpunit-fluent-assertions/compare/12.6.0...12.7.0
Expand Down
82 changes: 76 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ fact([1, 2, 3])->notCount(3); // Fails
fact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passes
fact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Fails

fact(['tags' => ['a', 'b']])->arrayContainsAssociativeArray(['tags' => ['b']]); // Passes — position does not matter
fact(['tags' => ['a']])->arrayContainsAssociativeArray(['tags' => ['a', 'a']]); // Fails — only one to go around
fact(['id' => 1])->arrayContainsAssociativeArray(['parent' => null]); // Fails — there is no such key

fact(['a' => 1])->arrayHasKey('a'); // Passes
fact(['a' => 1])->arrayHasKey('b'); // Fails

Expand Down Expand Up @@ -137,6 +141,33 @@ fact(1)->isZero(); // Fails

fact(5)->isBetween(1, 10); // Passes
fact(15)->isBetween(1, 10); // Fails

fact(10)->isGreaterThanOrEqual(10); // Passes
fact(5)->isGreaterThanOrEqual(10); // Fails

fact(5)->isLowerThanOrEqual(5); // Passes
fact(10)->isLowerThanOrEqual(5); // Fails

fact(1)->isNotZero(); // Passes
fact(0.0)->isNotZero(); // Fails

fact(1.5)->isFinite(); // Passes
fact(INF)->isFinite(); // Fails

fact(sqrt(-1))->isNan(); // Passes
fact(1.0)->isNan(); // Fails
```

Comparing floats with a tolerance — the delta defaults to `PHP_FLOAT_EPSILON`, which
covers accumulated arithmetic noise. It is an absolute tolerance, so money, percentages
and values far from `1.0` want a delta of their own.
```php
fact(0.1 + 0.2)->isCloseTo(0.3); // Passes — strict comparison would not
fact(99.985)->isCloseTo(99.99, 0.005); // Passes
fact(99.9)->isCloseTo(99.99, 0.005); // Fails

fact(1.0)->notCloseTo(2.0); // Passes
fact(0.1 + 0.2)->notCloseTo(0.3); // Fails
```

### String assertions
Expand Down Expand Up @@ -174,20 +205,58 @@ fact('hello')->isEmptyString(); // Fails
fact('hello')->isNotEmptyString(); // Passes
fact('')->isNotEmptyString(); // Fails

fact('user@example.com')->isValidEmail(); // Passes
fact('invalid-email')->isValidEmail(); // Fails

fact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID)
fact('invalid-ulid')->ulid(); // Fails
```

### JSON assertions
The subject is a JSON string. Documents are compared by value, so object key order and
formatting never matter; array element order does. An expectation can be written as JSON
text or as the array/object it would encode to, which keeps `json_encode()` out of the test.
```php
fact('{"key": "value"}')->isJson(); // Passes
fact('invalid json')->isJson(); // Fails

fact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes (key order ignored)
fact('{"a":1,"b":2}')->matchesJson(['b' => 2, 'a' => 1]); // Passes
fact('{"a":1}')->matchesJson('{"a":2}'); // Fails

fact('{"a":1}')->notMatchesJson('{"a":2}'); // Passes
fact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Fails

fact('user@example.com')->isValidEmail(); // Passes
fact('invalid-email')->isValidEmail(); // Fails
fact($canonicalJson)->matchesJsonFile(__DIR__ . '/fixtures/bundle.json'); // Passes when equal
```

fact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID)
fact('invalid-ulid')->ulid(); // Fails
`containsJson()` matches a subset. In an object, the keys you name must be present with a
matching value and everything else is ignored — which is what makes it usable against
responses carrying volatile fields. In a list, each expected element must match *some*
element of the document, at any position, and two expectations never claim the same one.
Scalars are compared strictly.
```php
fact('{"id":42,"created_at":"2026-01-01"}')->containsJson(['id' => 42]); // Passes
fact('{"data":{"id":42,"role":"admin"}}')->containsJson(['data' => ['id' => 42]]); // Passes
fact('{"items":[{"id":1},{"id":2}]}')->containsJson(['items' => [['id' => 2]]]); // Passes
fact('{"tags":["b","a"]}')->containsJson(['tags' => ['a']]); // Passes — position is not identity
fact('{"tags":["a"]}')->containsJson(['tags' => ['a', 'a']]); // Fails — only one to go around
fact('{"id":42}')->containsJson(['id' => '42']); // Fails — strict comparison

fact('{"id":42}')->notContainsJson(['id' => 43]); // Passes
fact('{"id":42}')->notContainsJson(['id' => 42]); // Fails
```

Element order and the exact shape of a list are `matchesJson()`'s job, not this one's.

Single values are reachable by a dot-separated path; segments address object keys and list
positions alike. Keys that contain a dot are not addressable this way.
```php
fact('{"data":[{"id":42}]}')->jsonPath('data.0.id', 42); // Passes
fact('{"meta":{"total":3}}')->jsonPath('meta.total', '3'); // Fails — strict comparison

fact('{"data":{"id":42}}')->hasJsonPath('data.id'); // Passes
fact('{"data":{"id":42}}')->notHasJsonPath('data.name'); // Passes
```

### Type Checking assertions
Expand Down Expand Up @@ -330,8 +399,9 @@ includes:

Narrowing is applied for `notNull()`, `null()`, `true()`, `notTrue()`, `false()`,
`notFalse()`, `instanceOf()`, `notInstanceOf()`, `is()`, the type checks `isString()`,
`isInt()`, `isFloat()`, `isBool()`, `isArray()`, `isCallable()` and `isResource()`, and the JSON
assertions `isJson()`, `matchesJson()` and `notMatchesJson()` (subject narrowed to `string`). Loose
`isInt()`, `isFloat()`, `isBool()`, `isArray()`, `isCallable()` and `isResource()`, every JSON
assertion (subject narrowed to `string`), and the numeric assertions that guard their subject —
`isCloseTo()`, `notCloseTo()` and `isFinite()` (to `int|float`) and `isNan()` (to `float`). Loose
or negated assertions such as `equals()` (loose `==`) and `not()` would not narrow soundly, so they
are intentionally left out and leave the type unchanged.

Expand Down
2 changes: 2 additions & 0 deletions src/FluentAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use K2gl\PHPUnitFluentAssertions\Traits\DateTimeAssertions;
use K2gl\PHPUnitFluentAssertions\Traits\EnumAssertions;
use K2gl\PHPUnitFluentAssertions\Traits\ExceptionAssertions;
use K2gl\PHPUnitFluentAssertions\Traits\JsonAssertions;
use K2gl\PHPUnitFluentAssertions\Traits\NullAssertions;
use K2gl\PHPUnitFluentAssertions\Traits\NumericAssertions;
use K2gl\PHPUnitFluentAssertions\Traits\StringAssertions;
Expand All @@ -22,6 +23,7 @@ class FluentAssertions
use NullAssertions;
use NumericAssertions;
use StringAssertions;
use JsonAssertions;
use ArrayAssertions;
use TypeCheckingAssertions;
use ExceptionAssertions;
Expand Down
24 changes: 21 additions & 3 deletions src/PHPStan/FluentAssertionsTypeSpecifyingExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use K2gl\PHPUnitFluentAssertions\FluentAssertions;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\BinaryOp\BooleanOr;
use PhpParser\Node\Expr\BinaryOp\Identical;
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
use PhpParser\Node\Expr\BooleanNot;
Expand Down Expand Up @@ -195,9 +196,21 @@ private static function getResolvers(): array
'isresource' => static fn (Expr $s): Expr => self::isType('is_resource', $s),

// JSON assertions can only pass on a (valid JSON) string subject.
'isjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'matchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'notmatchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'isjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'matchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'notmatchesjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'matchesjsonfile' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'containsjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'notcontainsjson' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'jsonpath' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'hasjsonpath' => static fn (Expr $s): Expr => self::isType('is_string', $s),
'nothasjsonpath' => static fn (Expr $s): Expr => self::isType('is_string', $s),

// Numeric assertions that guard the subject type before comparing.
'iscloseto' => static fn (Expr $s): Expr => self::isNumeric($s),
'notcloseto' => static fn (Expr $s): Expr => self::isNumeric($s),
'isfinite' => static fn (Expr $s): Expr => self::isNumeric($s),
'isnan' => static fn (Expr $s): Expr => self::isType('is_float', $s),
];
}

Expand All @@ -211,6 +224,11 @@ private static function isType(string $function, Expr $subject): FuncCall
return new FuncCall(new Name($function), [new Arg($subject)]);
}

private static function isNumeric(Expr $subject): BooleanOr
{
return new BooleanOr(self::isType('is_int', $subject), self::isType('is_float', $subject));
}

/**
* @param array<mixed> $args
*/
Expand Down
78 changes: 78 additions & 0 deletions src/Support/SubsetMatcher.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace K2gl\PHPUnitFluentAssertions\Support;

/**
* Decides whether a document contains an expected subset.
*
* Shared by the array and JSON subset assertions so both answer the question the same way.
*
* A map keeps subset semantics: the keys named in the expectation must be present with a
* matching value, and unnamed keys are ignored. A list is matched by membership instead —
* an index is not the identity of an element the way a key is the identity of a value, so
* an expected element may sit at any position, and two expectations never claim the same
* element.
*
* @internal
*/
final class SubsetMatcher
{
/**
* @param array<array-key, mixed> $document
* @param array<array-key, mixed> $subset
*/
public static function matches(array $document, array $subset): bool
{
if (array_is_list($document) && array_is_list($subset)) {
return self::listMatches($document, $subset, 0, []);
}

foreach ($subset as $key => $value) {
if (! array_key_exists($key, $document) || ! self::valueMatches($document[$key], $value)) {
return false;
}
}

return true;
}

private static function valueMatches(mixed $documentValue, mixed $expected): bool
{
if (is_array($documentValue) && is_array($expected)) {
return self::matches($documentValue, $expected);
}

return $documentValue === $expected;
}

/**
* Pairs every expected element with a distinct document element.
*
* Backtracks rather than keeping the first match: with subsets on both sides a greedy
* pass can consume the only element a later expectation could have matched.
*
* @param list<mixed> $document
* @param list<mixed> $expected
* @param array<int, true> $taken
*/
private static function listMatches(array $document, array $expected, int $index, array $taken): bool
{
if ($index === count($expected)) {
return true;
}

foreach ($document as $position => $candidate) {
if (isset($taken[$position]) || ! self::valueMatches($candidate, $expected[$index])) {
continue;
}

if (self::listMatches($document, $expected, $index + 1, $taken + [$position => true])) {
return true;
}
}

return false;
}
}
30 changes: 7 additions & 23 deletions src/Traits/ArrayAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace K2gl\PHPUnitFluentAssertions\Traits;

use K2gl\PHPUnitFluentAssertions\FluentAssertions;
use K2gl\PHPUnitFluentAssertions\Support\SubsetMatcher;
use PHPUnit\Framework\Assert;
use ArrayAccess;
use Countable;
Expand Down Expand Up @@ -67,11 +68,15 @@ public function notCount(int $elementsCount, string $message = ''): self
/**
* Asserts that the array contains another associative array.
*
* This method checks if the actual array contains all the key-value pairs from the provided array.
* The keys named in the expectation must be present with a matching value; unnamed keys
* are ignored. Inside a list the expectation is matched by membership, so an expected
* element may sit at any position, and two expectations never claim the same element.
*
* Example usage:
* fact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passes
* fact(['tags' => ['a', 'b']])->arrayContainsAssociativeArray(['tags' => ['b']]); // Passes
* fact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Fails
* fact(['id' => 1])->arrayContainsAssociativeArray(['parent' => null]); // Fails — no such key
*
* @param array<array-key, mixed> $values The associative array that should be contained within the actual array.
*
Expand All @@ -84,7 +89,7 @@ public function arrayContainsAssociativeArray(array $values): self
}

Assert::assertTrue(
$this->arrayContainsAssociativeArrayRecursive($this->variable, $values),
SubsetMatcher::matches($this->variable, $values),
sprintf(
"Array does not contain associative array. \n\nArray: '%s' \n\nExpected values: '%s'",
var_export($this->variable, true),
Expand All @@ -95,27 +100,6 @@ public function arrayContainsAssociativeArray(array $values): self
return $this;
}

/**
* @param array<array-key, mixed> $data
* @param array<array-key, mixed> $values
*/
protected function arrayContainsAssociativeArrayRecursive(array $data, array $values): bool
{
foreach ($values as $key => $value) {
$actual = $data[$key] ?? null;

if (is_array($value)) {
if (! is_array($actual) || ! $this->arrayContainsAssociativeArrayRecursive($actual, $value)) {
return false;
}
} elseif ($actual !== $value) {
return false;
}
}

return true;
}

/**
* Asserts that the variable has a specific key.
*
Expand Down
Loading