A lightweight, modern PHP testing framework built from scratch.
Probe is a minimalist test runner for PHP 8.4+ that focuses on simplicity and readability. No bloat, no magic—just clean, understandable test code.
- Fluent assertions - Chainable
ck()syntax for readable tests - Exception testing - Test expected failures with
throws() - set_up/tear_down - Prepare and clean test environments
- Standalone - No dependencies, just pure PHP
- Self-contained - One class per test file, simple structure
- PHP 8.4+
- Composer (optional)
composer require --dev flo/probe- Clone or download Probe
- Add to your project's
tests/directory - Create
bin/probeexecutable
Create tests/Example_test.php:
<?php
declare(strict_types=1);
namespace MyApp\Tests;
use Probe\Attributes\Test;
use Probe\Test_fixture;
class Example_test extends Test_fixture
{
#[Test]
public function addition_works()
{
$result = 2 + 2;
ck($result)->eq(4);
}
#[Test]
public function strings_can_be_checked()
{
ck("Hello World")->is_str()->contains("World");
}
}php bin/probeOutput:
> Example_test
OK - addition_works
OK - strings_can_be_checked
===
> Passed: 2
> Failed: 0
> Total: 2
===
\o/ ALL TESTS PASSED
Every test class must:
- Extend
Test_fixture - Mark test methods with
#[Test]attribute - Use
ck()for assertions
use Probe\Attributes\Test;
use Probe\Test_fixture;
class My_test extends Test_fixture
{
#[Test]
public function my_test_method()
{
// Your test code here
ck($value)->eq($expected);
}
}- Test files:
*_test.php(e.g.,User_test.php) - Test classes:
*_test(e.g.,User_test) - Test methods: Descriptive names (e.g.,
user_can_login,validates_email_format)
Probe uses the ck() function for all assertions. Assertions are chainable for readability.
ck($value)->is_int(); // Value must be integer
ck($value)->is_float(); // Value must be float
ck($value)->is_str(); // Value must be string
ck($value)->is_bool(); // Value must be boolean
ck($value)->is_array(); // Value must be array
ck($value)->is_object(); // Value must be object
ck($value)->is_callable(); // Value must be callableck($user)->is_instance_of(User::class); // Must be instance of User
ck($request)->is_instance_of(Request::class); // Must be instance of Requestck($actual)->eq($expected); // Strict equality (===)
ck($actual)->ne($expected); // Not equal (!==)ck($value)->gt(10); // Greater than (>)
ck($value)->ge(10); // Greater or equal (>=)
ck($value)->lt(10); // Less than (<)
ck($value)->le(10); // Less or equal (<=)ck($string)->contains("substring"); // String contains substring
ck($path)->starts_with("/api/"); // String starts with prefix
ck($email)->ends_with("@example.com"); // String ends with suffix
ck($email)->matches('/^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$/'); // String matches regex
ck($string)->is_empty(); // String is emptyck($value)->is_true(); // Strictly TRUE (not truthy)
ck($value)->is_false(); // Strictly FALSE (not falsy)
ck($value)->is_null(); // Value is NULL
ck($value)->is_empty(); // Value is emptyck($array)->has_count(3); // Array has exactly 3 elements
ck($config)->has_key('database'); // Array has key 'database'
ck($method)->in(['GET', 'POST', 'PUT', 'DELETE']); // Value is in arrayck($path)->is_file(); // Path points to an existing fileAll assertions return $this, allowing you to chain multiple checks:
ck($age)->is_int()->gt(18)->le(65);
ck($email)->is_str()->contains("@")->ends_with(".com");
ck($path)->is_str()->starts_with("/api/")->matches('!^/api/v\d+/!');
ck($user)->is_object()->is_instance_of(User::class);Use throws() to test that code throws an exception:
#[Test]
public function division_by_zero_throws()
{
$this->throws(); // Declare that an exception is expected
$result = 10 / 0; // This will throw DivisionByZeroError
}
#[Test]
public function invalid_user_throws()
{
$this->throws();
ck("")->is_int(); // This will throw Assertion_exception
}By default, throws() expects Assertion_exception. To expect a different exception:
#[Test]
public function throws_custom_exception()
{
$this->throws(\InvalidArgumentException::class);
throw new \InvalidArgumentException("Invalid input");
}Use set_up() and tear_down() to prepare and clean up your test environment.
Called before each test in the class:
class Database_test extends Test_fixture
{
private Database $db;
protected function set_up(): void
{
// Create a fresh database for each test
$this->db = new Database(':memory:');
$this->db->migrate();
}
#[Test]
public function can_insert_user()
{
$this->db->insert('users', ['name' => 'John']);
ck($this->db->count('users'))->eq(1);
}
#[Test]
public function can_delete_user()
{
$this->db->insert('users', ['name' => 'Jane']);
$this->db->delete('users', 1);
ck($this->db->count('users'))->eq(0);
}
}Called after each test (even if the test fails):
protected function tear_down(): void
{
// Clean up resources
$this->db->close();
unset($this->db);
}For each test method, Probe executes:
reset()- Reset exception expectationsset_up()- Prepare test environment- Test method - Your actual test
tear_down()- Clean up (always runs, even on failure)
set_up()andtear_down()are optional- Each test gets a fresh environment (set_up is called before EACH test)
- If
set_up()throws an exception, the test fails andtear_down()still runs - If
tear_down()throws an exception, a warning is shown but the test result is preserved
Run all tests in the tests/ directory:
php bin/probeWhen running tests from an AI agent (Claude Code, etc.), use --ai to get a compact, token-efficient output:
php bin/probe --aiAll tests pass:
OK 42/42
Some tests fail:
FAIL 2/42
NOK Checker_test::eq_strict > Expected 5 === "5" [Checker_test.php:34]
NOK User_test::validates_email > Expected string to contain "@" [User_test.php:18]
No headers, no per-test OK lines — only what matters.
Probe automatically discovers:
- All
.phpfiles intests/directory (recursively) - All classes with methods marked with
#[Test] - Executes tests and reports results
0- All tests passed1- One or more tests failed
Use this in CI/CD pipelines:
php bin/probe || exit 1<?php
declare(strict_types=1);
namespace MyApp\Tests;
use Probe\Attributes\Test;
use Probe\Test_fixture;
use MyApp\User;
class User_test extends Test_fixture
{
private User $user;
protected function set_up(): void
{
$this->user = new User('john@example.com', 'password123');
}
#[Test]
public function user_has_email()
{
ck($this->user->email)->eq('john@example.com');
}
#[Test]
public function user_can_change_password()
{
$this->user->changePassword('newpass456');
ck($this->user->verifyPassword('newpass456'))->is_true();
}
#[Test]
public function empty_email_throws()
{
$this->throws(\InvalidArgumentException::class);
new User('', 'password');
}
#[Test]
public function weak_password_fails()
{
$this->throws();
$this->user->changePassword('123');
}
protected function tear_down(): void
{
unset($this->user);
}
}> User_test
OK - user_has_email
OK - user_can_change_password
OK - empty_email_throws
OK - weak_password_fails
===
> Passed: 4
> Failed: 0
> Total: 4
===
\o/ ALL TESTS PASSED
> User_test
OK - user_has_email
NOK - user_can_change_password
OK - empty_email_throws
===
> Passed: 2
> Failed: 1
> Total: 3
===
> Tests échoués
User_test::user_can_change_password
> Expected TRUE, got FALSE
[42] - /path/to/User.php
===
X_X SOME TESTS FAILED
- Auto-discovery of test files
-
#[Test]attribute - Fluent assertions with
ck() - Exception testing with
throws() -
set_up()/tear_down()lifecycle - Extended type checks (float, object, callable, is_instance_of)
- String assertions (starts_with, ends_with, matches)
- Array assertions (has_key, in)
- AI mode (
--ai) — compact, token-efficient output for AI agents
- Data providers — run the same test logic with multiple datasets
- Code coverage
- Custom assertion failure messages
Probe is built with these principles:
- Simplicity - Easy to understand, no magic
- Readability - Tests should read like documentation
- Zero config - Works out of the box
- Modern PHP - Leverage modern PHP features
- Educational - Learn by reading the source code
MIT — see LICENSE.
Inspired by:
- PHPUnit - The standard PHP testing framework
- Pest - Modern, elegant testing
- xUnit - The foundation of test frameworks
Built with ❤️ by flo418 as a learning journey into test framework design.
Happy Testing o/