ForgeTesting

A CLI testing capability your app uses to verify its own code — no extra test framework to install. Mark up your tests with attributes and run them with a single command.

Overview

ForgeTesting is a lightweight test runner built for the Kernel. You write test classes that extend a TestCase, mark the methods you want run with attributes, and execute them with php forge.php test. It handles unit and integration tests for your app, your capabilities, and even the Kernel itself — all from one command.

A few characteristics worth knowing up front:

  • Attribute-driven: a method only runs when it carries #[Test].
  • No separate test framework or vendor binary — the runner ships with the capability.
  • Lifecycle hooks (#[BeforeEach] / #[AfterEach]) plus data providers and test dependencies.
  • Built-in helpers for database, performance, cache, and HTTP assertions.
  • Colorful CLI report: passed, failed, skipped, incomplete, slowest tests, and benchmarks.

Framing: this is the runner your app uses to test itself. Whatever framework style you prefer (PHPUnit, a custom assert suite), you keep writing your own test code — ForgeTesting just makes it discoverable and runnable.

Installation

php forge.php package:install-module --module=forge-testing

Once installed, the test command is available and your test directories are picked up automatically.

Running Tests

The test command discovers files ending in Test.php in the directories for the scope you choose:

php forge.php test                          # app tests (default)
php forge.php test --type=kernel           # the Kernel's own tests
php forge.php test --type=module           # tests across all modules
php forge.php test --type=module --module=users
php forge.php test --group=unit            # only tests in the 'unit' group
Option What it selects
--type=app (default) Tests in app/tests/
--type=kernel Tests in kernel/tests/
--type=module Tests in {module}/src/tests/ across a module root
--module=NAME With --type=module, narrow to one (or several) module(s)
--group=NAME Only run tests whose class or method carries that #[Group]

Results print to the console and the command exits with a non-zero code if any test fails — handy for CI. It also exits fast by caching discovered test classes (the cache lives under storage/framework/cache/) so repeated runs stay quick.

Writing Tests

A test is a class that extends TestCase; each test is a method marked with #[Test]. If a method lacks the attribute, it's not run — which gives you room for helpers that don't look like tests:

namespace App\Tests;

use Modules\ForgeTesting\TestCase;
use Modules\ForgeTesting\Attributes\Test;

final class UserTest extends TestCase
{
    #[Test(description: 'a user can be marked active')]
    public function can_activate_user(): void
    {
        $user = new User();
        $user->activate();

        $this->assertTrue($user->isActive());
    }
}

The optional description on #[Test] is what shows up in reports in place of the raw method name.

Attributes

The runner is driven entirely by attributes. Here's the full set:

Attribute Applies to Purpose
#[Test(description)] method Marks a method as a test (the only way a method runs)
#[BeforeEach] method Runs before every test in the class
#[AfterEach] method Runs after every test in the class
#[DataProvider(methodName)] method Runs the test once per row returned by a provider method
#[Depends(testMethod)] method Runs another method first, then the test
#[Group(name)] class / method Tags tests so --group= can filter them
#[Skip(reason)] class / method Skips a test, reporting the reason
#[Incomplete(reason)] method Marks a test as not-yet-finished

As an alternative to attributes, TestCase also provides markTestSkipped() and markTestIncomplete() you can call inside a test.

#[DataProvider('provideCases')]
#[Test]
public function order_total_is_correct(int $quantity, int $expected): void
{
    $this->assertEquals($expected, (new Order())->total($quantity));
}

public function provideCases(): array
{
    return [[1, 10], [2, 20], [3, 30]];
}

Assertions

TestCase ships a full set of assertion helpers for everyday checks:

  • assertEquals / assertNotEquals
  • assertSame / assertNotSame
  • assertTrue / assertFalse
  • assertNull / assertNotNull
  • assertEmpty / assertNotEmpty
  • assertCount
  • assertInstanceOf / assertNotInstanceOf
  • assertArrayHasKey / assertArrayNotHasKey
  • assertGreaterThan / assertLessThan (+ OrEqual)
  • assertStringContainsString (and not)
  • assertMatchesRegularExpression (and not)
  • assertContains / assertNotContains
  • assertJsonStringEqualsJsonString
  • assertFileExists / assertFileDoesNotExist
  • assertHttpStatus
  • fail / shouldFail

Database Testing

For tests that touch the database, TestCase gives you helpers for a clean and migratable state plus row-level assertions:

#[Test]
public function order_is_persisted(): void
{
    $this->refreshDatabase();        // run migrations once
    $this->seed(OrdersSeeder::class);

    $this->assertDatabaseHas('orders', ['status' => 'pending']);
    $this->assertDatabaseCount('orders', 1, ['status' => 'pending']);
    $this->assertDatabaseMissing('orders', ['status' => 'shipped']);
}
  • refreshDatabase() — runs migrations (once per run) so your schema is ready.
  • seed(seederClass) — run a seeder.
  • assertDatabaseHas / assertDatabaseMissing / assertDatabaseCount — check rows against column/value criteria.

Performance & Cache

You can guard against slowdowns and benchmarks show up right in your test report:

#[Test]
public function index_stays_fast(): void
{
    // fail if the callable takes longer than 0.2s
    $this->assertMaxExecutionTime(0.2, fn() => $this->service->index());
}

#[Test]
public function hash_benchmark(): void
{
    $result = $this->benchmark(fn() => hash('sha256', 'forge'), iterations: 5000);
    $this->assertLessThan(0.001, $result['avg']);
}

assertMaxExecutionTime(maxSeconds, callable) fails a slow test; benchmark(callable, iterations) returns avg, min, max, and total timings that the runner surfaces as a benchmark table.

For clean state, flushCache() clears framework cache and compiled config/routes, and clearLogs() empties your log files before a run.

A note on metrics: with recordMetrics() and profile() you can attach wall/CPU/ memory figures to a test; the runner groups and prints them as a metric table in the report.

HTTP Testing

ForgeTesting includes an HTTP testing trait that drives requests straight through your router — no server needed. It ships as a separate trait that leans on the routing capability of your app:

use Modules\ForgeTesting\Traits\HttpTesting;

final class DashboardTest extends TestCase
{
    use HttpTesting;

    #[Test]
    public function dashboard_returns_ok(): void
    {
        $response = $this->get('/dashboard');
        $this->assertHttpStatus(200, $response);
    }

    #[Test]
    public function form_posts_with_csrf(): void
    {
        $response = $this->post('/settings', $this->withCsrf(['name' => 'Forge']));
        $this->assertHttpStatus(200, $response);
    }
}

Helpers include get(), post(), and patch() (each returning a response you can assert on), plus withCsrf() and csrfHeaders() for anything that needs a token.

Not enabled by default: the HTTP trait is provided but not composed into TestCase automatically. Add it to the test classes that need it (it requires the router capability to be present).

Test Reports

After the run the runner prints a readable summary for you:

  • A table of passed tests with durations.
  • Failure details with the message and file:line.
  • Skipped and incomplete tests, each with its reason.
  • The five slowest tests, so hot spots stand out.
  • Benchmarks and metric tables when a test produces them.
  • A final summary row: Total / Passed / Failed / Skipped / Incomplete.