بنقرة واحدة
testo-write-tests
Write or modify tests in a project that uses the Testo PHP testing framework. Use when adding a
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
Write or modify tests in a project that uses the Testo PHP testing framework. Use when adding a
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
Migrate an existing PHPUnit (or Pest) test suite to Testo — via the Rector bridge, via AI-agent rewriting, or a combination. Use when the user says "migrate from PHPUnit", "port phpunit tests", "convert TestCase to Testo", or has files extending PHPUnit\Framework\TestCase that need to move to Testo's attribute-based style.
Set up or edit `testo.php` — the Testo application config. Use when the user is bootstrapping a project (including running `vendor/bin/testo init`), adding/removing a suite, scoping a finder, wiring an application-wide plugin (coverage, JUnit), or asking "where do I configure Testo" / "how do I initialize Testo".
Report and raise line coverage on a Testo project — collect coverage once, aggregate the Clover report into a ranked, LLM-friendly work-list, then optionally write tests for the least-covered files with subagents. Use both for read-only coverage questions and for improving coverage. Triggers are "increase coverage", "improve test coverage", "cover untested code", "find uncovered lines", "raise the coverage percentage", "what isn't tested", "what's our test coverage", "coverage status/report", "how well tested is X", "is X covered".
Author a Testo plugin — interceptors (middleware), event listeners, container bindings, custom attributes, or full test-environment provisioning. Use when the user wants to extend how Testo runs tests (wrap every test, provision a database/service, custom reporters, attribute-driven behaviour, integrating an external system) rather than writing a single test.
Write or tune Testo performance benchmarks with
Run mutation testing on a Testo project with Infection (via testo/bridge-infection), collect surviving mutants efficiently, and kill them by strengthening tests. Use when the user asks about "mutation testing", "infection", "MSI", "mutation score", "escaped mutants", "kill mutants", or wants to measure how good the tests really are.
| name | testo-write-tests |
| description | Write or modify tests in a project that uses the Testo PHP testing framework. Use when adding a |
Testo is not PHPUnit. The attribute set, assertion facade, exception expectations, and lifecycle hooks are Testo's own — do not transliterate PHPUnit idioms.
Fetch the canonical API surface (cached for 15 min):
https://php-testo.github.io/llms.txt — concise index. Always start here.https://php-testo.github.io/llms-full.txt — escalate when llms.txt doesn't answer the question.If the project ships an AGENTS.md, honour it.
<?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('Alice', $user->name);
}
}
Hard rules:
#[Test] when every public method is a test (preferred). Method-level #[Test] when only some are.final class by default.void or never under a #[Test] class are auto-discovered as tests.#[Covers(...)] at class level when all tests cover the same class; at method level when they differ.// Arrange, // Act, // Assert comments.src/Foo/Bar.php → tests/Unit/Foo/BarTest.php (or wherever the suite finder is rooted).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::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::array($a)->hasKeys(['id', 'name'])->isList()->hasCount(3)->contains('x')->notContains('y');
Assert::object($o)->instanceOf(Foo::class)->hasProperty('id');
Assert::json($s)->isObject()->hasKeys(['data', 'meta'])->assertPath('$.data.id', 42);
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.
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's SkipTest.Constraints:
#[BeforeTest]/#[AfterTest] hook bubbles out of the pipeline and is treated as Status::Aborted instead. To skip from a hook, leave the precondition check inside the test body.try/catch them inside the test, just throw.class MissingExtensionSkip extends SkipTest {} is still recognized.void, or never if the throw is unconditional.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) |
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 { /* ... */ }
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. Select with --group (OR across
values); prefix with ! to exclude. Group filters AND with name/path/suite filters.
vendor/bin/testo --json # all suites
vendor/bin/testo --json --suite=Unit # one suite
vendor/bin/testo --json --filter='UserServiceTest' # by name
vendor/bin/testo --json --path=tests/Unit/UserService # by path
vendor/bin/testo --json --group=integration # only the "integration" group
vendor/bin/testo --json --group=!slow # everything except the "slow" group
Always pass --json (as above) for a compact, machine-readable report that's cheap to parse — a run
summary plus the failed tests. Use --log-json=build/report.json to write it to a file while keeping
the terminal output.
Use the Testo CLI, never phpunit.
enums or final classes — instantiate real ones.llms.txt, escalate to llms-full.txt before guessing.setUp/tearDown — use the lifecycle attributes above.testo-data-driven skill.testo-flaky-tests skill.Expect::exception(...) before the throwing call — never wrap in try/catch.