Compare commits

..

3 Commits

Author SHA1 Message Date
nuno maduro 962510b7e3 chore: adjusts snapshots 2026-07-21 01:14:59 +01:00
Punyapal Shah e9c268a584 feat: validation expectations (#1762)
* feat: add validation expectations for IP, MAC, hostname, domain, base64, and hexadecimal

* improvements

* improvements
2026-07-20 11:21:57 +01:00
nuno maduro fa91fcf744 fix: coverage merger 2026-07-19 16:45:06 +01:00
10 changed files with 416 additions and 4 deletions
+100
View File
@@ -1219,4 +1219,104 @@ final class Expectation
return $this;
}
/**
* Asserts that the value is an IP address.
*
* @return self<TValue>
*/
public function toBeIpAddress(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_IP), $message);
return $this;
}
/**
* Asserts that the value is a MAC address.
*
* @return self<TValue>
*/
public function toBeMacAddress(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_MAC), $message);
return $this;
}
/**
* Asserts that the value is a hostname.
*
* @return self<TValue>
*/
public function toBeHostname(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue((bool) filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME), $message);
return $this;
}
/**
* Asserts that the value is a domain name.
*
* @return self<TValue>
*/
public function toBeDomain(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$isValid = filter_var($this->value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false
&& str_contains($this->value, '.');
Assert::assertTrue($isValid, $message);
return $this;
}
/**
* Asserts that the value is a base64-encoded string.
*
* @return self<TValue>
*/
public function toBeBase64(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
$decoded = base64_decode($this->value, true);
Assert::assertTrue($decoded !== false && base64_encode($decoded) === $this->value, $message);
return $this;
}
/**
* Asserts that the value is a hexadecimal string.
*
* @return self<TValue>
*/
public function toBeHexadecimal(string $message = ''): self
{
if (! is_string($this->value)) {
InvalidExpectationValue::expected('string');
}
Assert::assertTrue(ctype_xdigit($this->value), $message);
return $this;
}
}
+57 -1
View File
@@ -8,6 +8,9 @@ use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\Serialization\Unserializer;
use Throwable;
/**
@@ -161,7 +164,60 @@ final class CoverageMerger
return null;
}
return $value instanceof CodeCoverage ? $value : null;
// Legacy `--coverage-php` format: a serialized `CodeCoverage` object.
if ($value instanceof CodeCoverage) {
return $value;
}
// Since phpunit/php-code-coverage 14, `--coverage-php` writes the report
// as a serialized array (`['codeCoverage' => ..., 'testResults' => ...,
// 'basePath' => ...]`) rather than a `CodeCoverage` object, so it has to
// be rebuilt into one before it can be merged.
return self::coverageFromSerializedData($reportPath);
}
private static function coverageFromSerializedData(string $reportPath): ?CodeCoverage
{
if ($reportPath === '') {
return null;
}
try {
$serialized = new Unserializer()->unserialize($reportPath);
} catch (Throwable) {
return null;
}
$data = $serialized['codeCoverage'];
$basePath = $serialized['basePath'];
if ($basePath !== '') {
foreach ($data->coveredFiles() as $relativePath) {
$data->renameFile($relativePath, $basePath.DIRECTORY_SEPARATOR.$relativePath);
}
}
$coverage = self::emptyCoverage();
if (! $coverage instanceof CodeCoverage) {
return null;
}
$coverage->setData($data);
$coverage->setTests($serialized['testResults']);
return $coverage;
}
private static function emptyCoverage(): ?CodeCoverage
{
try {
$filter = new Filter;
return new CodeCoverage(new Selector()->forLineCoverage($filter), $filter);
} catch (Throwable) {
return null;
}
}
private static function unserializeCoverage(string $bytes): ?CodeCoverage
+51 -1
View File
@@ -476,6 +476,16 @@
✓ pass
✓ failures
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeBase64
✓ pass
✓ failures
✓ failures with invalid padding
✓ failures with padding only
✓ failures with malformed input
✓ failures with invalid type
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeBetween
@@ -522,6 +532,14 @@
✓ pass
✓ failures
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeDomain
✓ pass
✓ failures
✓ failures with leading dot
✓ failures with invalid type
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeEmail
@@ -584,6 +602,24 @@
✓ passes with strings
✓ failures
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeHexadecimal
✓ pass
✓ failures
✓ failures with invalid characters
✓ failures with zero-x prefix
✓ failures with empty string
✓ failures with invalid type
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeHostname
✓ pass
✓ failures
✓ failures with trailing hyphen
✓ failures with invalid type
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeIn
@@ -622,6 +658,13 @@
✓ failure when the class is not invokable
✓ class is not invokable
PASS Tests\Features\Expect\toBeIpAddress
✓ pass
✓ failures
✓ failures with invalid type
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeIterable
✓ pass
✓ failures
@@ -666,6 +709,13 @@
✓ pass
✓ failures
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeMacAddress
✓ pass
✓ failures
✓ failures with invalid type
✓ failures with custom message
✓ not failures
PASS Tests\Features\Expect\toBeNAN
@@ -2136,4 +2186,4 @@
✓ pass with dataset with ('my-datas-set-value')
✓ within describe → pass with dataset with ('my-datas-set-value')
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1504 passed (3281 assertions)
Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1542 passed (3375 assertions)
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('pass', function (): void {
expect('Zm9v')->toBeBase64() // 'foo' (no padding)
->and('Zm9vYg==')->toBeBase64() // 'foob' (with padding)
->and('Zm9vYmE=')->toBeBase64() // 'fooba' (with padding)
->and('Zm9vYmFy')->toBeBase64() // 'foobar' (no padding)
->and('')->toBeBase64(); // empty string
});
test('failures', function (): void {
expect('not-base64!')->toBeBase64();
})->throws(ExpectationFailedException::class);
test('failures with invalid padding', function (): void {
expect('AAA')->toBeBase64();
})->throws(ExpectationFailedException::class);
test('failures with padding only', function (): void {
expect('====')->toBeBase64();
})->throws(ExpectationFailedException::class);
test('failures with malformed input', function (): void {
expect('!!invalid!!')->toBeBase64();
})->throws(ExpectationFailedException::class);
test('failures with invalid type', function (): void {
expect([])->toBeBase64();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('failures with custom message', function (): void {
expect('!!invalid!!')->toBeBase64('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function (): void {
expect('Zm9v')->not->toBeBase64();
})->throws(ExpectationFailedException::class);
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('pass', function (): void {
expect('example.com')->toBeDomain() // standard domain
->and('sub.example.com')->toBeDomain() // subdomain
->and('my-host.io')->toBeDomain() // with hyphen
->and('example.co.uk')->toBeDomain(); // multi-level TLD
});
test('failures', function (): void {
expect('example')->toBeDomain();
})->throws(ExpectationFailedException::class);
test('failures with leading dot', function (): void {
expect('.example.com')->toBeDomain();
})->throws(ExpectationFailedException::class);
test('failures with invalid type', function (): void {
expect([])->toBeDomain();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('failures with custom message', function (): void {
expect('example')->toBeDomain('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function (): void {
expect('example.com')->not->toBeDomain();
})->throws(ExpectationFailedException::class);
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('pass', function (): void {
expect('abcdef')->toBeHexadecimal() // lowercase
->and('ABCDEF')->toBeHexadecimal() // uppercase
->and('aBcDeF')->toBeHexadecimal() // mixed case
->and('1234567890')->toBeHexadecimal() // numeric
->and('deadbeef')->toBeHexadecimal(); // alphanumeric
});
test('failures', function (): void {
expect('xyz')->toBeHexadecimal();
})->throws(ExpectationFailedException::class);
test('failures with invalid characters', function (): void {
expect('ghijkl')->toBeHexadecimal();
})->throws(ExpectationFailedException::class);
test('failures with zero-x prefix', function (): void {
expect('0x1a')->toBeHexadecimal();
})->throws(ExpectationFailedException::class);
test('failures with empty string', function (): void {
expect('')->toBeHexadecimal();
})->throws(ExpectationFailedException::class);
test('failures with invalid type', function (): void {
expect([])->toBeHexadecimal();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('failures with custom message', function (): void {
expect('xyz')->toBeHexadecimal('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function (): void {
expect('abcdef')->not->toBeHexadecimal();
})->throws(ExpectationFailedException::class);
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('pass', function (): void {
expect('example')->toBeHostname() // single label
->and('example.com')->toBeHostname() // multiple labels
->and('sub.example.com')->toBeHostname() // subdomain
->and('my-host')->toBeHostname(); // with hyphen
});
test('failures', function (): void {
expect('-example')->toBeHostname();
})->throws(ExpectationFailedException::class);
test('failures with trailing hyphen', function (): void {
expect('example-')->toBeHostname();
})->throws(ExpectationFailedException::class);
test('failures with invalid type', function (): void {
expect([])->toBeHostname();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('failures with custom message', function (): void {
expect('-example')->toBeHostname('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function (): void {
expect('example.com')->not->toBeHostname();
})->throws(ExpectationFailedException::class);
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('pass', function (): void {
expect('192.168.1.1')->toBeIpAddress()
->and('::1')->toBeIpAddress()
->and('2001:db8::1')->toBeIpAddress();
});
test('failures', function (): void {
expect('not-an-ip')->toBeIpAddress();
})->throws(ExpectationFailedException::class);
test('failures with invalid type', function (): void {
expect([])->toBeIpAddress();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('failures with custom message', function (): void {
expect('not-an-ip')->toBeIpAddress('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function (): void {
expect('192.168.1.1')->not->toBeIpAddress();
})->throws(ExpectationFailedException::class);
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Pest\Exceptions\InvalidExpectationValue;
use PHPUnit\Framework\ExpectationFailedException;
test('pass', function (): void {
expect('00:1a:2b:3c:4d:5e')->toBeMacAddress() // colon-separated
->and('00-1a-2b-3c-4d-5e')->toBeMacAddress() // hyphen-separated
->and('ff:ff:ff:ff:ff:ff')->toBeMacAddress();
});
test('failures', function (): void {
expect('not-a-mac')->toBeMacAddress();
})->throws(ExpectationFailedException::class);
test('failures with invalid type', function (): void {
expect([])->toBeMacAddress();
})->throws(InvalidExpectationValue::class, 'Invalid expectation value type. Expected [string].');
test('failures with custom message', function (): void {
expect('not-a-mac')->toBeMacAddress('oh no!');
})->throws(ExpectationFailedException::class, 'oh no!');
test('not failures', function (): void {
expect('00:1a:2b:3c:4d:5e')->not->toBeMacAddress();
})->throws(ExpectationFailedException::class);
+2 -2
View File
@@ -24,13 +24,13 @@ test('parallel', function () use ($run): void {
$file = file_get_contents(__FILE__);
$file = preg_replace(
'/\$expected = \'.*?\';/',
"\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1487 passed (3228 assertions)';",
"\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1525 passed (3322 assertions)';",
$file,
);
file_put_contents(__FILE__, $file);
}
$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1487 passed (3228 assertions)';
$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1525 passed (3322 assertions)';
expect($output)
->toContain("Tests: {$expected}")