Packages · utilities
k2gl/phpunit-fluent-assertions
Fluent, readable assertions for PHPUnit.
Write fact($value)->is(...) chains instead of remembering assertEquals argument order.
Install
composer require k2gl/phpunit-fluent-assertions Reach for it when
- You want more readable, chainable assertions in PHPUnit tests.
Look elsewhere when
- You prefer PHPUnit’s native assertions, or you’re not on PHPUnit.
Fluent assertions for PHPUnit
This library is inspired by Vladimir Khorikov, the author of Unit Testing: Principles, Patterns and Practices and makes checks in tests more readable.
Installation
You can add this library as a local, per-project dependency to your project using Composer:
composer require --dev k2gl/phpunit-fluent-assertions
Usage
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 assertions
self::assertSame($e164PhoneNumber, $user->getPhone());
// fluent assertions
fact($user->getPhone())
->is($e164PhoneNumber)
->isString()
->startsWith('+7')
// etc.
;
Array assertions
fact([1, 2, 3])->count(3); // Passes
fact([1, 2])->count(3); // Fails
fact([1, 2])->notCount(3); // Passes
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(['a' => 1])->arrayHasKey('a'); // Passes
fact(['a' => 1])->arrayHasKey('b'); // Fails
fact(['a' => 1])->arrayNotHasKey('b'); // Passes
fact(['a' => 1])->arrayNotHasKey('a'); // Fails
fact([1, 2, 3])->contains(2); // Passes
fact([1, 2])->contains(3); // Fails
fact([1, 2])->doesNotContain(3); // Passes
fact([1, 2, 3])->doesNotContain(3); // Fails
fact([1, 2])->hasSize(2); // Passes
fact([1, 2, 3])->hasSize(2); // Fails
fact([])->isEmptyArray(); // Passes
fact([1, 2])->isEmptyArray(); // Fails
fact([1, 2])->isNotEmptyArray(); // Passes
fact([])->isNotEmptyArray(); // Fails
fact([2, 4, 6])->every(fn($v) => $v % 2 === 0); // Passes
fact([1, 2, 3])->every(fn($v) => $v > 5); // Fails
fact([1, 2, 3])->some(fn($v) => $v > 2); // Passes
fact([1, 2, 3])->some(fn($v) => $v > 10); // Fails
fact([1, 2, 3])->none(fn($v) => $v > 10); // Passes
fact([1, 2, 3])->none(fn($v) => $v > 2); // Fails
Boolean assertions
fact(true)->true(); // Passes
fact(1)->true(); // Fails due to strict comparison
fact(false)->notTrue(); // Passes
fact(true)->notTrue(); // Fails
fact(false)->false(); // Passes
fact(0)->false(); // Fails due to strict comparison
fact(true)->notFalse(); // Passes
fact(false)->notFalse(); // Fails
Comparison and equality assertions
fact(42)->is(42); // Passes
fact(42)->is('42'); // Fails due to type difference
fact(42)->equals(42); // Passes
fact(42)->equals('42'); // Passes due to loose comparison
fact(42)->not(43); // Passes
fact(42)->not(42); // Fails
Null assertions
fact(null)->null(); // Passes
fact('')->null(); // Fails
fact(42)->notNull(); // Passes
fact(null)->notNull(); // Fails
Numeric assertions
fact(5)->isLowerThan(10); // Passes
fact(10)->isLowerThan(5); // Fails
fact(10)->isGreaterThan(5); // Passes
fact(5)->isGreaterThan(10); // Fails
fact(5)->isPositive(); // Passes
fact(-3)->isPositive(); // Fails
fact(-3)->isNegative(); // Passes
fact(5)->isNegative(); // Fails
fact(0)->isZero(); // Passes
fact(0.0)->isZero(); // Passes
fact(1)->isZero(); // Fails
fact(5)->isBetween(1, 10); // Passes
fact(15)->isBetween(1, 10); // Fails
String assertions
fact('abc123')->matchesRegularExpression('/^[a-z]+\d+$/'); // Passes
fact('123abc')->matchesRegularExpression('/^[a-z]+\d+$/'); // Fails
fact('123abc')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Passes
fact('abc123')->notMatchesRegularExpression('/^[a-z]+\d+$/'); // Fails
fact('hello world')->containsString('world'); // Passes
fact('hello world')->containsString('foo'); // Fails
fact('hello world')->notContainsString('foo'); // Passes
fact('hello world')->notContainsString('world'); // Fails
fact('Hello World')->containsStringIgnoringCase('world'); // Passes
fact('Hello World')->containsStringIgnoringCase('foo'); // Fails
fact('Hello World')->notContainsStringIgnoringCase('foo'); // Passes
fact('Hello World')->notContainsStringIgnoringCase('world'); // Fails
fact('hello world')->startsWith('hello'); // Passes
fact('world hello')->startsWith('hello'); // Fails
fact('file.txt')->endsWith('.txt'); // Passes
fact('txt.file')->endsWith('.txt'); // Fails
fact('abc')->hasLength(3); // Passes
fact('abcd')->hasLength(3); // Fails
fact('')->isEmptyString(); // Passes
fact('hello')->isEmptyString(); // Fails
fact('hello')->isNotEmptyString(); // Passes
fact('')->isNotEmptyString(); // Fails
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}')->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('01ARZ3NDEKTSV4RRFFQ69G5FAV')->ulid(); // Passes (if valid ULID)
fact('invalid-ulid')->ulid(); // Fails
Type Checking assertions
fact(new stdClass())->instanceOf(stdClass::class); // Passes
fact(new stdClass())->instanceOf(Exception::class); // Fails
fact(new stdClass())->notInstanceOf(Exception::class); // Passes
fact(new stdClass())->notInstanceOf(stdClass::class); // Fails
fact(42)->isInt(); // Passes
fact('42')->isInt(); // Fails
fact('text')->isString(); // Passes
fact(42)->isString(); // Fails
fact((object)['name' => 'John'])->hasProperty('name'); // Passes
fact((object)['name' => 'John'])->hasProperty('age'); // Fails
fact(new stdClass())->hasMethod('__construct'); // Passes
fact(new stdClass())->hasMethod('nonExistentMethod'); // Fails
fact(3.14)->isFloat(); // Passes
fact(42)->isFloat(); // Fails
fact(true)->isBool(); // Passes
fact(1)->isBool(); // Fails
fact([1, 2])->isArray(); // Passes
fact('not array')->isArray(); // Fails
fact(fopen('php://memory', 'r'))->isResource(); // Passes
fact('string')->isResource(); // Fails
fact('strlen')->isCallable(); // Passes
fact(123)->isCallable(); // Fails
fact(3.14)->isFloat(); // Passes
fact(42)->isFloat(); // Fails
fact(true)->isBool(); // Passes
fact(1)->isBool(); // Fails
Exception assertions
The 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 () => 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
Date/time assertions
The subject is a DateTimeInterface; a string expectation is parsed as a DateTimeImmutable.
fact(new DateTimeImmutable('2024-01-01'))->isBefore('2024-12-31'); // Passes
fact(new DateTimeImmutable('2024-12-31'))->isAfter('2024-01-01'); // Passes
fact(new DateTimeImmutable('2024-06-13 23:59'))->isSameDate('2024-06-13'); // Passes — same calendar date, time ignored
Enum assertions
Work on any native UnitEnum / BackedEnum.
fact($order->status)->isEnum(Status::Paid); // identity: the subject is that case
fact(Status::Paid)->hasValue('paid'); // BackedEnum backing value
fact(Status::Paid)->hasName('Paid'); // case name
PHPStan support
The 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}|null
fact($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.neon
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
or negated assertions such as equals() (loose ==) and not() would not narrow soundly, so they
are intentionally left out and leave the type unchanged.
Pull requests are always welcome
API
Public classes and methods, generated from the source.
K2gl\PHPUnitFluentAssertions\FluentAssertions class
__construct( public readonly mixed $variable = null )for(mixed $variable = null): self
K2gl\PHPUnitFluentAssertions\PHPStan\FluentAssertionsTypeSpecifyingExtension class
setTypeSpecifier(TypeSpecifier $typeSpecifier): voidgetClass(): stringisMethodSupported( MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context, ): boolspecifyTypes( MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context, ): SpecifiedTypes(Expr $s, array $args, Scope $scope): ?Expr(Expr $s, array $args, Scope $scope): ?Expr(Expr $s, array $args): ?Expr
K2gl\PHPUnitFluentAssertions\Reference\RegularExpressionPattern class
K2gl\PHPUnitFluentAssertions\Traits\ArrayAssertions trait
count(int $expectedCount, string $message = ''): selfnotCount(int $elementsCount, string $message = ''): selfarrayContainsAssociativeArray(array $values): selfarrayHasKey(int|string $key, string $message = ''): selfarrayNotHasKey(int|string $key, string $message = ''): selfcontains(mixed $value, string $message = ''): selfdoesNotContain(mixed $value, string $message = ''): selfhasSize(int $size, string $message = ''): selfisEmptyArray(string $message = ''): selfisNotEmptyArray(string $message = ''): selfevery(callable $callback, string $message = ''): selfsome(callable $callback, string $message = ''): selfnone(callable $callback, string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\BooleanAssertions trait
true(string $message = ''): selfnotTrue(string $message = ''): selffalse(string $message = ''): selfnotFalse(string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\ComparisonAndEqualityAssertions trait
is(mixed $expected, string $message = ''): selfequals(mixed $expected, string $message = ''): selfnot(mixed $expected, string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\DateTimeAssertions trait
isBefore(DateTimeInterface|string $expected, string $message = ''): selfisAfter(DateTimeInterface|string $expected, string $message = ''): selfisSameDate(DateTimeInterface|string $expected, string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\EnumAssertions trait
isEnum(UnitEnum $case, string $message = ''): selfhasValue(int|string $value, string $message = ''): selfhasName(string $name, string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\ExceptionAssertions trait
throws(string $exception, string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\NullAssertions trait
null(string $message = ''): selfnotNull(string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\NumericAssertions trait
isLowerThan(int|float $expected, string $message = ''): selfisGreaterThan(int|float $expected, string $message = ''): selfisPositive(string $message = ''): selfisNegative(string $message = ''): selfisZero(string $message = ''): selfisBetween(int|float $min, int|float $max, string $message = ''): self
K2gl\PHPUnitFluentAssertions\Traits\StringAssertions trait
matchesRegularExpression(string $pattern, string $message = ''): selfnotMatchesRegularExpression(string $pattern, string $message = ''): selfcontainsString(string $string, string $message = ''): selfnotContainsString(string $string, string $message = ''): selfcontainsStringIgnoringCase(string $string, string $message = ''): selfnotContainsStringIgnoringCase(string $string, string $message = ''): selfstartsWith(string $prefix, string $message = ''): selfendsWith(string $suffix, string $message = ''): selfhasLength(int $length, string $message = ''): selfisEmptyString(string $message = ''): selfisNotEmptyString(string $message = ''): selfisJson(string $message = ''): selfmatchesJson(string $expectedJson, string $message = ''): selfnotMatchesJson(string $expectedJson, string $message = ''): selfisValidEmail(string $message = ''): selfulid(): self
K2gl\PHPUnitFluentAssertions\Traits\TypeCheckingAssertions trait
instanceOf(string $expected, string $message = ''): selfnotInstanceOf(string $expected, string $message = ''): selfisInt(string $message = ''): selfisString(string $message = ''): selfhasProperty(string $property, string $message = ''): selfhasMethod(string $method, string $message = ''): selfisFloat(string $message = ''): selfisBool(string $message = ''): selfisArray(string $message = ''): selfisResource(string $message = ''): selfisCallable(string $message = ''): self