This library is inspired by Vladimir Khorikov, the author of Unit Testing: Principles, Patterns and Practices and makes checks in tests more readable.
You can add this library as a local, per-project dependency to your project using Composer:
composer require --dev k2gl/phpunit-fluent-assertions
Write tests as usual, just use fluent assertions short aliases check($x)->...;, expect($x)->...; or fact($x)->...; instead of self::assert...($x, $y).
// arrange$user = UserFactory::createOne();
// act$user->setPhone($e164PhoneNumber = faker()->e164PhoneNumber);
// traditional PHPUnit assertionsself::assertSame($e164PhoneNumber, $user->getPhone());
// fluent assertionsfact($user->getPhone())
->is($e164PhoneNumber)
->isString()
->startsWith('+7')
// etc.
;fact([1, 2, 3])->count(3); // Passesfact([1, 2])->count(3); // Failsfact([1, 2])->notCount(3); // Passesfact([1, 2, 3])->notCount(3); // Failsfact(['a' => ['b' => 'c']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Passesfact(['a' => ['b' => 'd']])->arrayContainsAssociativeArray(['a' => ['b' => 'c']]); // Failsfact(['a' => 1])->arrayHasKey('a'); // Passesfact(['a' => 1])->arrayHasKey('b'); // Failsfact(['a' => 1])->arrayNotHasKey('b'); // Passesfact(['a' => 1])->arrayNotHasKey('a'); // Failsfact([1, 2, 3])->contains(2); // Passesfact([1, 2])->contains(3); // Failsfact([1, 2])->doesNotContain(3); // Passesfact([1, 2, 3])->doesNotContain(3); // Fails fact([1, 2])->hasSize(2); // Passesfact([1, 2, 3])->hasSize(2); // Failsfact([])->isEmptyArray(); // Passesfact([1, 2])->isEmptyArray(); // Failsfact([1, 2])->isNotEmptyArray(); // Passesfact([])->isNotEmptyArray(); // Failsfact([2, 4, 6])->every(fn($v) => $v % 2 === 0); // Passesfact([1, 2, 3])->every(fn($v) => $v > 5); // Failsfact([1, 2, 3])->some(fn($v) => $v > 2); // Passesfact([1, 2, 3])->some(fn($v) => $v > 10); // Failsfact([1, 2, 3])->none(fn($v) => $v > 10); // Passesfact([1, 2, 3])->none(fn($v) => $v > 2); // Failsfact(true)->true(); // Passesfact(1)->true(); // Fails due to strict comparisonfact(false)->notTrue(); // Passesfact(true)->notTrue(); // Failsfact(false)->false(); // Passesfact(0)->false(); // Fails due to strict comparisonfact(true)->notFalse(); // Passesfact(false)->notFalse(); // Failsfact(42)->is(42); // Passesfact(42)->is('42'); // Fails due to type differencefact(42)->equals(42); // Passesfact(42)->equals('42'); // Passes due to loose comparisonfact(42)->not(43); // Passesfact(42)->not(42); // Failsfact(null)->null(); // Passesfact('')->null(); // Failsfact(42)->notNull(); // Passesfact(null)->notNull(); // Failsfact(5)->isLowerThan(10); // Passesfact(10)->isLowerThan(5); // Failsfact(10)->isGreaterThan(5); // Passesfact(5)->isGreaterThan(10); // Failsfact(5)->isPositive(); // Passesfact(-3)->isPositive(); // Failsfact(-3)->isNegative(); // Passesfact(5)->isNegative(); // Failsfact(0)->isZero(); // Passesfact(0.0)->isZero(); // Passesfact(1)->isZero(); // Failsfact(5)->isBetween(1, 10); // Passesfact(15)->isBetween(1, 10); // Failsfact('abc123')->matchesRegularExpression('/^[a-z]+\d+$/'); // Passesfact('123abc')->matchesRegularExpression('/^[a-z]+\d+$/'); // Failsfact('123abc')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Passesfact('abc123')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Failsfact('hello world')->containsString('world'); // Passesfact('hello world')->containsString('foo'); // Failsfact('hello world')->notContainsString('foo'); // Passesfact('hello world')->notContainsString('world'); // Failsfact('Hello World')->containsStringIgnoringCase('world'); // Passesfact('Hello World')->containsStringIgnoringCase('foo'); // Failsfact('Hello World')->notContainsStringIgnoringCase('foo'); // Passesfact('Hello World')->notContainsStringIgnoringCase('world'); // Failsfact('hello world')->startsWith('hello'); // Passesfact('world hello')->startsWith('hello'); // Failsfact('file.txt')->endsWith('.txt'); // Passesfact('txt.file')->endsWith('.txt'); // Failsfact('abc')->hasLength(3); // Passesfact('abcd')->hasLength(3); // Failsfact('')->isEmptyString(); // Passesfact('hello')->isEmptyString(); // Failsfact('hello')->isNotEmptyString(); // Passesfact('')->isNotEmptyString(); // Failsfact('{"key": "value"}')->isJson(); // Passesfact('invalid json')->isJson(); // Failsfact('{"a":1,"b":2}')->matchesJson('{"b":2,"a":1}'); // Passes (key order ignored)fact('{"a":1}')->matchesJson('{"a":2}'); // Failsfact('{"a":1}')->notMatchesJson('{"a":2}'); // Passesfact('{"a":1,"b":2}')->notMatchesJson('{"b":2,"a":1}'); // Failsfact('user@example.com')->isValidEmail(); // Passesfact('invalid-email')->isValidEmail(); // Failsfact('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID)fact('invalid-ulid')->ulid(); // Failsfact(newstdClass())->instanceOf(stdClass::class); // Passesfact(newstdClass())->instanceOf(Exception::class); // Failsfact(newstdClass())->notInstanceOf(Exception::class); // Passesfact(newstdClass())->notInstanceOf(stdClass::class); // Failsfact(42)->isInt(); // Passesfact('42')->isInt(); // Failsfact('text')->isString(); // Passesfact(42)->isString(); // Failsfact((object)['name' => 'John'])->hasProperty('name'); // Passesfact((object)['name' => 'John'])->hasProperty('age'); // Failsfact((object)['name' => 'John'])->notHasProperty('age'); // Passesfact((object)['name' => 'John'])->notHasProperty('name'); // Failsfact(newstdClass())->hasMethod('__construct'); // Passesfact(newstdClass())->hasMethod('nonExistentMethod'); // Failsfact(3.14)->isFloat(); // Passesfact(42)->isFloat(); // Failsfact(true)->isBool(); // Passesfact(1)->isBool(); // Failsfact([1, 2])->isArray(); // Passesfact('not array')->isArray(); // Failsfact(fopen('php://memory', 'r'))->isResource(); // Passesfact('string')->isResource(); // Failsfact('strlen')->isCallable(); // Passesfact(123)->isCallable(); // Failsfact(3.14)->isFloat(); // Passesfact(42)->isFloat(); // Failsfact(true)->isBool(); // Passesfact(1)->isBool(); // FailsThe subject is a callable; throws() invokes it and asserts the expected exception is thrown.
Pass a message substring to also assert on the exception message.
fact(fn () => thrownewRuntimeException('boom'))->throws(RuntimeException::class); // Passesfact(fn () => $service->run())->throws(DomainException::class, 'invalid'); // Passes if message contains "invalid"fact(fn () => 42)->throws(RuntimeException::class); // Fails — nothing thrownThe subject is a DateTimeInterface; a string expectation is parsed as a DateTimeImmutable.
fact(newDateTimeImmutable('2024-01-01'))->isBefore('2024-12-31'); // Passesfact(newDateTimeImmutable('2024-12-31'))->isAfter('2024-01-01'); // Passesfact(newDateTimeImmutable('2024-06-13 23:59'))->isSameDate('2024-06-13'); // Passes — same calendar date, time ignoredWork on any native UnitEnum / BackedEnum.
fact($order->status)->isEnum(Status::Paid); // identity: the subject is that casefact(Status::Paid)->hasValue('paid'); // BackedEnum backing valuefact(Status::Paid)->hasName('Paid'); // case nameThe package ships a PHPStan extension that narrows the asserted value, so the analyser keeps following your types after a fluent assertion:
/** @var array{something: string}|null $context */$context = $user->getContext();
echo$context['something']; // PHPStan error: Cannot access offset 'something' on array{something: string}|nullfact($context)->notNull();
echo$context['something']; // OK: $context is narrowed to array{something: string}More examples — every supported assertion narrows the subject in place:
// instanceOf(): a union is narrowed to the concrete class/** @var DateTimeInterface|null $date */fact($date)->instanceOf(DateTimeImmutable::class);
// → $date is DateTimeImmutable// notInstanceOf(): the class is subtracted from the union/** @var DateTime|DateTimeImmutable $createdAt */fact($createdAt)->notInstanceOf(DateTimeImmutable::class);
// → $createdAt is DateTime// true() / false(): a bool is narrowed to the literal/** @var bool $flag */fact($flag)->true(); // → $flag is true/** @var bool $enabled */check($enabled)->false(); // → $enabled is false// is(): narrowed to the exact expected value (assertSame semantics)/** @var mixed $status */fact($status)->is('active');
// → $status is 'active'// null(): narrowed to null/** @var string|null $token */fact($token)->null();
// → $token is null// type checks: narrowed to the asserted PHP type/** @var mixed $value */fact($value)->isString();
// → $value is string (likewise isInt/isFloat/isBool/isArray/isCallable/isResource)// chains accumulate every supported step/** @var string|null $name */fact($name)->notNull()->is('admin');
// → $name is 'admin'The chain can be opened with any of check(), expect(), fact() or
FluentAssertions::for(), and the subject may be a variable or a property
(e.g. fact($user->getContext())->notNull()).
If you use phpstan/extension-installer
it is picked up automatically. Otherwise include it manually in your phpstan.neon:
includes:- vendor/k2gl/phpunit-fluent-assertions/extension.neonNarrowing 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
or negated assertions such as equals() (loose ==) and not() would not narrow soundly, so they
are intentionally left out and leave the type unchanged.