Greenlight

Documentation

Start

On this page

Move from PHPUnit

Greenlight and PHPUnit use different test structures. A migration usually changes the test support code, but much test logic can remain the same. A bundled Rector rule automates the mechanical part of the change. This guide describes the concepts behind the conversion.

Convert tests automatically

Greenlight includes the Greenlight\Rector\PhpUnitToGreenlightRector rule for Rector 2. Install Rector as a development dependency:

composer require --dev rector/rector:^2.5

The rule rewrites final, attribute-based PHPUnit 10+ test classes. It converts the TestCase parent, hooks, attributes, assertions, expectException() blocks, markTestSkipped(), and fail().

Register the rule in a rector.php file that selects your test directories:

<?php

declare(strict_types=1);

use Greenlight\Rector\PhpUnitToGreenlightRector;
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/tests'])
    ->withImportNames(removeUnusedImports: true)
    ->withRules([PhpUnitToGreenlightRector::class]);

The rule converts a final class only when each member has a faithful Greenlight equivalent. All other classes remain valid PHPUnit code, so a suite can move in steps. Converted classes run with Greenlight. The remaining classes continue to run with PHPUnit.

A class does not convert when it uses:

The Rector rule does not translate a custom assertion failure message to because(). By default, a message prevents the conversion of the class. Use this configuration to drop assertion messages during automatic conversion:

    ->withConfiguredRule(PhpUnitToGreenlightRector::class, [
        PhpUnitToGreenlightRector::DROP_ASSERTION_MESSAGES => true,
    ])

Two conversions change the code shape. An expectException() block becomes a toThrow() expectation over an arrow function, and the earlier statements do not move. expectExceptionMessage() finds a substring, so the rule writes a quoted matching: pattern and not an exact message: constraint.

The rule preserves each repeated #[TestWith] row. It rejects class-process and global-state options that have different Greenlight behavior.

Some attribute conversions are less direct:

PHPUnitGreenlight
#[Ticket]#[Group]
#[Small], #[Medium], and #[Large]#[Group('small')], #[Group('medium')], and #[Group('large')]
#[RunInSeparateProcess] or #[RunTestsInSeparateProcesses]#[Isolated]
#[DoesNotPerformAssertions]#[NoExpectations]
#[RequiresPhpExtension]#[SkipUnless] with ExtensionLoaded
#[RequiresOperatingSystemFamily]#[SkipUnless] with OperatingSystemFamily

This separate-process conversion applies only to process-pool execution. --workers=1 and automatic in-process fallback cannot give #[Isolated] tests a dedicated process.

The rule removes coverage metadata attributes, for example #[CoversClass], because coverage configuration belongs in greenlight.php. It also removes use metadata, #[TestDox], and #[DisableReturnValueGenerationForTestDoubles].

Preview the proposed changes:

vendor/bin/rector process --dry-run

Apply the changes after you review the preview:

vendor/bin/rector process

Rector’s printer also reflows each converted class. Run your code-style fixer after the conversion. If no test depends on #[Isolated], run the suite one time with --workers=1 before you enable parallel workers. Otherwise, start with two process-pool workers.

Map the concepts

Convert assertions

Expectations start with Expect::value(). They do not use methods on the test class.

// PHPUnit                                                // Greenlight
$this->assertSame('a', $value);                           Expect::value($value)->toBe('a');
$this->assertEquals($expected, $order);                   Expect::value($order)->toEqual($expected);
$this->fail('Reason');                                    Fail::because('Reason');
$this->assertTrue($open, 'Order must stay open');         Expect::value($open)->because('Order must stay open')->toBeTrue();
$this->assertInstanceOf(Response::class, $r);             Expect::value($r)->toBeInstanceOf(Response::class);
$this->assertCount(3, $items);                            Expect::value($items)->toHaveCount(3);
$this->expectException(DomainException::class);           Expect::calling($fn)->toThrow(DomainException::class);
$this->assertGreaterThanOrEqual(3, $n);                   Expect::value($n)->toBeGreaterThanOrEqual(3);
$this->assertIsArray($value);                             Expect::value($value)->toBeArray();
$this->assertContains($needle, $haystack);                Expect::value($haystack)->toContain($needle);
$this->assertEqualsCanonicalizing($a, $b);                Expect::value($b)->toEqualCanonicalizing($a);
$this->assertJson($payload);                              Expect::value($payload)->toBeJson();
$this->assertJsonStringEqualsJsonString($e, $a);          Expect::value($a)->toMatchJson($e);

Other type predicates include toBeString(), toBeInt(), toBeFloat(), toBeBool(), toBeCallable(), and toBeIterable().

Membership matchers include toBeOneOf() and toBeIn(). Other matchers include toHaveLength() and toContainSubset().

See Greenlight\Expect\Expectation for the complete list.

These differences are important:

A throwable callback removes manual exception capture. Its parameter type specifies the expected throwable class. Its body can check the message, the previous throwable, and other throwable state.

Replace this pattern:

try {
    $fixtureManager->start();
    $this->fail('Expected the fixture manager to fail.');
} catch (IntegrationFixtureError $error) {
    $this->assertInstanceOf(
        LengthException::class,
        $error->getPrevious(),
    );
}

Use this expectation:

Expect::calling(fn() => $fixtureManager->start())
    ->toThrow(
        static function (IntegrationFixtureError $error): void {
            Expect::value($error->getPrevious())
                ->toBeInstanceOf(LengthException::class);
        },
    );

The callback runs only after the throwable type matches.

Replace manual sleep() calls or retry loops with eventually():

Expect::calling(fn() => $repository->find($id))->returnValue()->eventually()
    ->within(2.0)
    ->toEqual($expected);

Use consistently()->for() when a value must not change. A probe exception always stops this consistency check.

With eventually(), a probe exception stops the poll unless retryOnException() lists its type. Consistency checks do not support retryOnException().

Convert test doubles

Constructor injection supplies the Doubles service.

PHPUnit createMock() can create a tolerant double whose methods return null or automatic stubs. Greenlight has no tolerant double equivalent.

mock(Type::class, fn (MockPlan $plan) => ...) creates a strict mock. Greenlight verifies each planned expectation at the end of the test.

An unplanned call fails the test immediately. Configure each return value with andReturns(), andReturnsSequence(), andReturnsUsing(), or andThrows().

Replace willReturnOnConsecutiveCalls() with andReturnsSequence(...). The sequence consumes one value for each call.

A call after the last value is a test-author error.

Replace willReturnCallback() with andReturnsUsing(fn (...) => ...). The callback receives the call arguments.

Replace argument constraints with Greenlight\Doubles\Argument:

// PHPUnit                                          // Greenlight
$mock->method('save')->with($this->anything());     $plan->expects('save')->with(Argument::any());
$this->isInstanceOf(Order::class)                   Argument::type(Order::class)
$this->callback(fn ($v) => $v > 0)                  Argument::predicate(fn ($v) => $v > 0, 'positive')
$this->equalTo($expected)                           Argument::equals($expected)

Use a captured argument instead of callback inspection:

$captor = $plan->expects('save')->once()->andReturns(true)->captureArgument(0);
// ... exercise the subject ...
Expect::value($captor->value())->toBeInstanceOf(Order::class);

stub(Type::class) supplies a collaborator and rejects each interaction.

If the collaborator must return a value, use a mock with explicit expectations.

spy(Type::class) records calls to methods declared void or without a native return type. These calls return null. A call with another native return type fails the test.

Read records with $this->doubles->callsTo($spy, 'method'). Check the records with Expect.

$gateway = $this->doubles->mock(PaymentGateway::class, function (MockPlan $plan) use ($amount, $ok) {
    $plan->expects('charge')->with($amount)->once()->andReturns($ok);
});

Greenlight verifies mocks when the per-test scope closes. You do not need a Mockery::close() equivalent.

Double targets are interfaces or non-final, non-readonly classes. Prefer an interface because class doubles preserve final, static, and protected implementations.

After migration, strict doubles can expose interactions that old tests accepted.

See test doubles for the complete doubles API.

Replace class fixtures

Replace setUpBeforeClass() and static fixture properties with per-class harness services.

A per-class harness service is a typed object with PerClass scope. Greenlight creates one instance for each test class. It injects the instance into each test constructor and disposes it after the class completes.

External infrastructure such as database servers, message brokers, or containers belongs in an IntegrationFixtureProvider. It provisions in the orchestrator, can allocate one resource per worker channel, and tears down after the run even if workers fail. Worker-side tests consume its serializable connection data through IntegrationResources or a HarnessProvider bridge.

Plugins register harness services. A plugin implements HarnessProvider and returns service definitions with their scopes.

For shared suite fixtures, move the fixtures to a small plugin. Do not keep them in a static property on the test class.

Split a large class after migration

Keep class-level scheduling during the initial migration. This schedule preserves method order and one per-class harness service instance.

After the suite is stable, add #[AllowParallel] only to an independent large class. The attribute makes each selected test or data set a separate worker assignment.

Do not add the attribute to a class that uses a per-class harness service. Greenlight rejects that service request.

Do not combine #[AllowParallel] with #[Isolated]. Greenlight rejects this combination during discovery.

Data providers can run again in each assigned worker. Keep each provider pure, deterministic, and fast.

Understand the deliberate differences

These differences are intentional:

Migration sequence

  1. Add greenlight.php.
  2. Configure the test directories.
  3. Run the bundled Rector rule across the suite.
  4. Convert one remaining leaf test class manually.
  5. Remove the base class.
  6. Add #[Test] to each test method.
  7. Convert assertions to Expect::value().
  8. Convert data providers.
  9. Keep the provider body when only the attribute must change.
  10. Convert mocks after the other test code.
  11. Use strict-double failures to find loose assumptions in the old tests.
  12. If no test depends on #[Isolated], run with --workers=1 to exclude parallel execution from the first runs. Otherwise, start with two workers.
  13. Remove --workers=1 when you used it.
  14. Correct failures that occur only with parallel workers.