Writing tests with Testo
SkillDev toolsWrite or modify tests in a project that uses the Testo PHP testing framework. Use when adding a #[Test] class, writing assertions with the Assert facade, expecting exceptions with Expect, or adding lifecycle hooks (#[BeforeTest], #[AfterTest], #[BeforeClass], #[AfterClass]). Trigger when the user says "write a test", "add a test for X", "test this class", or edits a file under `tests/`.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Writing tests with Testo skill
What this skill tells your AI
The instructions your AI receives, as published by php-testo/testo in skills/testo-write-tests/SKILL.md and read by ahel’s review.
The attribute set, assertion facade, exception expectations, and lifecycle hooks are Testo's own. Write them the Testo way described below — don't transliterate idioms from other test frameworks.
Before you write code
Fetch the canonical API surface:
https://php-testo.github.io/llms.txt— concise index. Always start here.https://php-testo.github.io/llms-full.txt— escalate whenllms.txtdoesn't answer the question.
If the project ships an AGENTS.md, honour it.
Canonical shape of a test class
<?php
declare(strict_types=1);
namespace Tests\Unit;
use Testo\Assert;
use Testo\Codecov\Covers;
use Testo\Test;
use App\UserService;
#[Test]
#[Covers(UserService::class)]
final class UserServiceTest
{
public function createsUserWithGivenName(): void
{
$service = new UserService(new InMemoryRepository());
$user = $service->create('Alice', 'alice@example.com');
Assert::same($user->name, 'Alice');
}
}
Hard rules:
- Class-level
#[Test]when every public method is a test (preferred). Method-level#[Test]when only some are. final classby default.- No base class — Testo does not require one.
- Public methods returning
voidorneverunder a#[Test]class are auto-discovered as tests. - One
#[Covers(...)]at class level when all tests cover the same class; at method level when they differ. - Arrange / Act / Assert separated by a single blank line. Do not write
// Arrange,// Act,// Assertcomments. - File path mirrors the source:
src/Foo/Bar.php→tests/Unit/Foo/BarTest.php(or wherever the suite finder is rooted).
Assert facade (immediate checks)
Use the Testo\Assert facade for in-test checks. Order is actual, expected for same/equals.
Assert::same($user->id, 42);
Assert::notSame($a, $b);
Assert::equals($result, '1'); // loose ==
Assert::true($flag);
Assert::false($flag);
Assert::null($value);
Assert::blank($value); // null, '', [], or 0-count
Assert::notBlank($value); // inverse of blank(); false/0/'0' count as non-blank
Assert::contains($collection, $needle);
Assert::count($collection, 3);
Assert::instanceOf($object, MyClass::class);
Assert::fail('explicit failure');
Typed chains (use when you want a fluent series of checks on one value):
Assert::string($s)->contains('foo')->notContains('bar');
Assert::int($n)->greaterThan(0)->lessThanOrEqual(100);
Assert::numeric($n)->between(1, 100); // int, float, or numeric string
Assert::array($a)->hasKeys('id', 'name')->isList()->hasCount(3)->contains('x')->notContains('y');
Assert::array($a)->sameElementsAs([3, 2, 1]); // order-insensitive, keys ignored
Assert::object($o)->instanceOf(Foo::class)->hasProperty('id');
Assert::json($s)->isObject()->hasKeys(['data', 'meta'])->assertPath('$.data.id', 42);
Expecting exceptions
Use Testo\Expect declared before the Act phase. The test method's return type is never.
use Testo\Expect;
#[Test]
public function rejectsNegativeAmount(): never
{
Expect::exception(InvalidArgumentException::class)
->withMessage('amount must be positive')
->withCode(1001);
new Account(-100);
}
Other Expect modifiers: withMessageContaining(...), withPrevious(class, closure), memory-leak expectations.
Do not use try/catch-based assertions for expected exceptions — Expect::exception is the correct API.
Marking a test as skipped or cancelled
Throw a status-bearing exception from the test body to short-circuit the run with a non-error verdict:
use Testo\Core\Exception\SkipTest;
use Testo\Core\Exception\CancelTest;
#[Test]
public function requiresPdoMysql(): void
{
if (!extension_loaded('pdo_mysql')) {
throw new SkipTest('pdo_mysql required');
}
// ... real test ...
}
SkipTest→Status::Skipped. Use when the test isn't applicable in this environment (missing extension, disabled feature flag, unavailable optional dependency, etc.).CancelTest→Status::Cancelled. Use for cooperative cancellation (deadline expired, Fiber unwind). Not a generic "I don't want to run" — that'sSkipTest.
Constraints:
- Must escape the test method itself. The runner's inner try/catch maps the throw to a status; raising from an interceptor or
#[BeforeTest]/#[AfterTest]hook bubbles out of the pipeline and is treated asStatus::Abortedinstead. To skip from a hook, leave the precondition check inside the test body. - These are not assertions — don't
try/catchthem inside the test, justthrow. - Subclasses work:
class MissingExtensionSkip extends SkipTest {}is still recognized. - Return type stays
void, orneverif the throw is unconditional.
Tests that intentionally perform no assertions
A test that finishes successfully without recording a single assertion is reported as
Status::Risky — the framework assumes you forgot to assert. When a test legitimately verifies
behaviour without the Assert facade (e.g. it only checks that a call does not throw), declare
that intent with #[ExpectNoAssertions] to keep it Status::Passed:
use Testo\Assert\ExpectNoAssertions;
#[Test]
#[ExpectNoAssertions]
public function bootsWithoutError(): void
{
(new Kernel())->boot(); // success is simply "no exception thrown"
}
Place it on a single test — a method or a function. It is not allowed on a class: "no test here
asserts anything" is rarely a real contract, and a stray class-level marker would flip every
genuinely-asserting test to Risky.
The attribute is a two-way contract, not just a switch: a marked test that does record an
assertion is reported as Status::Risky (the declaration is stale or wrong). This includes
Expect::exception(...) / #[ExpectException] — expecting an exception is itself an assertion, so
pairing it with #[ExpectNoAssertions] is contradictory and comes out Risky. Use the attribute
only on tests that truly assert nothing.
#[ExpectNoAssertions] | test records an assertion | status |
|---|---|---|
| no | no | Risky (forgotten assertion) |
| no | yes | Passed |
| yes | no | Passed |
| yes | yes | Risky (stale/misapplied attribute) |
Lifecycle hooks
use Testo\Lifecycle\{BeforeClass, AfterClass, BeforeTest, AfterTest};
#[BeforeClass]
public static function bootSchema(): void { /* once before any test */ }
#[BeforeTest]
public function openTx(): void { /* before each test */ }
#[AfterTest]
public function rollback(): void { /* after each test */ }
#[AfterClass]
public static function dropSchema(): void { /* once after all tests */ }
Hooks may be either instance methods or static — Testo invokes them accordingly. They run regardless of #[Test] on the method.
In a function-based test case (a file of top-level #[Test] functions rather than a class), the same
attributes work on plain functions. The hooks apply to that file's case — #[BeforeClass]/#[AfterClass]
run once around the whole file, #[BeforeTest]/#[AfterTest] around each test function. A lifecycle
function needs no #[Test] and is never itself a test; share state through a static holder, since
functions have no $this.
use Testo\Lifecycle\BeforeTest;
use Testo\Test;
#[BeforeTest]
function openTx(): void { Db::$tx = Db::begin(); } // before each test function in this file
#[Test]
function insertsRow(): void { /* ... */ }
Grouping tests
Label tests with #[Group] (from the testo/filter plugin) to select or skip them by category.
It targets classes, methods, and functions and is variadic (pass several names at once).
use Testo\Filter\Group;
#[Test]
#[Group('driver-mysql')] // inherited by every test of the class
final class MysqlConnectionTest
{
#[Group('slow')] // effective groups: driver-mysql, slow
public function importsLargeDataset(): void { /* ... */ }
}
A test's group set is the union of all groups reachable from it: its own method (and any overridden
parent method), the test class, its parent classes, and traits. Groups are selected at run time with
--group — see the testo-run-tests skill.
Running
Run the test you just wrote through the Testo CLI, always with --json:
vendor/bin/testo --json --filter='UserServiceTest'
Filter selection (--suite/--filter/--path/--group/--type), the JSON report shape, and exit
semantics are covered by the testo-run-tests skill — escalate there before adding other flags.
Pitfalls
- Do not mock
enums orfinalclasses — instantiate real ones. - Do not invent attributes. If you need behaviour you haven't seen in
llms.txt, escalate tollms-full.txtbefore guessing. - Do not write
setUp/tearDown— use the lifecycle attributes above. - For parameterized tests, escalate to the
testo-data-drivenskill. - For flaky-test handling, escalate to the
testo-flaky-testsskill. - For fiber/coroutine or async I/O tests (
\Fiber::suspend(), amphp, Revolt,Future::await()), escalate to thetesto-asyncskill. - For exception assertions, always use
Expect::exception(...)before the throwing call — never wrap in try/catch.
Signals
- GitHub stars
- 213
- Forks
- 16
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
testo-write-tests- Source
- github.com/php-testo/testo