Skip to main content
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:
  1. Action-level testing - Test individual actions in isolation
  2. Workflow graph testing - Verify the execution order and dependencies
  3. 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

1
Test actions independently
2
Always test individual actions before integrating them into workflows. This makes debugging easier and tests faster.
3
Verify the execution graph
4
For complex workflows, verify that the dependency graph is correct to ensure jobs execute in the expected order.
5
Test edge cases
6
Test with invalid inputs, empty data, and boundary conditions to ensure your workflow handles errors gracefully.
7
Use meaningful test data
8
Use realistic test data that represents actual use cases rather than simple placeholder values.
9
Test both success and failure paths
10
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.

Build docs developers (and LLMs) love