# Nette Tester - PHP Testing Framework Nette Tester is a lightweight, standalone PHP testing framework designed for simplicity, speed, and process isolation. It provides a productive testing environment for PHP applications without external dependencies, running on PHP 8.0+. Each test executes in a completely isolated PHP process, preventing interference between tests and ensuring reliable parallel execution with 8 threads by default. The framework features annotation-driven test configuration, comprehensive assertion methods, and multi-engine code coverage support (Xdebug, PCOV, PHPDBG). It's self-hosting, using itself for testing, and offers multiple test organization styles from simple assertion scripts to xUnit-style TestCase classes. Tests can be run individually as PHP scripts or orchestrated through the test runner for parallel execution with sophisticated output formatting. ## Core Assertions ### Assert::same() - Strict Identity Comparison Validates that two values are identical using strict comparison (===). Checks type, value, and object identity. ```php getMessage(); // "1.0 should be 1" } try { Assert::same(new stdClass, new stdClass); // fails: different instances } catch (Tester\AssertException $e) { echo $e->getMessage(); } // With custom description Assert::same(42, $calculatedValue, 'Calculation result mismatch'); ``` ### Assert::equal() - Loose Equality with Expectations Compares values with flexible rules: ignores object identity, array key order, and float precision differences. Supports Expect objects for complex assertions. ```php 1, 'b' => 2], ['b' => 2, 'a' => 1]); // passes: key order ignored // Object comparison - ignores instance identity $obj1 = new stdClass; $obj1->id = 5; $obj2 = new stdClass; $obj2->id = 5; Assert::equal($obj1, $obj2); // passes: same class and properties // Float precision - marginally different floats are equal Assert::equal(0.1 + 0.2, 0.3); // passes: handles floating point precision // Using Expect for complex structures $user = [ 'id' => 123, 'username' => 'john', 'email' => 'john@example.com', 'created_at' => new DateTime('2024-01-15'), 'roles' => ['user', 'editor'], ]; Assert::equal([ 'id' => Expect::type('int'), // type validation 'username' => 'john', // exact match 'email' => Expect::match('#.*@example\.com#'), // regex pattern 'created_at' => Expect::type(DateTime::class), // class validation 'roles' => Expect::type('array')->andCount(2), // chained expectations ], $user); // Match order and identity when needed Assert::equal([1, 2, 3], [3, 2, 1], matchOrder: false); // passes Assert::equal([1, 2, 3], [1, 2, 3], matchOrder: true); // passes Assert::equal([1, 2, 3], [3, 2, 1], matchOrder: true); // fails ``` ### Assert::exception() - Exception Testing Validates that a callable throws a specific exception type with optional message and code verification. ```php $id, 'name' => 'User ' . $id]; } } $repo = new UserRepository; // Basic exception type check Assert::exception( fn() => $repo->find(0), InvalidArgumentException::class ); // Check exception message (supports patterns) Assert::exception( fn() => $repo->find(-5), InvalidArgumentException::class, 'ID must be positive' ); // Pattern matching in message Assert::exception( fn() => $repo->find(-1), InvalidArgumentException::class, '%a%positive%a%' // wildcard pattern ); // Check exception code Assert::exception( fn() => $repo->find(0), InvalidArgumentException::class, 'ID must be positive', 100 ); // Capture exception for further inspection $e = Assert::exception( fn() => $repo->find(999), RuntimeException::class, 'User not found' ); // Now inspect the caught exception Assert::same('User not found', $e->getMessage()); Assert::type(RuntimeException::class, $e); ``` ### Assert::match() - Pattern Matching Matches strings against patterns with wildcards or regular expressions. Supports extensive wildcard syntax for flexible matching. ```php true); Assert::type('resource', fopen('php://memory', 'r')); Assert::type('null', null); Assert::type('scalar', 'test'); // string, int, float, or bool // Special type: list (array with sequential integer keys starting from 0) Assert::type('list', [1, 2, 3]); // passes Assert::type('list', ['a' => 1, 'b' => 2]); // fails: associative array try { Assert::type('list', [1 => 'a', 2 => 'b']); // fails: doesn't start at 0 } catch (Tester\AssertException $e) { echo $e->getMessage(); } // Class and interface validation class User { public function __construct(public string $name) {} } interface Repository {} class UserRepository implements Repository {} $user = new User('John'); Assert::type(User::class, $user); Assert::type('object', $user); $repo = new UserRepository; Assert::type(UserRepository::class, $repo); Assert::type(Repository::class, $repo); // interface check // With DateTime $date = new DateTime; Assert::type(DateTime::class, $date); Assert::type(DateTimeInterface::class, $date); // Using in complex assertions $response = ['code' => 200, 'data' => ['user' => new User('Alice')]]; Assert::type('array', $response); Assert::type('int', $response['code']); Assert::type(User::class, $response['data']['user']); ``` ### Assert::contains() - Containment Validation Validates that a string contains a substring or an array contains a value (strict comparison). ```php

Welcome

'; Assert::contains('Welcome', $html); Assert::contains('class="container"', $html); $permissions = ['read', 'write', 'delete']; Assert::contains('write', $permissions); // With custom description Assert::contains('success', $apiResponse, 'API response should indicate success'); // Negation Assert::notContains('error', $apiResponse); Assert::notContains('password', $loggedData); ``` ### Assert::error() - Error and Warning Testing Validates that a callable generates specific PHP errors, warnings, or notices. ```php trigger_error('Notice message', E_USER_NOTICE), 'E_USER_NOTICE', 'Notice message' ); // Multiple errors in sequence Assert::error( function () { trigger_error('First warning', E_USER_WARNING); trigger_error('Second notice', E_USER_NOTICE); }, [ [E_USER_WARNING, 'First warning'], [E_USER_NOTICE, 'Second notice'], ] ); // Pattern matching in error messages Assert::error( fn() => trigger_error('Error in module X', E_USER_ERROR), E_USER_ERROR, '%a%module%a%' ); // Testing actual PHP warnings Assert::error( fn() => 1 / 0, // Division by zero in PHP 8+ E_WARNING ); // Assert no errors occurred Assert::noError(function () { $x = 1 + 1; echo $x; }); // Catching exception as error alternative Assert::error( fn() => throw new Exception('Test exception'), Exception::class, 'Test exception' ); ``` ### Assert::count() - Count Validation Validates the number of elements in an array or Countable object. ```php 'John', 'age' => 30]); // Nested arrays $matrix = [ [1, 2, 3], [4, 5, 6], ]; Assert::count(2, $matrix); // 2 rows Assert::count(3, $matrix[0]); // 3 columns in first row // Countable objects class TaskList implements Countable { private array $tasks = []; public function add(string $task) { $this->tasks[] = $task; } public function count(): int { return count($this->tasks); } } $tasks = new TaskList; Assert::count(0, $tasks); $tasks->add('Task 1'); $tasks->add('Task 2'); Assert::count(2, $tasks); // With custom description Assert::count(10, $results, 'Expected exactly 10 search results'); // Practical usage in tests $users = fetchUsersFromDatabase(); Assert::count(5, $users, 'Should retrieve 5 users'); $filteredData = array_filter($data, fn($item) => $item['active']); Assert::count(3, $filteredData, 'Should have 3 active items'); ``` ## TestCase - xUnit Style Testing ### Basic TestCase Structure Organize tests using xUnit-style classes with setUp/tearDown lifecycle hooks and multiple test methods. ```php db = new PDO('sqlite::memory:'); $this->db->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); $this->manager = new UserManager($this->db); } protected function tearDown() { // Runs after each test method $this->db = null; $this->manager = null; } public function testCreateUser() { $userId = $this->manager->create('John', 'john@example.com'); Assert::type('int', $userId); Assert::true($userId > 0); $user = $this->manager->find($userId); Assert::same('John', $user['name']); Assert::same('john@example.com', $user['email']); } public function testUpdateUser() { $userId = $this->manager->create('Jane', 'jane@example.com'); $this->manager->update($userId, 'Jane Doe', 'jane.doe@example.com'); $user = $this->manager->find($userId); Assert::same('Jane Doe', $user['name']); Assert::same('jane.doe@example.com', $user['email']); } public function testDeleteUser() { $userId = $this->manager->create('Bob', 'bob@example.com'); Assert::notNull($this->manager->find($userId)); $this->manager->delete($userId); Assert::null($this->manager->find($userId)); } /** * @throws InvalidArgumentException */ public function testCreateUserWithInvalidEmail() { // Method throws InvalidArgumentException $this->manager->create('Test', 'invalid-email'); } } // Required: instantiate and run the test case (new UserManagerTest)->run(); ``` ### TestCase with Data Providers Use data providers to run the same test with multiple input sets, either from methods or external files. ```php validator = new Validator; } /** * @dataProvider getEmailValidationData */ public function testEmailValidation(string $email, bool $expected) { $result = $this->validator->isValidEmail($email); Assert::same($expected, $result); } public function getEmailValidationData(): array { return [ ['valid@example.com', true], ['user.name@example.co.uk', true], ['user+tag@example.com', true], ['invalid@', false], ['@example.com', false], ['invalid email@example.com', false], ['', false], ]; } /** * @dataProvider getPasswordStrengthData */ public function testPasswordStrength(string $password, int $expectedScore) { $score = $this->validator->getPasswordStrength($password); Assert::same($expectedScore, $score); } public function getPasswordStrengthData(): array { return [ 'weak' => ['12345', 1], 'medium' => ['Password1', 2], 'strong' => ['P@ssw0rd!123', 3], 'very_strong' => ['c0mPL3x!P@ssW0rD#2024', 4], ]; } } (new ValidatorTest)->run(); ``` ## HTTP Testing ### HttpAssert - HTTP Request Testing Test HTTP endpoints with fluent interface for validating status codes, headers, and response bodies. ```php expectCode(200) ->expectHeader('Content-Type', contains: 'application/json') ->expectBody(contains: '"id":123'); // POST request with authentication HttpAssert::fetch( 'https://api.example.com/users', method: 'POST', headers: [ 'Authorization' => 'Bearer token123', 'Content-Type' => 'application/json', ], body: '{"name":"John","email":"john@example.com"}' ) ->expectCode(201) ->expectHeader('Location') ->expectBody(contains: '"id"'); // Custom headers - both formats supported $response = HttpAssert::fetch( 'https://api.example.com/data', headers: [ 'X-API-Key' => 'secret123', // key-value format 'Accept: application/json', // string format 'User-Agent' => 'TestClient/1.0', ] ) ->expectCode(200); // Request with cookies HttpAssert::fetch( 'https://example.com/dashboard', cookies: [ 'session' => 'abc123xyz', 'user_pref' => 'dark_mode', ] ) ->expectCode(200) ->expectBody(contains: 'Welcome back'); // Follow redirects HttpAssert::fetch( 'https://example.com/old-url', follow: true ) ->expectCode(200); // Don't follow redirects HttpAssert::fetch( 'https://example.com/redirect', follow: false ) ->expectCode(302) ->expectHeader('Location', 'https://example.com/new-url'); // Status code validation with closure HttpAssert::fetch('https://api.example.com/health') ->expectCode(fn($code) => $code >= 200 && $code < 300) ->denyCode(fn($code) => $code >= 500); // Header validation patterns HttpAssert::fetch('https://api.example.com/data') ->expectHeader('Content-Type', 'application/json; charset=utf-8') // exact ->expectHeader('Server', contains: 'nginx') // contains ->expectHeader('X-Response-Time', matches: '%d% ms') // pattern ->denyHeader('X-Debug'); // must not exist // Body validation HttpAssert::fetch('https://api.example.com/users/1') ->expectBody(contains: '"status":"active"') ->expectBody(matches: '%A%"email":"%a%@%a%"%A%') ->denyBody(contains: 'password') ->denyBody(contains: 'secret'); // Custom body validation with closure HttpAssert::fetch('https://api.example.com/data') ->expectBody(function($body) { $data = json_decode($body, true); return isset($data['success']) && $data['success'] === true; }); // PUT request HttpAssert::fetch( 'https://api.example.com/users/123', method: 'PUT', headers: ['Content-Type' => 'application/json'], body: '{"name":"Updated Name"}' ) ->expectCode(200); // DELETE request HttpAssert::fetch( 'https://api.example.com/users/123', method: 'DELETE', headers: ['Authorization' => 'Bearer token123'] ) ->expectCode(204); // Testing error responses HttpAssert::fetch('https://api.example.com/invalid') ->expectCode(404) ->expectBody(contains: '"error":"Not Found"'); ``` ## DOM Query - HTML Testing ### DomQuery - CSS Selector Based HTML Querying Query and traverse HTML/XML documents using CSS selectors for testing web page structure and content. ```php Test Page

Hello World

John Doe

First paragraph.

Second paragraph.

Read more
'; $dom = DomQuery::fromHtml($html); // Check element existence Assert::true($dom->has('article.post')); Assert::true($dom->has('h1.title')); Assert::true($dom->has('[data-id]')); Assert::false($dom->has('.non-existent')); // Find elements (returns array of DomQuery objects) $headings = $dom->find('h1'); Assert::count(1, $headings); Assert::same('Hello World', (string) $headings[0]); // Find by class $articles = $dom->find('article.post'); Assert::count(1, $articles); // Find by attribute $dataElements = $dom->find('[data-id]'); Assert::count(1, $dataElements); Assert::same('123', (string) $dataElements[0]['data-id']); // Find multiple elements $paragraphs = $dom->find('p'); Assert::count(2, $paragraphs); Assert::same('First paragraph.', (string) $paragraphs[0]); Assert::same('Second paragraph.', (string) $paragraphs[1]); // Complex selectors $featuredPosts = $dom->find('article.featured'); Assert::count(1, $featuredPosts); $sidebarLinks = $dom->find('aside.sidebar a'); Assert::count(2, $sidebarLinks); // Attribute selectors $links = $dom->find('a[href^="/posts"]'); // starts with /posts Assert::count(3, $links); // Check if element matches selector $article = $dom->find('article')[0]; Assert::true($article->matches('article')); Assert::true($article->matches('.post')); Assert::true($article->matches('.featured')); Assert::false($article->matches('div')); // Find closest ancestor (PHP 8.4+) if (PHP_VERSION_ID >= 80400) { $time = $dom->find('time')[0]; $closestDiv = $time->closest('div'); Assert::true($closestDiv->matches('.meta')); } // Testing table structure $tableHtml = '
Name Email Status
John john@example.com Active
Jane jane@example.com Inactive
'; $table = DomQuery::fromHtml($tableHtml); // Verify table structure Assert::true($table->has('table.data-table')); Assert::true($table->has('thead tr th')); $headers = $table->find('thead th'); Assert::count(3, $headers); $rows = $table->find('tbody tr'); Assert::count(2, $rows); // Check specific cells $activeRow = $table->find('tr.active td'); Assert::count(3, $activeRow); Assert::same('John', (string) $activeRow[0]); // Badge verification $badges = $table->find('.badge'); Assert::count(2, $badges); Assert::true($badges[0]->matches('.success')); Assert::true($badges[1]->matches('.danger')); // Testing forms $formHtml = '
'; $form = DomQuery::fromHtml($formHtml); Assert::true($form->has('form#login-form')); Assert::true($form->has('input[type="text"][required]')); Assert::true($form->has('input[name="password"]')); Assert::true($form->has('button[type="submit"]')); $inputs = $form->find('input'); Assert::count(2, $inputs); ``` ## Test Organization Functions ### test() - Named Test Blocks Organize tests into named blocks with automatic setUp/tearDown support and clear output. ```php throw new InvalidArgumentException('Test error'), InvalidArgumentException::class, 'Test error' ); }); // Test with object state class Calculator { private float $result = 0; public function add(float $n): self { $this->result += $n; return $this; } public function multiply(float $n): self { $this->result *= $n; return $this; } public function getResult(): float { return $this->result; } } test('calculator chaining', function () { $calc = new Calculator; $result = $calc->add(5)->add(3)->multiply(2)->getResult(); Assert::same(16.0, $result); }); test('calculator edge cases', function () { $calc = new Calculator; Assert::same(0.0, $calc->getResult()); $calc->add(10); Assert::same(10.0, $calc->getResult()); $calc->multiply(0); Assert::same(0.0, $calc->getResult()); }); ``` ### Environment - Test Environment Configuration Configure and control the test execution environment including error handling, skipping, and locking. ```php db = new PDO('sqlite:' . __DIR__ . '/tmp/test.db'); $this->db->exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)'); } public function testInsert() { $stmt = $this->db->prepare('INSERT INTO users (name) VALUES (?)'); $stmt->execute(['John']); Assert::same(1, $this->db->lastInsertId()); } } // Bypass final keywords for testing (useful for mocking) Environment::bypassFinals(); final class FinalClass { public function method() { return 'original'; } } // Now you can extend it in tests class TestableClass extends FinalClass { public function method() { return 'mocked'; } } $obj = new TestableClass; Assert::same('mocked', $obj->method()); // Load data from data provider (when using @dataProvider annotation) // This would be called inside a test file with @dataProvider annotation if (getenv('NETTE_TESTER_RUNNER')) { // Running under test runner with data provider try { $data = Environment::loadData(); // Use $data in test } catch (Exception $e) { // No data provider configured } } // Check if running under test runner if (getenv(Environment::VariableRunner)) { echo "Running under Nette Tester runner\n"; } // Get thread number in parallel execution $threadNum = getenv(Environment::VariableThread); if ($threadNum !== false) { echo "Running in thread $threadNum\n"; } // Access test annotations programmatically $annotations = Environment::getTestAnnotations(); if (isset($annotations['skip'])) { Environment::skip($annotations['skip']); } ``` ## File Mocking and Data Providers ### FileMock - Virtual File System Create virtual files in memory for testing file operations without touching the real filesystem. ```php $matches[1], 'message' => $matches[2], ]; } } fclose($handle); return $entries; } } $logFile = FileMock::create(" [ERROR] Database connection failed [WARNING] Cache miss for key: user_123 [INFO] Request processed successfully "); $parser = new LogParser; $entries = $parser->parse($logFile); Assert::count(3, $entries); Assert::same('ERROR', $entries[0]['level']); Assert::same('Database connection failed', $entries[0]['message']); ``` ### DataProvider - External Test Data Load test data from external INI or PHP files with query filtering for parameterized testing. ```php 0; } } $validator = new UserValidator; Assert::true($validator->validate($data)); ``` ## Advanced Patterns ### Expect - Complex Structure Validation Create chainable expectations for validating complex data structures within Assert::equal(). ```php 'success', 'code' => 200, 'data' => [ 'user' => [ 'id' => 12345, 'username' => 'john_doe', 'email' => 'john@example.com', 'created_at' => new DateTime('2024-01-15 10:30:00'), 'profile' => [ 'avatar' => 'https://example.com/avatar.jpg', 'bio' => 'Software developer', ], 'roles' => ['user', 'editor'], 'settings' => [ 'notifications' => true, 'theme' => 'dark', ], ], 'token' => 'abc123xyz789', ], 'timestamp' => 1705315800, ]; Assert::equal([ 'status' => 'success', 'code' => Expect::type('int')->andSame(200), 'data' => [ 'user' => [ 'id' => Expect::type('int'), 'username' => Expect::match('[a-z_]+'), 'email' => Expect::match('#^[^@]+@[^@]+\.[^@]+$#'), 'created_at' => Expect::type(DateTime::class), 'profile' => [ 'avatar' => Expect::match('%a%.jpg'), 'bio' => Expect::type('string'), ], 'roles' => Expect::type('array')->andCount(2), 'settings' => Expect::type('array'), ], 'token' => Expect::match('%h%'), // hex string ], 'timestamp' => Expect::type('int'), ], $apiResponse); // Database result validation $dbRow = [ 'id' => 1, 'username' => 'admin', 'password_hash' => '$2y$10$abcdefghijklmnopqrstuv', 'last_login' => '2024-01-15 14:30:00', 'login_count' => 42, 'is_active' => 1, ]; Assert::equal([ 'id' => Expect::type('int'), 'username' => Expect::type('string')->andContains('admin'), 'password_hash' => Expect::match('$2y$%a%'), // bcrypt hash 'last_login' => Expect::match('%d%-%d%-%d% %d%:%d%:%d%'), 'login_count' => Expect::type('int'), 'is_active' => Expect::that(fn($v) => $v === 0 || $v === 1), ], $dbRow); // Custom validation with closure $product = [ 'id' => 123, 'name' => 'Laptop', 'price' => 999.99, 'stock' => 5, ]; Assert::equal([ 'id' => Expect::type('int'), 'name' => Expect::type('string'), 'price' => Expect::that(fn($price) => $price > 0 && $price < 10000), 'stock' => Expect::that(fn($stock) => $stock >= 0), ], $product); // Chaining multiple expectations $data = [ 'items' => [1, 2, 3, 4, 5], 'total' => 15, ]; Assert::equal([ 'items' => Expect::type('array')->andCount(5), 'total' => Expect::type('int')->andSame(15), ], $data); // Testing nested arrays with Expect $nested = [ 'level1' => [ 'level2' => [ 'level3' => [ 'value' => 'deep', 'count' => 3, ], ], ], ]; Assert::equal([ 'level1' => [ 'level2' => [ 'level3' => [ 'value' => Expect::type('string'), 'count' => Expect::type('int')->andSame(3), ], ], ], ], $nested); ``` ## Summary Nette Tester provides a comprehensive testing solution for PHP applications with three primary use cases: unit testing with rich assertions, integration testing with TestCase classes and data providers, and HTTP/DOM testing for web applications. The framework excels at testing individual functions with Assert methods, organizing complex test suites with TestCase lifecycle hooks, and validating API responses and HTML output through HttpAssert and DomQuery helpers. Integration patterns are straightforward across all test styles. Simple tests use direct assertions in `.phpt` files executable as PHP scripts. Complex test suites leverage TestCase classes with setUp/tearDown methods and external data providers from INI or PHP files. The framework integrates seamlessly with CI/CD pipelines through multiple output formats (TAP, JUnit XML) and parallel execution capabilities. Code coverage collection works transparently with Xdebug, PCOV, or PHPDBG, aggregating results across parallel test processes. Tests can be run individually during development or orchestrated through the test runner for full suite execution with sophisticated filtering, caching, and result reporting.