원클릭으로
testo-write-tests
// Write or modify tests in a project that uses the Testo PHP testing framework. Use when adding a
// Write or modify tests in a project that uses the Testo PHP testing framework. Use when adding a
Stabilize flaky Testo tests with
Migrate an existing PHPUnit test suite to Testo. 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.
Author a Testo plugin — event listeners, interceptors, custom container bindings, or new test attributes. Use when the user wants to extend Testo's behaviour (custom reporters, lifecycle hooks across the suite, attribute-driven middleware, integrating an external system) rather than writing a single test.
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".
Parameterize Testo tests with
Write or tune Testo performance benchmarks with
| 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);
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.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.
vendor/bin/testo # all suites
vendor/bin/testo --suite=Unit # one suite
vendor/bin/testo --filter='UserServiceTest' # by name
vendor/bin/testo --path=tests/Unit/UserService # by path
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.