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 scenarios — calculatesDiscountForVipCustomer 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.