Testing is a critical part of building reliable workflows. Chevere Workflow provides several strategies for testing your workflows at different levels of granularity.
Testing strategies
You can test workflows at three different levels:
- Action-level testing - Test individual actions in isolation
- Workflow graph testing - Verify the execution order and dependencies
- Integration testing - Test complete workflow execution with real data
Testing actions
Actions are the building blocks of workflows. Test them independently to ensure they work correctly before integrating them into workflows.
use PHPUnit\Framework\TestCase;
class FetchUserTest extends TestCase
{
public function testFetchUser(): void
{
$action = new FetchUser();
$result = $action(userId: 123);
$this->assertSame(123, $result['id']);
$this->assertArrayHasKey('name', $result);
$this->assertArrayHasKey('email', $result);
}
}
Testing actions with parameters
When your actions use chevere/parameter for validation, test that the parameter rules are enforced:
use Chevere\Action\Action;
use Chevere\Parameter\Attributes\_int;
class CalculateDiscount extends Action
{
public function __invoke(
#[_int(min: 0, max: 100)]
int $percentage,
#[_int(min: 0)]
int $amount
): int {
return intval($amount * (100 - $percentage) / 100);
}
}
class CalculateDiscountTest extends TestCase
{
public function testValidCalculation(): void
{
$action = new CalculateDiscount();
$result = $action(percentage: 20, amount: 100);
$this->assertSame(80, $result);
}
public function testInvalidPercentage(): void
{
$this->expectException(InvalidArgumentException::class);
$action = new CalculateDiscount();
$action(percentage: 150, amount: 100); // Exceeds max: 100
}
}
Testing workflow graphs
Verify that your workflow creates the correct dependency graph and execution order:
use PHPUnit\Framework\TestCase;
use function Chevere\Workflow\{workflow, async, sync, response};
class WorkflowGraphTest extends TestCase
{
public function testParallelExecution(): void
{
$workflow = workflow(
a: async(ActionA::class),
b: async(ActionB::class),
c: async(ActionC::class)
);
$graph = $workflow->jobs()->graph()->toArray();
// All three jobs should run in parallel (level 0)
$this->assertSame([['a', 'b', 'c']], $graph);
}
public function testDependencyOrder(): void
{
$workflow = workflow(
fetch: async(FetchData::class),
process: sync(ProcessData::class, data: response('fetch')),
store: sync(StoreData::class, result: response('process'))
);
$graph = $workflow->jobs()->graph()->toArray();
// Verify execution levels
$this->assertSame(
[
['fetch'], // Level 0: No dependencies
['process'], // Level 1: Depends on fetch
['store'] // Level 2: Depends on process
],
$graph
);
}
public function testMixedParallelAndSequential(): void
{
$workflow = workflow(
thumb: async(ResizeImage::class, size: 'thumb'),
medium: async(ResizeImage::class, size: 'medium'),
large: async(ResizeImage::class, size: 'large'),
store: sync(
StoreFiles::class,
files: [
response('thumb'),
response('medium'),
response('large')
]
)
);
$graph = $workflow->jobs()->graph()->toArray();
$this->assertSame(
[
['thumb', 'medium', 'large'], // Level 0: Parallel
['store'] // Level 1: After all resize
],
$graph
);
}
}
The graph is represented as a two-dimensional array where each level contains job names that can run in parallel at that execution level.
Testing workflow responses
Test complete workflow execution and verify the responses from each job:
use function Chevere\Workflow\{workflow, sync, variable, run};
class WorkflowResponsesTest extends TestCase
{
public function testCalculationWorkflow(): void
{
$workflow = workflow(
calculate: sync(
fn(int $a, int $b): int => $a + $b,
a: 10,
b: variable('value')
),
format: sync(
fn(int $result): string => "Result: {$result}",
result: response('calculate')
)
);
$result = run($workflow, value: 5);
$this->assertSame(15, $result->response('calculate')->int());
$this->assertSame('Result: 15', $result->response('format')->string());
}
public function testNestedResponseAccess(): void
{
$workflow = workflow(
user: sync(
fn(int $id): array => [
'id' => $id,
'name' => 'John Doe',
'email' => '[email protected]'
],
id: variable('userId')
),
email: sync(
fn(string $email): string => strtolower($email),
email: response('user', 'email')
)
);
$result = run($workflow, userId: 123);
$this->assertSame('[email protected]', $result->response('user', 'email')->string());
$this->assertSame('[email protected]', $result->response('email')->string());
}
}
Testing conditional execution
Verify that jobs are skipped or executed based on conditions:
class ConditionalWorkflowTest extends TestCase
{
public function testJobSkippedWhenConditionFalse(): void
{
$workflow = workflow(
process: sync(
ProcessData::class,
data: variable('data')
)->withRunIf(variable('shouldProcess'))
);
$result = run($workflow, data: 'test', shouldProcess: false);
$this->assertTrue($result->skip()->contains('process'));
}
public function testJobExecutedWhenConditionTrue(): void
{
$workflow = workflow(
process: sync(
ProcessData::class,
data: variable('data')
)->withRunIf(variable('shouldProcess'))
);
$result = run($workflow, data: 'test', shouldProcess: true);
$this->assertFalse($result->skip()->contains('process'));
$this->assertNotNull($result->response('process'));
}
public function testConditionalBasedOnResponse(): void
{
$workflow = workflow(
validate: sync(
fn(string $email): bool => filter_var($email, FILTER_VALIDATE_EMAIL) !== false,
email: variable('email')
),
send: sync(
SendEmail::class,
email: variable('email')
)->withRunIf(response('validate'))
);
// Test with invalid email
$result = run($workflow, email: 'invalid-email');
$this->assertTrue($result->skip()->contains('send'));
// Test with valid email
$result = run($workflow, email: '[email protected]');
$this->assertFalse($result->skip()->contains('send'));
}
}
Testing exceptions
Use ExpectWorkflowExceptionTrait to test error scenarios and verify that exceptions are properly wrapped:
use Chevere\Workflow\Traits\ExpectWorkflowExceptionTrait;
use Chevere\Workflow\Exceptions\RunnerException;
class WorkflowExceptionTest extends TestCase
{
use ExpectWorkflowExceptionTrait;
public function testJobThrowsException(): void
{
$workflow = workflow(
validate: sync(
fn(string $email): bool =>
filter_var($email, FILTER_VALIDATE_EMAIL) !== false
? true
: throw new InvalidArgumentException('Invalid email format')
,
email: variable('email')
)
);
$this->expectWorkflowException(
closure: fn() => run($workflow, email: 'invalid'),
instance: InvalidArgumentException::class,
job: 'validate',
message: 'Invalid email format',
code: null
);
}
public function testExceptionFromAction(): void
{
$workflow = workflow(
divide: sync(
fn(int $a, int $b): float =>
$b === 0
? throw new DivisionByZeroError('Cannot divide by zero')
: $a / $b
,
a: variable('numerator'),
b: variable('denominator')
)
);
$this->expectWorkflowException(
closure: fn() => run($workflow, numerator: 10, denominator: 0),
instance: DivisionByZeroError::class,
job: 'divide',
message: 'Cannot divide by zero',
code: null
);
}
}
Manual exception handling
You can also catch and inspect RunnerException manually:
use Chevere\Workflow\Exceptions\RunnerException;
public function testManualExceptionHandling(): void
{
$workflow = workflow(
failing: sync(
fn() => throw new RuntimeException('Something went wrong', 500)
)
);
try {
run($workflow);
$this->fail('Expected RunnerException to be thrown');
} catch (RunnerException $e) {
$this->assertSame('failing', $e->name);
$this->assertInstanceOf(RuntimeException::class, $e->throwable);
$this->assertSame('Something went wrong', $e->throwable->getMessage());
$this->assertSame(500, $e->throwable->getCode());
}
}
Testing retry policies
Verify that retry logic works as expected:
class RetryPolicyTest extends TestCase
{
public function testJobRetriesUntilSuccess(): void
{
$attempts = 0;
$workflow = workflow(
flaky: sync(
function() use (&$attempts): string {
$attempts++;
if ($attempts < 3) {
throw new RuntimeException('Temporary failure');
}
return 'success';
}
)->withRetry(
maxAttempts: 3,
delay: 0
)
);
$result = run($workflow);
$this->assertSame(3, $attempts);
$this->assertSame('success', $result->response('flaky')->string());
}
public function testJobFailsAfterMaxAttempts(): void
{
$workflow = workflow(
failing: sync(
fn() => throw new RuntimeException('Always fails')
)->withRetry(
maxAttempts: 3,
delay: 0
)
);
$this->expectException(RunnerException::class);
$this->expectExceptionMessageMatches('/\[3\/3\].*Always fails/');
run($workflow);
}
public function testRetryWithTimeout(): void
{
$workflow = workflow(
slow: sync(
fn() => sleep(5)
)->withRetry(
timeout: 1,
maxAttempts: 2
)
);
$this->expectException(RunnerException::class);
$this->expectExceptionMessageMatches('/cancelled/');
run($workflow);
}
}
Testing with dependencies
When testing workflows that use dependency injection:
use Chevere\Container\Container;
class DependencyInjectionTest extends TestCase
{
public function testWorkflowWithContainer(): void
{
$logger = new TestLogger();
$container = new Container(
logger: $logger
);
$workflow = workflow(
log: sync(
LogAction::class, // Requires LoggerInterface in constructor
message: variable('message')
)
);
$result = run($workflow, $container, message: 'Test log');
$this->assertTrue($logger->hasLog('Test log'));
}
}
Best practices
Test actions independently
Always test individual actions before integrating them into workflows. This makes debugging easier and tests faster.
Verify the execution graph
For complex workflows, verify that the dependency graph is correct to ensure jobs execute in the expected order.
Test with invalid inputs, empty data, and boundary conditions to ensure your workflow handles errors gracefully.
Use realistic test data that represents actual use cases rather than simple placeholder values.
Test both success and failure paths
Make sure to test both successful execution and error scenarios to ensure robust error handling.
When testing workflows that use external services (databases, APIs), consider using mocks or test doubles to make tests faster and more reliable.