lucinda/unit-testing

Lightweight PHP8.1 library for unit testing with zero dependencies

Maintainers

Package info

github.com/aherne/unit-testing

pkg:composer/lucinda/unit-testing

Statistics

Installs: 21 545

Dependents: 21

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.7 2026-06-19 07:03 UTC

README

Lightweight PHP 8.1 unit testing library with no package dependencies. It reads an XML configuration, mirrors source classes into test classes, runs matching test methods, and reports Lucinda\UnitTest\Result objects in the console or as JSON.

Requirements

  • PHP 8.1 or newer
  • PHP extensions: SimpleXML, cURL, PDO, tokenizer
  • Composer PSR-4 autoloading for Lucinda\UnitTest\

Install it in the project that owns the code you want to test:

composer require lucinda/unit-testing

Configuration

Create an XML file, commonly unit-tests.xml, that describes one or more source/test folder pairs:

<?xml version="1.0" encoding="utf-8"?>
<xml>
    <unit_tests>
        <unit_test>
            <sources path="src" namespace="App\"/>
            <tests path="tests" namespace="Test\App\"/>
        </unit_test>
    </unit_tests>

    <servers>
        <sql>
            <local>
                <server
                    driver="mysql"
                    host="127.0.0.1"
                    port="3306"
                    username="root"
                    password=""
                    schema="test_schema"
                    charset="utf8mb4"
                />
            </local>
        </sql>
    </servers>
</xml>

unit_tests is required. Each unit_test entry requires sources and tests tags with non-empty namespace attributes. The path attributes default to src and tests; the source path must already exist, while a missing tests path is created automatically.

The optional servers/sql/{environment}/server entry configures the SQL validator. The environment name is the second argument passed to a controller, for example local.

Running Tests

Create a small runner script in the consuming project:

<?php

require __DIR__."/vendor/autoload.php";

try {
    new Lucinda\UnitTest\ConsoleController(__DIR__."/unit-tests.xml", "local");
} catch (Lucinda\UnitTest\Exception $e) {
    echo $e->getMessage().PHP_EOL;
}

Then run it:

php test.php

You can run a single generated test class by passing its fully qualified name as the third controller argument:

new Lucinda\UnitTest\ConsoleController(
    __DIR__."/unit-tests.xml",
    "local",
    Test\App\Service\UserServiceTest::class
);

Runtime Flow

Lucinda\UnitTest\Controller coordinates the whole process:

  1. Configuration reads the XML file, validates configured APIs, and builds an optional SQL data source for the selected environment.
  2. Creator scans configured source folders and creates or updates missing test classes and methods.
  3. Runner scans sources and tests, executes matching test methods, and collects Runner\UnitTest rows.
  4. A concrete controller displays or exports the collected results.

The package includes two controllers:

Class Output
Lucinda\UnitTest\ConsoleController Text table with passed/failed totals. Uses ANSI colors on non-Windows systems.
Lucinda\UnitTest\JsonController JSON array containing class, method, passed, and message.

You can also extend Controller and implement handle(array $results): void to store or render results differently.

Generated Tests

The generator scans PHP files under each configured source path. Concrete classes are mirrored into the tests path using the same relative file structure, the source class name with Test appended, and a Test\ namespace prefix.

For example:

src/Service/UserService.php      App\Service\UserService
tests/Service/UserServiceTest.php Test\App\Service\UserServiceTest

Only concrete classes are generated and run. Abstract classes, interfaces, and enums are skipped. Public methods are mirrored, excluding __construct and __destruct. Public inherited and implemented interface methods are included when the parent/interface is found in the configured scan results.

Generated methods are intentionally empty:

<?php
namespace Test\App\Service;
    
class UserServiceTest
{
    public function create()
    {
    }
}

Fill each generated method with assertions. A test method must return either one Lucinda\UnitTest\Result or a non-empty array of Result objects:

<?php
namespace Test\App\Service;

use App\Service\UserService;
use Lucinda\UnitTest\Result;
use Lucinda\UnitTest\Validator\Arrays;
use Lucinda\UnitTest\Validator\Strings;

class UserServiceTest
{
    public function create(): Result
    {
        $service = new UserService();
        $id = $service->create("ada@example.com");

        return (new Strings($id))->assertNotEmpty("Created user id should not be empty");
    }

    /**
     * @return Result[]
     */
    public function list(): array
    {
        $service = new UserService();
        $users = $service->list();

        return [
            (new Arrays($users))->assertNotEmpty("Users list should not be empty"),
            (new Arrays($users[0]))->assertContainsKey("email", "First user should expose email")
        ];
    }
}

If a source class or source method is not covered, or if a test method returns something other than Result or Result[], the runner records a failed result.

Additional public methods that exist only on the test class are also executed. This is useful for setup-specific checks that do not directly mirror one source method.

Results

Lucinda\UnitTest\Result is the common assertion payload:

new Lucinda\UnitTest\Result(true, "optional message");

Use hasPassed(): bool to read status and getMessage(): string to read the message. Validators return this object directly.

Validators

Primitive and filesystem validators wrap a value and expose assertion methods that return Result.

Validator Assertions
Validator\Booleans assertTrue, assertFalse
Validator\Integers assertEquals, assertNotEquals, assertGreater, assertGreaterEquals, assertSmaller, assertSmallerEquals
Validator\Floats assertEquals, assertNotEquals, assertGreater, assertGreaterEquals, assertSmaller, assertSmallerEquals
Validator\Strings assertEmpty, assertNotEmpty, assertEquals, assertNotEquals, assertContains, assertNotContains, assertSize, assertNotSize
Validator\Arrays assertEmpty, assertNotEmpty, assertEquals, assertNotEquals, assertIdentical, assertNotIdentical, assertContainsKey, assertNotContainsKey, assertContainsValue, assertNotContainsValue, assertSize, assertNotSize
Validator\Objects assertInstanceOf, assertNotInstanceOf
Validator\Files assertExists, assertNotExists, assertEquals, assertNotEquals, assertContains, assertNotContains, assertSize, assertNotSize

Examples:

return (new Lucinda\UnitTest\Validator\Integers($count))->assertGreater(0);
return (new Lucinda\UnitTest\Validator\Files(__DIR__."/fixture.json"))
    ->assertContains('"enabled":true');

SQL Assertions

SQL assertions use a shared Validator\SQL instance configured from XML by the runner. The connection is opened through PDO, starts a transaction, and rolls back when the instance is destroyed.

use Lucinda\UnitTest\Result;
use Lucinda\UnitTest\Validator\Integers;
use Lucinda\UnitTest\Validator\SQL;
use Lucinda\UnitTest\Validator\SQL\ResultValidator;

return SQL::getInstance()->assertPreparedStatement(
    "SELECT COUNT(*) FROM users WHERE active = :active",
    [":active" => "1"],
    new class implements ResultValidator {
        public function validate(\PDOStatement $statementResults): Result
        {
            return (new Integers((int) $statementResults->fetchColumn()))->assertGreater(0);
        }
    }
);

If no SQL data source is configured, SQL::getInstance() throws Lucinda\UnitTest\Exception.

URL Assertions

URL assertions use cURL through Validator\URL. Configure a Validator\URL\DataSource, run the request, and validate the Validator\URL\Response.

use Lucinda\UnitTest\Result;
use Lucinda\UnitTest\Validator\Integers;
use Lucinda\UnitTest\Validator\URL;
use Lucinda\UnitTest\Validator\URL\DataSource;
use Lucinda\UnitTest\Validator\URL\Response;
use Lucinda\UnitTest\Validator\URL\ResultValidator;

$dataSource = new DataSource("https://example.com/health");
$dataSource->setRequestMethod("GET");
$dataSource->addRequestHeader("Accept", "application/json");

return (new URL($dataSource))->assert(
    new class implements ResultValidator {
        public function validate(Response $response): Result
        {
            return (new Integers($response->getStatus()))->assertEquals(200);
        }
    }
);

Supported request methods are GET, POST, PUT, HEAD, DELETE, PATCH, and OPTIONS. GET parameters are appended as a query string; other request parameters are sent as URL-encoded fields. Requests time out after 10 seconds.

Notes And Constraints

  • The runner expects test classes to be autoloadable before it instantiates them.
  • Class discovery is file based and scans .php files recursively.
  • The source/test namespace attributes are required by configuration, but generated class names are derived from parsed source classes and the hard-coded Test\ prefix.
  • Creator writes missing classes and appends missing methods to existing test files. Review generated files before committing them.
  • ConsoleController uses the package's internal Message\Table renderer; there is no external console table dependency.