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.
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:
#[Test].#[BeforeEach] / #[AfterEach]) plus data providers and test dependencies.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.
php forge.php package:install-module --module=forge-testing
Once installed, the test command is
available and your test directories are picked up
automatically.
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.
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.
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]];
}
TestCase ships a full set of assertion
helpers for everyday checks:
assertEquals / assertNotEqualsassertSame / assertNotSameassertTrue / assertFalseassertNull / assertNotNullassertEmpty / assertNotEmptyassertCountassertInstanceOf / assertNotInstanceOfassertArrayHasKey / assertArrayNotHasKeyassertGreaterThan / assertLessThan (+ OrEqual)assertStringContainsString (and not)assertMatchesRegularExpression (and not)assertContains / assertNotContainsassertJsonStringEqualsJsonStringassertFileExists / assertFileDoesNotExistassertHttpStatusfail / shouldFail
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.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.
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).
After the run the runner prints a readable summary for you: