Skip to content
...

Writing Tests

Testo doesn't dictate how or where to write tests. Separate tests in classes and functions, inline tests on production code, benchmarks — all approaches can be combined in one project.

Test Approaches

ApproachDiscoveryWhen to use
Separate tests#[Test]#[Test()]Explicitly marks a method, function, or class as a test. / conventionsUnit, feature, integration
Inline tests#[TestInline]#[TestInline(array $arguments, mixed $result = null)]Declares an inline test on a method or function.Simple checks in application code
Benchmarks#[Bench]#[Bench(array $callables, array $arguments = [], int $warmup = 1, int $calls = 1_000, int $iterations = 10)]Declares a benchmark comparing the method's performance against alternative implementations.Performance comparison

Separate Tests

Tests are most commonly written in classes and functions, separate from the code being tested, in a tests/ directory.

A good test follows the AAA pattern — Arrange, Act, Assert:

php
function calculatesOrderTotal(): void
{
    // Arrange
    $order = new Order();
    $order->addItem('Book', price: 15.0, quantity: 2);
    $order->addItem('Pen', price: 3.0, quantity: 5);

    // Act
    $total = $order->total();

    // Assert
    Assert::same($total, 45.0);
}
php
function throwsOnNegativeAmount(): never
{
    // Arrange
    $account = new Account(balance: 100);

    // Assert — before action
    Expect::exception(InsufficientFundsException::class);

    // Act
    $account->withdraw(200);
}
php
// For simple tests, AAA is overkill
function defaultCurrencyIsUsd(): void
{
    Assert::same(new Money(100)->currency, 'USD');
}

For checks, Testo provides two facades from the Assert plugin:

  • Assert\Testo\Assert — assertions, checked immediately. Supports chained typed checks.
  • Expect\Testo\Expect — expectations, checked after the test completes (exceptions, memory leaks).
php
// Assert — assertions
Assert::same($user->name, 'John');
Assert::true($user->isActive);
Assert::string($email)->contains('@');

// Assert — chained typed checks
Assert::string($response->body)
    ->contains('success')
    ->notContains('error');

// Expect — test behavior expectations
Expect::exception(\RuntimeException::class);
Expect::notLeaks($connection);

Attributes

Instead of base classes or magic methods, Testo bets on attributes.

Visit the plugin pages for detailed information about each attribute and other capabilities.

Naming Conventions

The Convention plugin discovers tests by naming patterns — no attributes needed. By default, *Test suffix on classes and test* prefix on methods:

php
// tests/Unit/OrderTest.php
final class OrderTest
{
    public function testCreatesOrder(): void { /* ... */ }

    public function testCalculatesTotal(): void { /* ... */ }

    public function testAppliesDiscount(): void { /* ... */ }
}

Convention is not included in the default plugin set — enable it if needed.

Practical tips

  • Name tests like scenarioscalculatesDiscountForVipCustomer is clearer than testDiscount. When a test fails, the name is the first thing you'll see.
  • One test — one scenario. Multiple assertions in a test are fine, but multiple scenarios are not. If a test checks both creation and deletion — split it.
  • Stick to AAA (Arrange, Act, Assert). The // Arrange // Act // Assert comments are not required — just separate blocks with blank lines.

Inline Tests

Tests directly on the method being tested using the #[TestInline]#[TestInline(array $arguments, mixed $result = null)]Declares an inline test on a method or function. attribute from the Inline plugin — even a separate test class is not needed:

php
#[TestInline([1, 1], 2)]
#[TestInline([40, 2], 42)]
#[TestInline([-5, 5], 0)]
public static function sum(int $a, int $b): int
{
    return $a + $b;
}

Each attribute runs the method with the given arguments and checks the result. Works even with private methods.

Best for simple pure functions and quick prototyping.

Benchmarks

The #[Bench]#[Bench(array $callables, array $arguments = [], int $warmup = 1, int $calls = 1_000, int $iterations = 10)]Declares a benchmark comparing the method's performance against alternative implementations. attribute from the Bench plugin compares function performance:

php
#[Bench(
    callables: [
        'array' => [self::class, 'sumInArray'],
    ],
    arguments: [1, 5_000],
    calls: 2000,
    iterations: 10,
)]
public static function sumInCycle(int $a, int $b): int
{
    $result = 0;
    for ($i = $a; $i <= $b; ++$i) {
        $result += $i;
    }
    return $result;
}

Testo runs functions the specified number of times, filters outliers, and provides statistics with recommendations.

Folder Structure

Recommended structure, suitable for most applications:

project/
├── src/                  ← inline tests, benchmarks
│   └── ...
└── tests/
    ├── Unit/
    │   └── ...
    ├── Feature/
    │   └── ...
    └── Integration/
        └── ...

Each Suite is not just a separate folder, but a separate SuiteConfig with its own set of plugins. For example:

  • Unit — fast isolated tests, can run in parallel.
  • Feature — require application container, HTTP client, database.
  • Integration — work with real external services, sequential execution.
  • Sources — inline tests and benchmarks in application code.
php
return new ApplicationConfig(
    suites: [
        new SuiteConfig(name: 'Unit', location: ['tests/Unit'], plugins: [/* ... */]),
        new SuiteConfig(name: 'Feature', location: ['tests/Feature'], plugins: [/* ... */]),
        new SuiteConfig(name: 'Integration', location: ['tests/Integration'], plugins: [/* ... */]),
        new SuiteConfig(name: 'Sources', location: ['src'], plugins: [/* ... */]),
    ],
);

In modular architecture, tests can live within modules, with configs combined into one, as in a monorepo.