memran/php-testify

A modern, expressive testing library for PHP that makes writing tests enjoyable and readable.

Installs: 12

Dependents: 2

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/memran/php-testify

v1.0.0 2025-10-25 15:28 UTC

This package is auto-updated.

Last update: 2025-10-25 17:32:46 UTC


README

A modern, expressive testing library for PHP with a fluent and intuitive API, built on top of PHPUnit.

📦 Stats & Status

PHP Version License Downloads Version

Supported PHP Versions: 8.0, 8.1, 8.2, 8.3, 8.4

Installation

composer require memran/php-testify --dev

Configuration

Create phpunit.config.php in your project root:

<?php

return [
    'bootstrap' => __DIR__ . '/vendor/autoload.php',
    'test_patterns' => [
        __DIR__ . '/tests/*Test.php',     // PHPUnit-style classes
        __DIR__ . '/tests/*_test.php',    // describe/it style
    ],
];

Quick Start

Create your first test file:

<?php
// tests/ExampleTest.php

describe('Array operations', function() {
    $array = [];

    beforeEach(function() use (&$array) {
        $array = [1, 2, 3];
    });

    test('array should have initial values', function() use (&$array) {
        expect($array)->toHaveLength(3);
        expect($array)->toContain(2);
        expect($array[0])->toBe(1);
    });

    it('should allow adding elements', function() use (&$array) {
        $array[] = 4;
        expect($array)->toHaveLength(4);
        expect($array)->toContain(4);
    });
});

Run your tests:

php tests/ExampleTest.php

👀 Watch Mode

php-testify includes a built-in watch mode — just like Vitest or Jest — for instant feedback during development.

When enabled, it automatically re-runs your tests whenever any source or test file changes.

🧠 How it Works

  • Monitors all .php files in your src/ and tests/ directories.
  • Detects file changes using a lightweight polling system (works on Windows, macOS, and Linux).
  • Spawns a fresh PHP process for each re-run — ensuring a clean test environment.
  • Clears the screen before each re-run and prints a banner for visibility.
  • Keeps running until you stop it manually (Ctrl + C).

▶️ Run in Watch Mode

composer test -- --watch

Core API

Test Structure

Organize your tests using describe blocks:

describe('User authentication', function() {
    describe('Login functionality', function() {
        // Tests go here
    });

    describe('Password reset', function() {
        // Tests go here
    });
});

Writing Tests

Use test or it to define individual test cases:

<?php
test('user can login with valid credentials', function() {
// Test implementation
});

it('should reject invalid passwords', function() {
// Test implementation
});

Assertions

PHP-Testify provides a fluent assertion API:

expect($value)->toBe(5);
expect($value)->toBeTrue();
expect($value)->toBeFalse();
expect($value)->toBeNull();
expect($value)->toBeTruthy();
expect($value)->toBeFalsy();
expect($value)->toEqual(['key' => 'value']);
expect($value)->toBeGreaterThan(10);
expect($value)->toBeLessThan(20);
expect($string)->toContain('substring');
expect($array)->toContain('item');
expect($string)->toHaveLength(10);
expect($array)->toHaveLength(5);
expect($function)->toThrow(InvalidArgumentException::class);
expect($object)->toBeInstanceOf(User::class);

Negative Assertions

Use not() for negative assertions:

expect($value)->not()->toBeNull();
expect($array)->not()->toContain('forbidden');
expect($string)->not()->toHaveLength(0);

Lifecycle Hooks

Set up and tear down your test environment:

describe('Database tests', function() {
    beforeAll(function() {
        // Runs once before all tests in this block
        setupDatabase();
    });

    afterAll(function() {
        // Runs once after all tests in this block
        cleanupDatabase();
    });

    beforeEach(function() {
        // Runs before each test
        startTransaction();
    });

    afterEach(function() {
        // Runs after each test
        rollbackTransaction();
    });

    test('database operation', function() {
        // Test that uses database
    });
});

Complete Example

<?php

use function Testify\describe;
use function Testify\it;
use function Testify\expect;
use function Testify\beforeAll;
use function Testify\afterAll;
use function Testify\beforeEach;
use function Testify\afterEach;

class DummyUser
{
    public string $name = 'Ada';
}

describe('php-testify expectation API', function () {
    // we'll mutate these in hooks to prove hooks work
    $shared = [
        'bootCount' => 0,
        'eachCount' => 0,
        'cleanup'   => [],
        'numbers'   => [],
    ];

    beforeAll(function () use (&$shared) {
        // runs once before all tests
        $shared['bootCount']++;
        $shared['numbers'] = [2, 4, 6];
    });

    afterAll(function () use (&$shared) {
        // runs once after all tests
        $shared['cleanup'][] = 'afterAll-called';
        // final assertion on lifecycle
        expect($shared['bootCount'])->toBe(1);
        expect($shared['cleanup'])->toContain('afterAll-called');
    });

    beforeEach(function () use (&$shared) {
        // runs before every it()
        $shared['eachCount']++;
        $shared['x'] = 10;
        $shared['y'] = 5;
        $shared['str'] = 'hello world';
        $shared['arr'] = ['alpha', 'beta', 'gamma'];
        $shared['user'] = new DummyUser();
        $shared['nullish'] = null;
    });

    afterEach(function () use (&$shared) {
        // runs after every it()
        // prove that something happened during test, then clean it
        if (isset($shared['dirty'])) {
            unset($shared['dirty']);
        }
    });

    it('toBe / toEqual basics', function () use (&$shared) {
        expect($shared['x'])->toBe(10);
        expect($shared['x'])->toEqual(10); // == is same in this case

        // Arrays: === would fail, but toEqual uses loose equality (==)
        $a = ['key' => 'value'];
        $b = ['key' => 'value'];
        expect($a)->toEqual($b);

        // sanity: strict equality vs loose (just to confirm not() also works)
        expect($a)->not()->toBe($b);
    });

    it('truthiness / falsiness / null', function () use (&$shared) {
        expect(true)->toBeTrue();
        expect(false)->toBeFalse();
        expect($shared['nullish'])->toBeNull();

        expect(1)->toBeTruthy();
        expect("nonempty")->toBeTruthy();
        expect(0)->toBeFalsy();
        expect("")->toBeFalsy();

        // negated versions
        expect($shared['nullish'])->not()->toBeTruthy();
        expect("")->not()->toBeTruthy();
        expect("x")->not()->toBeFalsy();
    });

    it('numeric comparisons', function () use (&$shared) {
        expect($shared['x'])->toBeGreaterThan($shared['y']); // 10 > 5
        expect($shared['y'])->toBeLessThan($shared['x']);   // 5 < 10

        // negated
        expect($shared['y'])->not()->toBeGreaterThan($shared['x']);
        expect($shared['x'])->not()->toBeLessThan($shared['y']);
    });

    it('containment and lengths', function () use (&$shared) {
        // strings
        expect($shared['str'])->toContain('hello');
        expect($shared['str'])->toContain('world');

        // arrays
        expect($shared['arr'])->toContain('alpha');
        expect($shared['arr'])->toContain('beta');

        // negated contain
        expect($shared['arr'])->not()->toContain('delta');
        expect($shared['str'])->not()->toContain('nope');

        // lengths
        expect($shared['str'])->toHaveLength(11); // "hello world" length 11
        expect($shared['arr'])->toHaveLength(3);

        // negated length
        expect($shared['arr'])->not()->toHaveLength(99);
        expect($shared['str'])->not()->toHaveLength(0);
    });

    it('instance and class checks', function () use (&$shared) {
        expect($shared['user'])->toBeInstanceOf(DummyUser::class);
        expect($shared['user'])->not()->toBeInstanceOf(\stdClass::class);
    });

    it('exception expectations with toThrow', function () {
        $willThrow = function () {
            throw new InvalidArgumentException("bad arg");
        };

        // should PASS
        expect($willThrow)->toThrow(InvalidArgumentException::class);

        // and negation should PASS because different exception
        $wontThrowThis = function () {
            throw new RuntimeException("other");
        };
        expect($wontThrowThis)->not()->toThrow(InvalidArgumentException::class);

        // also ensure something that throws anything matches default Throwable::class
        $anyThrow = function () {
            throw new \LogicException("xxx");
        };
        expect($anyThrow)->toThrow(\Throwable::class);
    });

    it('lifecycle hooks actually mutated shared state', function () use (&$shared) {
        // beforeAll ran once at entire suite start
        expect($shared['bootCount'])->toBe(1);

        // beforeEach increments eachCount for each test
        expect($shared['eachCount'])->toBeGreaterThan(0);

        // we can "dirty" something to prove afterEach cleans it next test
        $shared['dirty'] = 'temp-marker';

        // data from beforeAll should still exist
        expect($shared['numbers'])->toContain(2);
        expect($shared['numbers'])->toContain(6);
        expect($shared['numbers'])->not()->toContain(999);
    });

    it('afterEach cleaned previous dirty state', function () use (&$shared) {
        // If afterEach ran after last test, "dirty" should be gone now.
        // We simulate expectation with negated truthy.
        $isDirtyPresent = array_key_exists('dirty', $shared);
        expect($isDirtyPresent)->toBeFalse();
        expect($isDirtyPresent)->not()->toBeTrue();
    });
});

Best Practices

  • Descriptive test names: Use clear, descriptive names for describe blocks and tests
  • One assertion per test: Focus each test on a single behavior
  • Use lifecycle hooks: Set up test data in beforeEach rather than repeating code
  • Keep tests independent: Tests should not depend on each other
  • Test edge cases: Include tests for error conditions and boundary cases

Available Commands

# Run all tests
composer test

Contributing

Contributions are welcome! Please feel free to submit pull requests or open issues for bugs and feature requests.

License

MIT License - see LICENSE file for details.