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:
- a non-final test class
- test doubles such as
createMock(), which you convert manually to strict doubles #[Depends],setUpBeforeClass(),tearDownAfterClass(), or traits- assertions without a Greenlight matcher, for example file or XML assertions
- assertion arguments whose changed evaluation order can change their values
assertEmpty()orassertNotEmpty(), because Greenlight uses different empty-value rules- multiple data providers or multiple requirements on one declaration
#[RunClassInSeparateProcess]or#[PreserveGlobalState]- other inherited
TestCaseAPI that the rule cannot prove safe
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:
| PHPUnit | Greenlight |
|---|---|
#[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
- Replace
extends TestCasewith a plain class. - Add
#[Test]to each public test method. - Replace
setUp()with#[Before]on a public method. - Replace
tearDown()with#[After]on a public method. - Replace
#[DataProvider('cases')]with#[DataSet('cases')]. - Keep the static data provider on the test class.
- Replace
#[TestWith([1, 2])]with#[DataRow([1, 2])]. - Add an optional label to
#[DataRow]. - Replace group annotations with the repeatable
#[Group('slow')]attribute. - Add
#[Group('slow')]to a method or class. - Replace
$this->markTestSkipped($reason)withthrow new SkipTest($reason). - Replace requirement attributes with a
#[SkipUnless]condition. - Use
#[SkipUnless(ExtensionLoaded::class, 'redis')]for an extension check. - Replace
$this->assert...()calls with staticExpect::value(...)chains. - Replace
createMock()andgetMockBuilder()with the injectedDoublesservice. - Use the
mock(),stub(), andspy()methods to create doubles. - Replace
setUpBeforeClass()static code with per-class harness services. - Replace
#[RunInSeparateProcess]with#[Isolated]. - Replace data flow from
#[Depends]with explicit fixture or harness state. - Remove
#[Depends]after the test no longer accepts the producer result. - Keep
@codeCoverageIgnoreand related annotations. - Use
#[CoverageIgnore]as the native equivalent.
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:
toEqual()uses defined deep-equality rules.- Integers and floats compare by numeric value.
- Other scalar values compare strictly.
- Arrays compare by keys and recursively equal values.
- Objects compare by exact class and all properties, with private properties.
- Unlike types do not use loose equality. Thus,
'1'does not equal1. - PHPUnit
assertEmpty()uses PHPempty()semantics. - Greenlight
toBeEmpty()accepts strings, arrays,Countable, and iterables. - Convert
assertEmpty()andassertNotEmpty()manually. ->not()applies only to the next matcher.->because()replaces the PHPUnit$messageargument and applies only to the next matcher.toThrow()accepts a callable subject and an optional message constraint.- Use
message:for exact equality. - Use
matching:for a regular expression. - Pass a Throwable instance to require the exact object.
- Use a typed throwable callback to check the caught throwable.
- Do not use
message:andmatching:in one call.
- Use
Fail::because()replaces$this->fail()and supports explicit type guards.Fail::because()counts as an expectation and reports the guard location.- A failed matcher throws immediately. Greenlight has no soft-assertion mode.
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:
- Greenlight has no
TestCasebase class. - Tests declare dependencies in the constructor.
- The runner supplies the declared dependencies.
- Greenlight has no inherited assertion API or
parent::setUp()chain. - Greenlight has no test method name pattern.
- The
#[Test]attribute identifies each test method. - Greenlight has no
#[Depends]. - Test dependencies create hidden order requirements that conflict with parallel execution.
- Put expensive shared state in a class-scoped or worker-scoped harness service.
- Tests run in parallel worker processes by default.
- Tests in one class stay together unless the class has
#[AllowParallel]. - With process-pool execution, use
#[Isolated]for a test that must own its process. - External dependencies require an explicit parallel strategy.
- Use a channel for one resource for each worker.
- Use
#[RequiresResource]to limit access to a shared dependency. - Doubles are strict.
- Unplanned interactions fail, and Greenlight does not invent return values.
Migration sequence
- Add
greenlight.php. - Configure the test directories.
- Run the bundled Rector rule across the suite.
- Convert one remaining leaf test class manually.
- Remove the base class.
- Add
#[Test]to each test method. - Convert assertions to
Expect::value(). - Convert data providers.
- Keep the provider body when only the attribute must change.
- Convert mocks after the other test code.
- Use strict-double failures to find loose assumptions in the old tests.
- If no test depends on
#[Isolated], run with--workers=1to exclude parallel execution from the first runs. Otherwise, start with two workers. - Remove
--workers=1when you used it. - Correct failures that occur only with parallel workers.