├── .gitattributes ├── phpstan.neon ├── .gitignore ├── .editorconfig ├── .codecov.yml ├── .php-cs-fixer.dist.php ├── src ├── Bus │ ├── BusEnvelopeCollection.php │ ├── TestBusRegistry.php │ └── TestBus.php ├── Transport │ ├── TransportEnvelopeCollection.php │ ├── TestTransportRegistry.php │ ├── TestTransportFactory.php │ └── TestTransport.php ├── Stamp │ └── AvailableAtStamp.php ├── TestEnvelope.php ├── ZenstruckMessengerTestBundle.php ├── InteractsWithMessenger.php └── EnvelopeCollection.php ├── LICENSE ├── composer.json ├── phpunit.xml.dist ├── CHANGELOG.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore 3 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 9 3 | paths: 4 | - src 5 | treatPhpDocTypesAsCertain: false 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.lock 2 | /phpunit.xml 3 | /vendor/ 4 | /build/ 5 | /var/ 6 | /.php-cs-fixer.cache 7 | /.phpunit.result.cache 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.yml] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 1% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 50% 11 | 12 | comment: false 13 | github_checks: 14 | annotations: false 15 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Bus; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Zenstruck\Messenger\Test\EnvelopeCollection; 16 | 17 | final class BusEnvelopeCollection extends EnvelopeCollection 18 | { 19 | public function __construct(private TestBus $bus, Envelope ...$envelope) 20 | { 21 | parent::__construct(...$envelope); 22 | } 23 | 24 | public function back(): TestBus 25 | { 26 | return $this->bus; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Transport/TransportEnvelopeCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Transport; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Zenstruck\Messenger\Test\EnvelopeCollection; 16 | 17 | final class TransportEnvelopeCollection extends EnvelopeCollection 18 | { 19 | public function __construct(private TestTransport $transport, Envelope ...$envelopes) 20 | { 21 | parent::__construct(...$envelopes); 22 | } 23 | 24 | public function back(): TestTransport 25 | { 26 | return $this->transport; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kevin Bond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Stamp/AvailableAtStamp.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Stamp; 13 | 14 | use Symfony\Component\Messenger\Stamp\DelayStamp; 15 | use Symfony\Component\Messenger\Stamp\StampInterface; 16 | 17 | /** 18 | * @internal 19 | */ 20 | final class AvailableAtStamp implements StampInterface 21 | { 22 | public function __construct(private \DateTimeImmutable $availableAt) 23 | { 24 | } 25 | 26 | public static function fromDelayStamp(DelayStamp $delayStamp, \DateTimeImmutable $now): self 27 | { 28 | return new self( 29 | $now->modify(\sprintf('%s%d milliseconds', 30 | $delayStamp->getDelay() > 0 ? '+' : '-', 31 | \abs($delayStamp->getDelay()) 32 | )) 33 | ); 34 | } 35 | 36 | public function getAvailableAt(): \DateTimeImmutable 37 | { 38 | return $this->availableAt; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenstruck/messenger-test", 3 | "description": "Assertions and helpers for testing your symfony/messenger queues.", 4 | "homepage": "https://github.com/zenstruck/messenger-test", 5 | "type": "symfony-bundle", 6 | "license": "MIT", 7 | "keywords": ["messenger", "symfony", "test", "queue", "dev"], 8 | "authors": [ 9 | { 10 | "name": "Kevin Bond", 11 | "email": "kevinbond@gmail.com" 12 | } 13 | ], 14 | "require": { 15 | "php": ">=8.1", 16 | "symfony/deprecation-contracts": "^2.2|^3.0", 17 | "symfony/framework-bundle": "^6.4.15|^7.0|^8.0", 18 | "symfony/messenger": "^6.4|^7.0|^8.0", 19 | "zenstruck/assert": "^1.0" 20 | }, 21 | "require-dev": { 22 | "phpstan/phpstan": "^2.1", 23 | "phpunit/phpunit": "^9.6.0", 24 | "symfony/browser-kit": "^6.4|^7.0|^8.0", 25 | "symfony/clock": "^6.4|^7.0|^8.0", 26 | "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", 27 | "symfony/yaml": "^6.4|^7.0|^8.0" 28 | }, 29 | "suggest": { 30 | "symfony/clock": "A PSR-20 clock implementation in order to support DelayStamp." 31 | }, 32 | "config": { 33 | "preferred-install": "dist", 34 | "sort-packages": true 35 | }, 36 | "autoload": { 37 | "psr-4": { "Zenstruck\\Messenger\\Test\\": "src/" } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { "Zenstruck\\Messenger\\Test\\Tests\\": "tests/" } 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true 44 | } 45 | -------------------------------------------------------------------------------- /src/Bus/TestBusRegistry.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Bus; 13 | 14 | use Symfony\Component\Messenger\MessageBusInterface; 15 | 16 | final class TestBusRegistry 17 | { 18 | /** @var array */ 19 | private array $buses = []; 20 | 21 | public function register(string $name, MessageBusInterface $bus): void 22 | { 23 | $this->buses[$name] = $bus; 24 | } 25 | 26 | public function get(?string $name = null): TestBus 27 | { 28 | if (0 === \count($this->buses)) { 29 | throw new \LogicException('No bus registered.'); 30 | } 31 | 32 | if (null === $name && 1 !== \count($this->buses)) { 33 | throw new \InvalidArgumentException(\sprintf('Multiple buses are registered (%s), you must specify a name.', \implode(', ', \array_keys($this->buses)))); 34 | } 35 | 36 | if (null === $name) { 37 | $name = \array_key_first($this->buses); 38 | } 39 | 40 | if (!$bus = $this->buses[$name] ?? null) { 41 | throw new \InvalidArgumentException("Bus \"{$name}\" not registered."); 42 | } 43 | 44 | if (!$bus instanceof TestBus) { 45 | throw new \LogicException("Bus \"{$name}\" needs to be a decorator of the bus."); 46 | } 47 | 48 | return $bus; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TestEnvelope.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Symfony\Component\Messenger\Stamp\StampInterface; 16 | use Zenstruck\Assert; 17 | 18 | /** 19 | * @author Kevin Bond 20 | * 21 | * @mixin Envelope 22 | */ 23 | final class TestEnvelope 24 | { 25 | public function __construct(public readonly Envelope $envelope) 26 | { 27 | } 28 | 29 | /** 30 | * @param mixed[] $arguments 31 | * 32 | * @return mixed 33 | */ 34 | public function __call(string $name, array $arguments) 35 | { 36 | return $this->envelope->{$name}(...$arguments); 37 | } 38 | 39 | /** 40 | * @param class-string $class 41 | */ 42 | public function assertHasStamp(string $class): self 43 | { 44 | Assert::that($this->envelope->all($class))->isNotEmpty( 45 | 'Expected to find stamp "{stamp}" but did not.', 46 | ['stamp' => $class], 47 | ); 48 | 49 | return $this; 50 | } 51 | 52 | /** 53 | * @param class-string $class 54 | */ 55 | public function assertNotHasStamp(string $class): self 56 | { 57 | Assert::that($this->envelope->all($class))->isEmpty( 58 | 'Expected to not find "{stamp}" but did.', 59 | ['stamp' => $class], 60 | ); 61 | 62 | return $this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ./tests/ 21 | ./tests/TransportsAreResetCorrectly 22 | 23 | 24 | ./tests/TransportsAreResetCorrectly/NotInteractsWithMessengerBeforeTest.php 25 | ./tests/TransportsAreResetCorrectly/UsingTraitInteractsWithMessengerTest.php 26 | ./tests/TransportsAreResetCorrectly/NotInteractsWithMessengerAfterTest.php 27 | 28 | 29 | 30 | 31 | 32 | ./src/ 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/Transport/TestTransportRegistry.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Transport; 13 | 14 | use Symfony\Component\Messenger\Transport\TransportInterface; 15 | 16 | /** 17 | * @author Kevin Bond 18 | * 19 | * @internal 20 | */ 21 | final class TestTransportRegistry 22 | { 23 | /** @var TransportInterface[] */ 24 | private array $transports = []; 25 | 26 | public function register(string $name, TransportInterface $transport): void 27 | { 28 | $this->transports[$name] = $transport; 29 | } 30 | 31 | public function get(?string $name = null): TestTransport 32 | { 33 | if (0 === \count($this->transports)) { 34 | throw new \LogicException('No transports registered.'); 35 | } 36 | 37 | if (null === $name && 1 !== \count($this->transports)) { 38 | throw new \InvalidArgumentException(\sprintf('Multiple transports are registered (%s), you must specify a name.', \implode(', ', \array_keys($this->transports)))); 39 | } 40 | 41 | if (null === $name) { 42 | $name = \array_key_first($this->transports); 43 | } 44 | 45 | if (!$transport = $this->transports[$name] ?? null) { 46 | throw new \InvalidArgumentException("Transport \"{$name}\" not registered."); 47 | } 48 | 49 | if (!$transport instanceof TestTransport) { 50 | throw new \LogicException("Transport \"{$name}\" needs to be set to \"test://\" in your test config to use this feature."); 51 | } 52 | 53 | return $transport; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Bus/TestBus.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Bus; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Symfony\Component\Messenger\MessageBusInterface; 16 | use Symfony\Component\Messenger\Stamp\ReceivedStamp; 17 | 18 | final class TestBus implements MessageBusInterface 19 | { 20 | /** @var array> */ 21 | private static array $messages = []; 22 | 23 | // The setting applies to all buses 24 | private static bool $enableMessagesCollection = true; 25 | 26 | public function __construct(private string $name, private MessageBusInterface $decorated) 27 | { 28 | self::$messages[$name] = []; 29 | } 30 | 31 | public function dispatched(): BusEnvelopeCollection 32 | { 33 | return new BusEnvelopeCollection($this, ...self::$messages[$this->name] ?? []); 34 | } 35 | 36 | public function dispatch(object $message, array $stamps = []): Envelope 37 | { 38 | $envelope = $this->decorated->dispatch($message, $stamps); 39 | 40 | if (true === self::$enableMessagesCollection && !$envelope->all(ReceivedStamp::class)) { 41 | self::$messages[$this->name] ??= []; 42 | self::$messages[$this->name][] = $envelope; 43 | } 44 | 45 | return $envelope; 46 | } 47 | 48 | public function reset(): self 49 | { 50 | self::$messages[$this->name] = []; 51 | 52 | return $this; 53 | } 54 | 55 | /** 56 | * Resets data and options for all buses. 57 | */ 58 | public static function resetAll(): void 59 | { 60 | self::$messages = []; 61 | } 62 | 63 | public static function enableMessagesCollection(): void 64 | { 65 | self::$enableMessagesCollection = true; 66 | } 67 | 68 | public static function disableMessagesCollection(): void 69 | { 70 | self::$enableMessagesCollection = false; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Transport/TestTransportFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Transport; 13 | 14 | use Psr\Clock\ClockInterface; 15 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 16 | use Symfony\Component\Messenger\MessageBusInterface; 17 | use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; 18 | use Symfony\Component\Messenger\Transport\TransportFactoryInterface; 19 | use Symfony\Component\Messenger\Transport\TransportInterface; 20 | 21 | /** 22 | * @author Kevin Bond 23 | * 24 | * @internal 25 | * 26 | * @implements TransportFactoryInterface 27 | */ 28 | final class TestTransportFactory implements TransportFactoryInterface 29 | { 30 | public function __construct(private MessageBusInterface $bus, private EventDispatcherInterface $dispatcher, private ?ClockInterface $clock = null) 31 | { 32 | } 33 | 34 | public function createTransport(string $dsn, array $options, SerializerInterface $serializer): TransportInterface // @phpstan-ignore-line 35 | { 36 | return new TestTransport($options['transport_name'], $this->bus, $this->dispatcher, $serializer, $this->clock, \array_merge($this->parseDsn($dsn), $options)); 37 | } 38 | 39 | public function supports(string $dsn, array $options): bool // @phpstan-ignore-line 40 | { 41 | return \str_starts_with($dsn, 'test://'); 42 | } 43 | 44 | /** 45 | * @return array 46 | */ 47 | private function parseDsn(string $dsn): array 48 | { 49 | $query = []; 50 | 51 | if ($queryAsString = \mb_strstr($dsn, '?')) { 52 | \parse_str(\ltrim($queryAsString, '?'), $query); 53 | } 54 | 55 | return [ 56 | 'intercept' => \filter_var($query['intercept'] ?? true, \FILTER_VALIDATE_BOOLEAN), 57 | 'catch_exceptions' => \filter_var($query['catch_exceptions'] ?? true, \FILTER_VALIDATE_BOOLEAN), 58 | 'test_serialization' => \filter_var($query['test_serialization'] ?? true, \FILTER_VALIDATE_BOOLEAN), 59 | 'disable_retries' => \filter_var($query['disable_retries'] ?? true, \FILTER_VALIDATE_BOOLEAN), 60 | 'support_delay_stamp' => \filter_var($query['support_delay_stamp'] ?? false, \FILTER_VALIDATE_BOOLEAN), 61 | ]; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ZenstruckMessengerTestBundle.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test; 13 | 14 | use Psr\Clock\ClockInterface; 15 | use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; 16 | use Symfony\Component\DependencyInjection\ContainerBuilder; 17 | use Symfony\Component\DependencyInjection\ContainerInterface; 18 | use Symfony\Component\DependencyInjection\Extension\ExtensionInterface; 19 | use Symfony\Component\DependencyInjection\Reference; 20 | use Symfony\Component\HttpKernel\Bundle\Bundle; 21 | use Symfony\Component\Messenger\Transport\TransportInterface; 22 | use Zenstruck\Messenger\Test\Bus\TestBus; 23 | use Zenstruck\Messenger\Test\Bus\TestBusRegistry; 24 | use Zenstruck\Messenger\Test\Transport\TestTransportFactory; 25 | use Zenstruck\Messenger\Test\Transport\TestTransportRegistry; 26 | 27 | /** 28 | * @author Kevin Bond 29 | */ 30 | final class ZenstruckMessengerTestBundle extends Bundle implements CompilerPassInterface 31 | { 32 | public function build(ContainerBuilder $container): void 33 | { 34 | $container->register('zenstruck_messenger_test.transport_factory', TestTransportFactory::class) 35 | ->setArguments([ 36 | new Reference('messenger.routable_message_bus'), 37 | new Reference('event_dispatcher'), 38 | new Reference(ClockInterface::class, invalidBehavior: ContainerInterface::NULL_ON_INVALID_REFERENCE), 39 | ]) 40 | ->addTag('messenger.transport_factory') 41 | ; 42 | 43 | $container->register('zenstruck_messenger_test.transport_registry', TestTransportRegistry::class) 44 | ->setPublic(true) 45 | ; 46 | 47 | $container->register('zenstruck_messenger_test.bus_registry', TestBusRegistry::class) 48 | ->setPublic(true) 49 | ; 50 | 51 | $container->addCompilerPass($this); 52 | } 53 | 54 | public function getContainerExtension(): ?ExtensionInterface 55 | { 56 | return null; 57 | } 58 | 59 | public function process(ContainerBuilder $container): void 60 | { 61 | $transportRegistry = $container->getDefinition('zenstruck_messenger_test.transport_registry'); 62 | 63 | foreach ($container->findTaggedServiceIds('messenger.receiver') as $id => $tags) { 64 | $name = $id; 65 | $transport = $container->getDefinition($name); 66 | 67 | if (!$class = $transport->getClass()) { 68 | continue; 69 | } 70 | 71 | if (!\is_a($class, TransportInterface::class, true)) { 72 | continue; 73 | } 74 | 75 | $transport->addTag('kernel.reset', ['method' => 'resetOnKernelShutdown', 'on_invalid' => 'ignore']); 76 | 77 | foreach ($tags as $tag) { 78 | if (isset($tag['alias'])) { 79 | $name = $tag['alias']; 80 | } 81 | } 82 | 83 | $transportRegistry->addMethodCall('register', [$name, new Reference($id)]); 84 | } 85 | 86 | $busRegistry = $container->getDefinition('zenstruck_messenger_test.bus_registry'); 87 | 88 | foreach ($container->findTaggedServiceIds('messenger.bus') as $id => $tags) { 89 | $name = "{$id}.test-bus"; 90 | $busRegistry->addMethodCall('register', [$id, new Reference($name)]); 91 | $container->register($name, TestBus::class) 92 | ->setAutowired(true) 93 | ->setPublic(true) 94 | ->setArgument('$name', $id) 95 | ->setDecoratedService($id) 96 | ; 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/InteractsWithMessenger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test; 13 | 14 | use PHPUnit\Framework\Attributes\After; 15 | use PHPUnit\Framework\Attributes\Before; 16 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 17 | use Zenstruck\Messenger\Test\Bus\TestBus; 18 | use Zenstruck\Messenger\Test\Bus\TestBusRegistry; 19 | use Zenstruck\Messenger\Test\Transport\TestTransport; 20 | use Zenstruck\Messenger\Test\Transport\TestTransportRegistry; 21 | 22 | /** 23 | * @author Kevin Bond 24 | */ 25 | trait InteractsWithMessenger // @phpstan-ignore trait.unused 26 | { 27 | /** 28 | * @internal 29 | * 30 | * @before 31 | */ 32 | #[Before] 33 | final protected static function _initializeTestTransports(): void 34 | { 35 | TestTransport::initialize(); 36 | } 37 | 38 | /** 39 | * @internal 40 | * 41 | * @before 42 | */ 43 | #[Before] 44 | final protected static function _enableMessagesCollection(): void 45 | { 46 | TestTransport::enableMessagesCollection(); 47 | TestBus::enableMessagesCollection(); 48 | } 49 | 50 | /** 51 | * @internal 52 | * 53 | * @before 54 | */ 55 | #[Before] 56 | final protected static function _disableResetOnKernelShutdown(): void 57 | { 58 | TestTransport::disableResetOnKernelShutdown(); 59 | } 60 | 61 | /** 62 | * @internal 63 | * 64 | * @after 65 | */ 66 | #[After] 67 | final protected static function _disableMessagesCollection(): void 68 | { 69 | TestTransport::disableMessagesCollection(); 70 | TestBus::disableMessagesCollection(); 71 | } 72 | 73 | /** 74 | * @internal 75 | * 76 | * @after 77 | */ 78 | #[After] 79 | final protected static function _resetMessengerTransports(): void 80 | { 81 | TestTransport::resetAll(); 82 | TestBus::resetAll(); 83 | } 84 | 85 | /** 86 | * @internal 87 | * 88 | * @after 89 | */ 90 | #[After] 91 | final protected static function _enableResetOnKernelShutdown(): void 92 | { 93 | TestTransport::enableResetOnKernelShutdown(); 94 | } 95 | 96 | /** 97 | * @deprecated use transport() instead 98 | */ 99 | final protected function messenger(?string $transport = null): TestTransport 100 | { 101 | trigger_deprecation('zenstruck/messenger-test', '1.7.0', '"messenger()" method is deprecated and will be removed in 2.0. Please use "transport()" instead.'); 102 | 103 | return $this->transport($transport); 104 | } 105 | 106 | final protected function transport(?string $transport = null): TestTransport 107 | { 108 | $this->init(); 109 | $container = self::getContainer(); 110 | 111 | if (!$container->has('zenstruck_messenger_test.transport_registry')) { 112 | throw new \LogicException('Cannot access transport - is ZenstruckMessengerTestBundle enabled in your test environment?'); 113 | } 114 | 115 | /** @var TestTransportRegistry $registry */ 116 | $registry = $container->get('zenstruck_messenger_test.transport_registry'); 117 | 118 | return $registry->get($transport); 119 | } 120 | 121 | final protected function bus(?string $bus = null): TestBus 122 | { 123 | $this->init(); 124 | $container = self::getContainer(); 125 | 126 | if (!$container->has('zenstruck_messenger_test.bus_registry')) { 127 | throw new \LogicException('Cannot access bus - is ZenstruckMessengerTestBundle enabled in your test environment?'); 128 | } 129 | 130 | /** @var TestBusRegistry $registry */ 131 | $registry = $container->get('zenstruck_messenger_test.bus_registry'); 132 | 133 | return $registry->get($bus); 134 | } 135 | 136 | private function init(): void 137 | { 138 | if (!$this instanceof KernelTestCase) { 139 | throw new \LogicException(\sprintf('The %s trait can only be used with %s.', __TRAIT__, KernelTestCase::class)); 140 | } 141 | 142 | if (!self::$booted) { 143 | self::bootKernel(); 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/EnvelopeCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test; 13 | 14 | use Symfony\Component\Messenger\Envelope; 15 | use Zenstruck\Assert; 16 | 17 | /** 18 | * @author Kevin Bond 19 | * 20 | * @implements \IteratorAggregate 21 | */ 22 | abstract class EnvelopeCollection implements \IteratorAggregate, \Countable 23 | { 24 | /** @var Envelope[] */ 25 | private array $envelopes; 26 | 27 | /** 28 | * @internal 29 | */ 30 | public function __construct(Envelope ...$envelopes) 31 | { 32 | $this->envelopes = $envelopes; 33 | } 34 | 35 | final public function assertEmpty(): static 36 | { 37 | return $this->assertCount(0); 38 | } 39 | 40 | final public function assertNotEmpty(): static 41 | { 42 | Assert::that($this)->isNotEmpty('Expected some messages but found none.'); 43 | 44 | return $this; 45 | } 46 | 47 | final public function assertCount(int $count): static 48 | { 49 | Assert::that($this->envelopes)->hasCount($count, 'Expected {expected} messages but {actual} messages found.'); 50 | 51 | return $this; 52 | } 53 | 54 | /** 55 | * @param class-string $messageClass 56 | */ 57 | final public function assertContains(string $messageClass, ?int $times = null): static 58 | { 59 | $messages = $this->messages($messageClass); 60 | 61 | if (null !== $times) { 62 | Assert::that($messages)->hasCount( 63 | $times, 64 | 'Expected to find "{message}" {expected} times but found {actual} times.', 65 | ['message' => $messageClass], 66 | ); 67 | 68 | return $this; 69 | } 70 | 71 | Assert::that($messages)->isNotEmpty('Message "{message}" not found.', ['message' => $messageClass]); 72 | 73 | return $this; 74 | } 75 | 76 | /** 77 | * @param class-string $messageClass 78 | */ 79 | final public function assertNotContains(string $messageClass): static 80 | { 81 | Assert::that($this->messages($messageClass))->isEmpty( 82 | 'Found message "{message}" but should not.', 83 | ['message' => $messageClass], 84 | ); 85 | 86 | return $this; 87 | } 88 | 89 | final public function first(callable|string|null $filter = null): TestEnvelope 90 | { 91 | if (null === $filter) { 92 | // just the first envelope 93 | return $this->first(fn() => true); 94 | } 95 | 96 | if (!\is_callable($filter)) { 97 | // first envelope for message class 98 | return $this->first(fn(Envelope $e) => $filter === \get_class($e->getMessage())); 99 | } 100 | 101 | $filter = self::normalizeFilter($filter); 102 | 103 | foreach ($this->envelopes as $envelope) { 104 | if ($filter($envelope)) { 105 | return new TestEnvelope($envelope); 106 | } 107 | } 108 | 109 | throw new \RuntimeException('No envelopes found.'); 110 | } 111 | 112 | /** 113 | * The messages extracted from envelopes. 114 | * 115 | * @template T of object 116 | * 117 | * @param class-string|null $class Only messages of this class 118 | * 119 | * @return ($class is string ? list : array) 120 | */ 121 | final public function messages(?string $class = null): array 122 | { 123 | $messages = \array_map(static fn(Envelope $envelope) => $envelope->getMessage(), $this->envelopes); 124 | 125 | if (!$class) { 126 | return $messages; 127 | } 128 | 129 | return \array_values(\array_filter($messages, static fn(object $message) => $class === $message::class)); 130 | } 131 | 132 | /** 133 | * @return TestEnvelope[] 134 | */ 135 | final public function all(): array 136 | { 137 | return \iterator_to_array($this); 138 | } 139 | 140 | /** 141 | * @return \Traversable|TestEnvelope[] 142 | */ 143 | final public function getIterator(): \Traversable 144 | { 145 | foreach ($this->envelopes as $envelope) { 146 | yield new TestEnvelope($envelope); 147 | } 148 | } 149 | 150 | final public function count(): int 151 | { 152 | return \count($this->envelopes); 153 | } 154 | 155 | private static function normalizeFilter(callable $filter): callable 156 | { 157 | $function = new \ReflectionFunction($filter(...)); 158 | 159 | if (!$parameter = $function->getParameters()[0] ?? null) { 160 | return $filter; 161 | } 162 | 163 | if (!$type = $parameter->getType()) { 164 | return $filter; 165 | } 166 | 167 | if (!$type instanceof \ReflectionNamedType || $type->isBuiltin() || Envelope::class === $type->getName()) { 168 | return $filter; 169 | } 170 | 171 | // user used message class name as type-hint 172 | return static function(Envelope $envelope) use ($filter, $type) { 173 | if ($type->getName() !== $envelope->getMessage()::class) { 174 | return false; 175 | } 176 | 177 | return $filter($envelope->getMessage()); 178 | }; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [v1.13.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.13.0) 4 | 5 | December 6th, 2025 - [v1.12.0...v1.13.0](https://github.com/zenstruck/messenger-test/compare/v1.12.0...v1.13.0) 6 | 7 | * a2fef7e chore: update CI (#100) by @kbond 8 | * 67f1965 chore: upgrade PHPStan to 2.x (#100) by @kbond 9 | * 608fc78 feat: drop Symfony 5.4 (#100) by @kbond 10 | * 53d294a chore: normalize CI (#100) by @kbond 11 | 12 | ## [v1.12.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.12.0) 13 | 14 | November 2nd, 2025 - [v1.11.1...v1.12.0](https://github.com/zenstruck/messenger-test/compare/v1.11.1...v1.12.0) 15 | 16 | * 67347f7 chore: support Symfony 8 (#99) by @nikophil 17 | 18 | ## [v1.11.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.11.1) 19 | 20 | July 14th, 2025 - [v1.11.0...v1.11.1](https://github.com/zenstruck/messenger-test/compare/v1.11.0...v1.11.1) 21 | 22 | * 7fae735 minor: throw exceptions on uninitialized transport (#96) by @kbond 23 | * 64a93e8 minor: fix flaky test (#96) by @kbond 24 | 25 | ## [v1.11.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.11.0) 26 | 27 | September 26th, 2024 - [v1.10.0...v1.11.0](https://github.com/zenstruck/messenger-test/compare/v1.10.0...v1.11.0) 28 | 29 | * d9d711b Reset TestTransport on ensureKernelShutdown (#90) by @HypeMC 30 | * 36329a1 chore: upgrade CI matrix (#92) by @nikophil 31 | * 1f78317 docs: Add installation step to add the bundle in the config (#85) by @dazz, @kbond 32 | 33 | ## [v1.10.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.10.0) 34 | 35 | July 31st, 2024 - [v1.9.3...v1.10.0](https://github.com/zenstruck/messenger-test/compare/v1.9.3...v1.10.0) 36 | 37 | * 07ea68a feat: handler use deserialization version of the message with test_serialization=true #86 (#87) by @jaugustin 38 | * a33dc20 feat: have `TestTransport` additional receiver interfaces (#84) by @kbond 39 | * 7164fce fix: allows to use options configuration (#80) by @aegypius 40 | 41 | ## [v1.9.3](https://github.com/zenstruck/messenger-test/releases/tag/v1.9.3) 42 | 43 | March 21st, 2024 - [v1.9.2...v1.9.3](https://github.com/zenstruck/messenger-test/compare/v1.9.2...v1.9.3) 44 | 45 | * d4a8525 minor: small cleanup (#76) by @kbond 46 | * b91a87f minor: fix sca (#76) by @kbond 47 | * 82cf50b minor: fix typo (#76) by @kbond 48 | * 9f31c86 fix: handle DelayStamp in the past (#75) by @benito103e 49 | 50 | ## [v1.9.2](https://github.com/zenstruck/messenger-test/releases/tag/v1.9.2) 51 | 52 | March 21st, 2024 - [v1.9.1...v1.9.2](https://github.com/zenstruck/messenger-test/compare/v1.9.1...v1.9.2) 53 | 54 | * d1c4d37 fix: add attributes to support PHPUnit 10 + 11 (#73) by @mickverm 55 | * 99856ba docs: add return types to arrow functions (#70) by @carusogabriel 56 | 57 | ## [v1.9.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.9.1) 58 | 59 | November 23rd, 2023 - [v1.9.0...v1.9.1](https://github.com/zenstruck/messenger-test/compare/v1.9.0...v1.9.1) 60 | 61 | * baf5893 fix: set support_delay_stamp to false by default (#69) by @nikophil 62 | 63 | ## [v1.9.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.9.0) 64 | 65 | October 24th, 2023 - [v1.8.0...v1.9.0](https://github.com/zenstruck/messenger-test/compare/v1.8.0...v1.9.0) 66 | 67 | * 0554e7c feat: Symfony 7 support (#67) by @kbond 68 | 69 | ## [v1.8.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.8.0) 70 | 71 | October 18th, 2023 - [v1.7.3...v1.8.0](https://github.com/zenstruck/messenger-test/compare/v1.7.3...v1.8.0) 72 | 73 | * 71d66ed feat: support delay stamp and require php 8.1 (#66) by @nikophil, @kbond 74 | 75 | ## [v1.7.3](https://github.com/zenstruck/messenger-test/releases/tag/v1.7.3) 76 | 77 | October 9th, 2023 - [v1.7.2...v1.7.3](https://github.com/zenstruck/messenger-test/compare/v1.7.2...v1.7.3) 78 | 79 | * 68b535a minor: Add conditional return type for `EnvelopeCollection::messages` (#64) by @norkunas 80 | 81 | ## [v1.7.2](https://github.com/zenstruck/messenger-test/releases/tag/v1.7.2) 82 | 83 | February 24th, 2023 - [v1.7.1...v1.7.2](https://github.com/zenstruck/messenger-test/compare/v1.7.1...v1.7.2) 84 | 85 | * 2e51d27 fix: always collect messages in TestTransport::$queue (#62) by @nikophil 86 | 87 | ## [v1.7.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.7.1) 88 | 89 | February 24th, 2023 - [v1.7.0...v1.7.1](https://github.com/zenstruck/messenger-test/compare/v1.7.0...v1.7.1) 90 | 91 | * 6a179b6 fix(EnvelopeCollection): return type for psalm and method final (#60) by @flohw 92 | 93 | ## [v1.7.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.7.0) 94 | 95 | February 22nd, 2023 - [v1.6.1...v1.7.0](https://github.com/zenstruck/messenger-test/compare/v1.6.1...v1.7.0) 96 | 97 | * 61e8802 feat: Make bus queriable (#54) by @flohw 98 | * 823f155 fix(ci): prevent running fixcs/sync-with-template on forks (#57) by @kbond 99 | * bfa2191 fix(tests): phpunit deprecation (#57) by @kbond 100 | 101 | ## [v1.6.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.6.1) 102 | 103 | February 2nd, 2023 - [v1.6.0...v1.6.1](https://github.com/zenstruck/messenger-test/compare/v1.6.0...v1.6.1) 104 | 105 | * 12c8b18 fix: reinit TestTransport before each test (#56) by @nikophil 106 | 107 | ## [v1.6.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.6.0) 108 | 109 | January 23rd, 2023 - [v1.5.1...v1.6.0](https://github.com/zenstruck/messenger-test/compare/v1.5.1...v1.6.0) 110 | 111 | * fb0bb0c feat: enable messages collection (#55) by @nikophil 112 | * 7fb455d fix(ci): add token by @kbond 113 | * 508f890 chore(ci): fix by @kbond 114 | * 416f086 fix(test): deprecation (#53) by @kbond 115 | * 885fbd8 ci: adjust (#53) by @kbond 116 | 117 | ## [v1.5.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.5.1) 118 | 119 | September 23rd, 2022 - [v1.5.0...v1.5.1](https://github.com/zenstruck/messenger-test/compare/v1.5.0...v1.5.1) 120 | 121 | * 56f667c [bug] ignore receiver detached from transport (#52) by @alli83 122 | 123 | ## [v1.5.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.5.0) 124 | 125 | September 6th, 2022 - [v1.4.2...v1.5.0](https://github.com/zenstruck/messenger-test/compare/v1.4.2...v1.5.0) 126 | 127 | * 0fe8aef [feature] allow `TestTransport::send()` to accept `object|array` (#50) by @kbond 128 | 129 | ## [v1.4.2](https://github.com/zenstruck/messenger-test/releases/tag/v1.4.2) 130 | 131 | June 15th, 2022 - [v1.4.1...v1.4.2](https://github.com/zenstruck/messenger-test/compare/v1.4.1...v1.4.2) 132 | 133 | * 4b4566d [bug] fix processing empty queue (#48) by @kbond 134 | * 6f79b43 [doc] add troubleshooting section and detached entities workaround (#46) by @kbond 135 | * 8efb392 [doc] update config to new `when@test` notation (#46) by @kbond 136 | * 8253ec9 [minor] simplify ci config (#45) by @kbond 137 | * ea298ec [minor] remove scrutinizer (#45) by @kbond 138 | * 2a1a944 [doc] fix: typo in README.md (#42) by @romainallanot 139 | 140 | ## [v1.4.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.4.1) 141 | 142 | March 4th, 2022 - [v1.4.0...v1.4.1](https://github.com/zenstruck/messenger-test/compare/v1.4.0...v1.4.1) 143 | 144 | * a70c269 [bug] reset transport before tests (#40) by @rodnaph 145 | * bb97995 [minor] add conflict for symfony/framework-bundle 5.4.5 & 6.0.5 (#39) by @kbond 146 | * 5a5e40c [minor] fix typehint by @kbond 147 | * 5932352 [minor] add static code analysis with phpstan (#38) by @kbond 148 | 149 | ## [v1.4.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.4.0) 150 | 151 | January 8th, 2022 - [v1.3.0...v1.4.0](https://github.com/zenstruck/messenger-test/compare/v1.3.0...v1.4.0) 152 | 153 | * 8a434ad [feature] add ability to enable retries (#37) by @kbond 154 | * 1381f27 [bug] disable retries (#37) by @kbond 155 | 156 | ## [v1.3.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.3.0) 157 | 158 | December 30th, 2021 - [v1.2.1...v1.3.0](https://github.com/zenstruck/messenger-test/compare/v1.2.1...v1.3.0) 159 | 160 | * 13376bb [minor] give a name to the transport in the Worker (#34) by @nikophil 161 | * 97fb25c [feature] add test_serialization option in dsn (#35) by @nikophil 162 | * cf2ec7f [bug] prevent infinite loop in unblock mode (#36) by @nikophil 163 | 164 | ## [v1.2.1](https://github.com/zenstruck/messenger-test/releases/tag/v1.2.1) 165 | 166 | October 19th, 2021 - [v1.2.0...v1.2.1](https://github.com/zenstruck/messenger-test/compare/v1.2.0...v1.2.1) 167 | 168 | * 8aac8d0 [minor] allow "0" for EnvelopeCollection::assertContains() (#30) by @kbond 169 | 170 | ## [v1.2.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.2.0) 171 | 172 | October 7th, 2021 - [v1.1.0...v1.2.0](https://github.com/zenstruck/messenger-test/compare/v1.1.0...v1.2.0) 173 | 174 | * b02e861 [feature] add EnvelopeCollection::back() (#28) by @kbond 175 | * d649463 [ci] use reusable workflows (#27) by @kbond 176 | 177 | ## [v1.1.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.1.0) 178 | 179 | October 5th, 2021 - [v1.0.0...v1.1.0](https://github.com/zenstruck/messenger-test/compare/v1.0.0...v1.1.0) 180 | 181 | * e54feb3 [feature] process messages with app's event dispatcher (#26) by @kbond 182 | * 9867e39 [minor] refactor service definitions (#25) by @kbond 183 | * 1499a57 [minor] add .editorconfig (#25) by @kbond 184 | * 84442ac [minor] test on Symfony 5.4 (#22) by @kbond 185 | 186 | ## [v1.0.0](https://github.com/zenstruck/messenger-test/releases/tag/v1.0.0) 187 | 188 | September 28th, 2021 - _[Initial Release](https://github.com/zenstruck/messenger-test/commits/v1.0.0)_ 189 | -------------------------------------------------------------------------------- /src/Transport/TestTransport.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Zenstruck\Messenger\Test\Transport; 13 | 14 | use Psr\Clock\ClockInterface; 15 | use Symfony\Component\EventDispatcher\EventDispatcherInterface; 16 | use Symfony\Component\Messenger\Envelope; 17 | use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; 18 | use Symfony\Component\Messenger\Event\WorkerRunningEvent; 19 | use Symfony\Component\Messenger\EventListener\StopWorkerOnMessageLimitListener; 20 | use Symfony\Component\Messenger\MessageBusInterface; 21 | use Symfony\Component\Messenger\Stamp\DelayStamp; 22 | use Symfony\Component\Messenger\Stamp\RedeliveryStamp; 23 | use Symfony\Component\Messenger\Transport\Receiver\ListableReceiverInterface; 24 | use Symfony\Component\Messenger\Transport\Receiver\MessageCountAwareInterface; 25 | use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface; 26 | use Symfony\Component\Messenger\Transport\TransportInterface; 27 | use Symfony\Component\Messenger\Worker; 28 | use Zenstruck\Assert; 29 | use Zenstruck\Messenger\Test\Stamp\AvailableAtStamp; 30 | use Zenstruck\Messenger\Test\TestEnvelope; 31 | 32 | /** 33 | * @author Kevin Bond 34 | */ 35 | final class TestTransport implements TransportInterface, ListableReceiverInterface, MessageCountAwareInterface 36 | { 37 | private const DEFAULT_OPTIONS = [ 38 | 'intercept' => true, 39 | 'catch_exceptions' => true, 40 | 'test_serialization' => true, 41 | 'disable_retries' => true, 42 | 'support_delay_stamp' => false, 43 | ]; 44 | 45 | private string $name; 46 | private EventDispatcherInterface $dispatcher; 47 | private MessageBusInterface $bus; 48 | private SerializerInterface $serializer; 49 | private ?ClockInterface $clock; 50 | 51 | /** @var array */ 52 | private static array $intercept = []; 53 | 54 | /** @var array */ 55 | private static array $catchExceptions = []; 56 | 57 | /** @var array */ 58 | private static array $testSerialization = []; 59 | 60 | /** @var array */ 61 | private static array $disableRetries = []; 62 | 63 | /** @var array */ 64 | private static array $supportDelayStamp = []; 65 | 66 | /** @var array */ 67 | private static array $dispatched = []; 68 | 69 | /** @var array */ 70 | private static array $acknowledged = []; 71 | 72 | /** @var array */ 73 | private static array $rejected = []; 74 | 75 | /** @var array */ 76 | private static array $queue = []; 77 | 78 | // this setting applies to all transports 79 | private static bool $enableMessagesCollection = true; 80 | private static bool $resetOnKernelShutdownEnabled = true; 81 | 82 | /** 83 | * @internal 84 | * 85 | * @param array $options 86 | */ 87 | public function __construct(string $name, MessageBusInterface $bus, EventDispatcherInterface $dispatcher, SerializerInterface $serializer, ?ClockInterface $clock = null, array $options = []) 88 | { 89 | $options = \array_merge(self::DEFAULT_OPTIONS, $options); 90 | 91 | $this->name = $name; 92 | $this->dispatcher = $dispatcher; 93 | $this->bus = $bus; 94 | $this->serializer = $serializer; 95 | $this->clock = $clock; 96 | 97 | self::$intercept[$name] ??= $options['intercept']; 98 | self::$catchExceptions[$name] ??= $options['catch_exceptions']; 99 | self::$testSerialization[$name] ??= $options['test_serialization']; 100 | self::$disableRetries[$name] ??= $options['disable_retries']; 101 | self::$supportDelayStamp[$name] ??= $options['support_delay_stamp']; 102 | 103 | if (!self::$supportDelayStamp[$name]) { 104 | trigger_deprecation('zenstruck/messenger-test', '1.8.0', 'Not supporting DelayStamp is deprecated, support will be removed in 2.0.'); 105 | } elseif (!$this->clock) { 106 | throw new \InvalidArgumentException(\sprintf('A service aliased "%s" must be available in order to support DelayStamp. You can install for instance symfony/clock (composer require symfony/clock).', ClockInterface::class)); 107 | } 108 | } 109 | 110 | /** 111 | * Processes any messages on the queue and processes future messages 112 | * immediately. 113 | */ 114 | public function unblock(): self 115 | { 116 | if ($this->hasMessagesToProcess()) { 117 | // process any messages currently on queue 118 | $this->process(); 119 | } 120 | 121 | self::$intercept[$this->name] = false; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Intercepts any future messages sent to queue. 128 | */ 129 | public function intercept(): self 130 | { 131 | self::$intercept[$this->name] = true; 132 | 133 | return $this; 134 | } 135 | 136 | public function catchExceptions(): self 137 | { 138 | self::$catchExceptions[$this->name] = true; 139 | 140 | return $this; 141 | } 142 | 143 | public function throwExceptions(): self 144 | { 145 | self::$catchExceptions[$this->name] = false; 146 | 147 | return $this; 148 | } 149 | 150 | /** 151 | * Processes messages on the queue. This is done recursively so if handling 152 | * a message dispatches more messages, these will be processed as well (up 153 | * to $number). 154 | * 155 | * @param int $number the number of messages to process (-1 for all) 156 | */ 157 | public function process(int $number = -1): self 158 | { 159 | $processCount = 0; 160 | 161 | // keep track of added listeners/subscribers so we can remove after 162 | $listeners = []; 163 | $subscribers = []; 164 | 165 | $this->dispatcher->addListener( 166 | WorkerRunningEvent::class, 167 | $listeners[WorkerRunningEvent::class] = static function(WorkerRunningEvent $event) use (&$processCount) { 168 | if ($event->isWorkerIdle()) { 169 | // stop worker if no messages to process 170 | $event->getWorker()->stop(); 171 | 172 | return; 173 | } 174 | 175 | ++$processCount; 176 | }, 177 | ); 178 | 179 | if ($number > 0) { 180 | // stop if limit was placed on number to process 181 | $this->dispatcher->addSubscriber($subscribers[] = new StopWorkerOnMessageLimitListener($number)); 182 | } 183 | 184 | if (!$this->isCatchingExceptions()) { 185 | $this->dispatcher->addListener( 186 | WorkerMessageFailedEvent::class, 187 | $listeners[WorkerMessageFailedEvent::class] = static function(WorkerMessageFailedEvent $event) { 188 | throw $event->getThrowable(); 189 | }, 190 | ); 191 | } 192 | 193 | $worker = new Worker([$this->name => $this], $this->bus, $this->dispatcher); 194 | $worker->run(['sleep' => 0]); 195 | 196 | // remove added listeners/subscribers 197 | foreach ($listeners as $event => $listener) { 198 | $this->dispatcher->removeListener($event, $listener); 199 | } 200 | 201 | foreach ($subscribers as $subscriber) { 202 | $this->dispatcher->removeSubscriber($subscriber); 203 | } 204 | 205 | if ($number > 0) { 206 | Assert::that($processCount)->is($number, 'Expected to process {expected} messages but only processed {actual}.'); 207 | } 208 | 209 | return $this; 210 | } 211 | 212 | /** 213 | * Works the same as {@see process()} but fails if no messages on queue. 214 | */ 215 | public function processOrFail(int $number = -1): self 216 | { 217 | Assert::true($this->hasMessagesToProcess(), 'No messages to process.'); 218 | 219 | return $this->process($number); 220 | } 221 | 222 | public function queue(): TransportEnvelopeCollection 223 | { 224 | return new TransportEnvelopeCollection($this, ...self::$queue[$this->name] ?? []); 225 | } 226 | 227 | public function dispatched(): TransportEnvelopeCollection 228 | { 229 | return new TransportEnvelopeCollection($this, ...self::$dispatched[$this->name] ?? []); 230 | } 231 | 232 | public function acknowledged(): TransportEnvelopeCollection 233 | { 234 | return new TransportEnvelopeCollection($this, ...self::$acknowledged[$this->name] ?? []); 235 | } 236 | 237 | public function rejected(): TransportEnvelopeCollection 238 | { 239 | return new TransportEnvelopeCollection($this, ...self::$rejected[$this->name] ?? []); 240 | } 241 | 242 | /** 243 | * @internal 244 | */ 245 | public function get(): iterable 246 | { 247 | if (!isset(self::$queue[$this->name]) || !self::$queue[$this->name]) { 248 | return []; 249 | } 250 | 251 | if (!$this->supportsDelayStamp()) { 252 | if ($this->shouldTestSerialization()) { 253 | // Simulate real transport by encoding/decoding the message 254 | return [$this->serializer->decode($this->serializer->encode(\array_shift(self::$queue[$this->name])))]; 255 | } 256 | 257 | return [\array_shift(self::$queue[$this->name])]; 258 | } 259 | 260 | $now = $this->clock->now(); 261 | 262 | foreach (self::$queue[$this->name] as $i => $envelope) { 263 | if (($availableAtStamp = $envelope->last(AvailableAtStamp::class)) && $now < $availableAtStamp->getAvailableAt()) { 264 | continue; 265 | } 266 | 267 | unset(self::$queue[$this->name][$i]); 268 | 269 | if ($this->shouldTestSerialization()) { 270 | // Simulate real transport by encoding/decoding the message 271 | return [$this->serializer->decode($this->serializer->encode($envelope))]; 272 | } 273 | 274 | return [$envelope]; 275 | } 276 | 277 | return []; 278 | } 279 | 280 | /** 281 | * @internal 282 | */ 283 | public function ack(Envelope $envelope): void 284 | { 285 | $this->collectMessage(self::$acknowledged, $envelope); 286 | } 287 | 288 | /** 289 | * @internal 290 | */ 291 | public function reject(Envelope $envelope): void 292 | { 293 | $this->collectMessage(self::$rejected, $envelope); 294 | } 295 | 296 | public function all(?int $limit = null): iterable 297 | { 298 | return \array_map( 299 | fn(TestEnvelope $envelope) => $envelope->envelope, 300 | \array_slice($this->queue()->all(), 0, $limit), 301 | ); 302 | } 303 | 304 | public function find(mixed $id): ?Envelope 305 | { 306 | throw new \BadMethodCallException('Not supported.'); 307 | } 308 | 309 | public function getMessageCount(): int 310 | { 311 | return $this->queue()->count(); 312 | } 313 | 314 | /** 315 | * @param Envelope|object|array{body: string, headers?: array} $what object: will be wrapped in envelope 316 | * array: will be decoded into envelope 317 | */ 318 | public function send($what): Envelope 319 | { 320 | if (\is_array($what)) { 321 | $what = $this->serializer->decode($what); 322 | } 323 | 324 | if (!\is_object($what)) { 325 | throw new \InvalidArgumentException(\sprintf('"%s()" requires a message/Envelope object or decoded message array. "%s" given.', __METHOD__, \get_debug_type($what))); 326 | } 327 | 328 | $envelope = Envelope::wrap($what); 329 | 330 | if ($this->supportsDelayStamp() && $delayStamp = $envelope->last(DelayStamp::class)) { 331 | $envelope = $envelope->with(AvailableAtStamp::fromDelayStamp($delayStamp, $this->clock->now())); 332 | } 333 | 334 | if ($this->isRetriesDisabled() && $envelope->last(RedeliveryStamp::class)) { 335 | // message is being retried, don't process 336 | return $envelope; 337 | } 338 | 339 | if ($this->shouldTestSerialization()) { 340 | Assert::try( 341 | fn() => $this->serializer->decode($this->serializer->encode($envelope)), 342 | 'A problem occurred in the serialization process.', 343 | ); 344 | } 345 | 346 | $this->collectMessage(self::$dispatched, $envelope); 347 | $this->collectMessage(self::$queue, $envelope, force: true); 348 | 349 | if (!$this->isIntercepting()) { 350 | $this->process(); 351 | } 352 | 353 | return $envelope; 354 | } 355 | 356 | /** 357 | * Resets all the data for this transport. 358 | */ 359 | public function reset(): void 360 | { 361 | self::$queue[$this->name] = self::$dispatched[$this->name] = self::$acknowledged[$this->name] = self::$rejected[$this->name] = []; 362 | } 363 | 364 | /** 365 | * Resets data and options for all transports. 366 | */ 367 | public static function resetAll(): void 368 | { 369 | self::$queue = self::$dispatched = self::$acknowledged = self::$rejected = []; 370 | self::initialize(); 371 | } 372 | 373 | public static function initialize(): void 374 | { 375 | self::$intercept = self::$catchExceptions = self::$testSerialization = self::$disableRetries = self::$supportDelayStamp = []; 376 | } 377 | 378 | public static function enableMessagesCollection(): void 379 | { 380 | self::$enableMessagesCollection = true; 381 | } 382 | 383 | public static function disableMessagesCollection(): void 384 | { 385 | self::$enableMessagesCollection = false; 386 | } 387 | 388 | public function isIntercepting(): bool 389 | { 390 | return self::$intercept[$this->name] ?? throw new \LogicException(\sprintf('Transport "%s" is not initialized.', $this->name)); 391 | } 392 | 393 | public function isCatchingExceptions(): bool 394 | { 395 | return self::$catchExceptions[$this->name] ?? throw new \LogicException(\sprintf('Transport "%s" is not initialized.', $this->name)); 396 | } 397 | 398 | public function shouldTestSerialization(): bool 399 | { 400 | return self::$testSerialization[$this->name] ?? throw new \LogicException(\sprintf('Transport "%s" is not initialized.', $this->name)); 401 | } 402 | 403 | public function isRetriesDisabled(): bool 404 | { 405 | return self::$disableRetries[$this->name] ?? throw new \LogicException(\sprintf('Transport "%s" is not initialized.', $this->name)); 406 | } 407 | 408 | /** 409 | * @phpstan-assert-if-true !null $this->clock 410 | */ 411 | public function supportsDelayStamp(): bool 412 | { 413 | if (!$this->clock) { 414 | return false; 415 | } 416 | 417 | return self::$supportDelayStamp[$this->name] ?? throw new \LogicException(\sprintf('Transport "%s" is not initialized.', $this->name)); 418 | } 419 | 420 | public function resetOnKernelShutdown(): void 421 | { 422 | if (!self::$resetOnKernelShutdownEnabled) { 423 | return; 424 | } 425 | 426 | $this->reset(); 427 | } 428 | 429 | public static function enableResetOnKernelShutdown(): void 430 | { 431 | self::$resetOnKernelShutdownEnabled = true; 432 | } 433 | 434 | public static function disableResetOnKernelShutdown(): void 435 | { 436 | self::$resetOnKernelShutdownEnabled = false; 437 | } 438 | 439 | /** 440 | * @param array $messagesCollection 441 | */ 442 | private function collectMessage(array &$messagesCollection, Envelope $envelope, bool $force = false): void 443 | { 444 | if (!self::$enableMessagesCollection && !$force) { 445 | return; 446 | } 447 | 448 | $messagesCollection[$this->name] ??= []; 449 | $messagesCollection[$this->name][] = $envelope; 450 | } 451 | 452 | private function hasMessagesToProcess(): bool 453 | { 454 | return !empty(self::$queue[$this->name] ?? []); 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zenstruck/messenger-test 2 | 3 | [![CI Status](https://github.com/zenstruck/messenger-test/workflows/CI/badge.svg)](https://github.com/zenstruck/messenger-test/actions?query=workflow%3ACI) 4 | [![Code Coverage](https://codecov.io/gh/zenstruck/messenger-test/branch/1.x/graph/badge.svg?token=R7OHYYGPKM)](https://codecov.io/gh/zenstruck/messenger-test) 5 | 6 | Assertions and helpers for testing your `symfony/messenger` queues. 7 | 8 | This library provides a `TestTransport` that, by default, intercepts any messages 9 | sent to it. You can then inspect and assert against these messages. Sent messages 10 | are serialized and unserialized as an added check. 11 | 12 | The transport also allows for processing these *queued* messages. 13 | 14 | ## Installation 15 | 16 | 1. Install the library: 17 | 18 | ```bash 19 | composer require --dev zenstruck/messenger-test 20 | ``` 21 | 2. If not added automatically by Symfony Flex, add the bundle in `config/bundles.php`: 22 | 23 | ```php 24 | Zenstruck\Messenger\Test\ZenstruckMessengerTestBundle::class => ['test' => true], 25 | ``` 26 | 27 | 3. Update `config/packages/messenger.yaml` and override your transport(s) 28 | in your `test` environment with `test://`: 29 | 30 | ```yaml 31 | # config/packages/messenger.yaml 32 | 33 | # ... 34 | 35 | when@test: 36 | framework: 37 | messenger: 38 | transports: 39 | async: test:// 40 | ``` 41 | 42 | ## Transport 43 | 44 | You can interact with the test transports in your tests by using the 45 | `InteractsWithMessenger` trait in your `KernelTestCase`/`WebTestCase` tests. 46 | You can assert the different steps of message processing by asserting on the queue 47 | and the different states of message processing like "acknowledged", "rejected" and so on. 48 | 49 | > **Note**: If you only need to know if a message has been dispatched you can 50 | > make assertions [on the bus itself](#bus). 51 | 52 | ### Queue Assertions 53 | 54 | ```php 55 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 56 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 57 | 58 | class MyTest extends KernelTestCase // or WebTestCase 59 | { 60 | use InteractsWithMessenger; 61 | 62 | public function test_something(): void 63 | { 64 | // ...some code that routes messages to your configured transport 65 | 66 | // assert against the queue 67 | $this->transport()->queue()->assertEmpty(); 68 | $this->transport()->queue()->assertNotEmpty(); 69 | $this->transport()->queue()->assertCount(3); 70 | $this->transport()->queue()->assertContains(MyMessage::class); // queue contains this message 71 | $this->transport()->queue()->assertContains(MyMessage::class, 3); // queue contains this message 3 times 72 | $this->transport()->queue()->assertContains(MyMessage::class, 0); // queue contains this message 0 times 73 | $this->transport()->queue()->assertNotContains(MyMessage::class); // queue not contains this message 74 | 75 | // access the queue data 76 | $this->transport()->queue(); // Envelope[] 77 | $this->transport()->queue()->messages(); // object[] the messages unwrapped from envelope 78 | $this->transport()->queue()->messages(MyMessage::class); // MyMessage[] just messages matching class 79 | } 80 | } 81 | ``` 82 | 83 | ### Processing The Queue 84 | 85 | ```php 86 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 87 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 88 | 89 | class MyTest extends KernelTestCase // or WebTestCase 90 | { 91 | use InteractsWithMessenger; 92 | 93 | public function test_something(): void 94 | { 95 | // ...some code that routes messages to your configured transport 96 | 97 | // let's assume 3 messages are on this queue 98 | $this->transport()->queue()->assertCount(3); 99 | 100 | $this->transport()->process(1); // process one message 101 | $this->transport()->processOrFail(1); // equivalent to above but fails if queue empty 102 | 103 | $this->transport()->queue()->assertCount(2); // queue now only has 2 items 104 | 105 | $this->transport()->process(); // process all messages on the queue 106 | $this->transport()->processOrFail(); // equivalent to above but fails if queue empty 107 | 108 | $this->transport()->queue()->assertEmpty(); // queue is now empty 109 | } 110 | } 111 | ``` 112 | 113 | **NOTE:** Calling `process()` not only processes messages on the queue but any 114 | messages created during the handling of messages (all by default or up to `$number`). 115 | 116 | ### Other Transport Assertions and Helpers 117 | 118 | ```php 119 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 120 | use Symfony\Component\Messenger\Envelope; 121 | use Symfony\Component\Messenger\Stamp\DelayStamp; 122 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 123 | use Zenstruck\Messenger\Test\Transport\TestTransport; 124 | 125 | class MyTest extends KernelTestCase // or WebTestCase 126 | { 127 | use InteractsWithMessenger; 128 | 129 | public function test_something(): void 130 | { 131 | // manually send a message to your transport 132 | $this->transport()->send(new MyMessage()); 133 | 134 | // send with stamps 135 | $this->transport()->send(Envelope::wrap(new MyMessage(), [new SomeStamp()])); 136 | 137 | // send "pre-encoded" message 138 | $this->transport()->send(['body' => '...']); 139 | 140 | $queue = $this->transport()->queue(); 141 | $dispatched = $this->transport()->dispatched(); 142 | $acknowledged = $this->transport()->acknowledged(); // messages successfully processed 143 | $rejected = $this->transport()->rejected(); // messages not successfully processed 144 | 145 | // The 4 above variables are all instances of Zenstruck\Messenger\Test\EnvelopeCollection 146 | // which is a countable iterator with the following api (using $queue for the example). 147 | // Methods that return Envelope(s) actually return TestEnvelope(s) which is an Envelope 148 | // decorator (all standard Envelope methods can be used) with some stamp-related assertions. 149 | 150 | // collection assertions 151 | $queue->assertEmpty(); 152 | $queue->assertNotEmpty(); 153 | $queue->assertCount(3); 154 | $queue->assertContains(MyMessage::class); // contains this message 155 | $queue->assertContains(MyMessage::class, 3); // contains this message 3 times 156 | $queue->assertNotContains(MyMessage::class); // not contains this message 157 | 158 | // helpers 159 | $queue->count(); // number of envelopes 160 | $queue->all(); // TestEnvelope[] 161 | $queue->messages(); // object[] the messages unwrapped from their envelope 162 | $queue->messages(MyMessage::class); // MyMessage[] just instances of the passed message class 163 | 164 | // get specific envelope 165 | $queue->first(); // TestEnvelope - first one on the collection 166 | $queue->first(MyMessage::class); // TestEnvelope - first where message class is MyMessage 167 | $queue->first(function(Envelope $e): bool { 168 | return $e->getMessage() instanceof MyMessage && $e->getMessage()->isSomething(); 169 | }); // TestEnvelope - first that matches the filter callback 170 | 171 | // Equivalent to above - use the message class as the filter function typehint to 172 | // auto-filter to this message type. 173 | $queue->first(fn(MyMessage $m): bool => $m->isSomething()); // TestEnvelope 174 | 175 | // TestEnvelope stamp assertions 176 | $queue->first()->assertHasStamp(DelayStamp::class); 177 | $queue->first()->assertNotHasStamp(DelayStamp::class); 178 | 179 | // reset collected messages on the transport 180 | $this->transport()->reset(); 181 | 182 | // reset collected messages for all transports 183 | TestTransport::resetAll(); 184 | 185 | // fluid assertions on different EnvelopeCollections 186 | $this->transport() 187 | ->queue() 188 | ->assertNotEmpty() 189 | ->assertContains(MyMessage::class) 190 | ->back() // returns to the TestTransport 191 | ->dispatched() 192 | ->assertEmpty() 193 | ->back() 194 | ->acknowledged() 195 | ->assertEmpty() 196 | ->back() 197 | ->rejected() 198 | ->assertEmpty() 199 | ->back() 200 | ; 201 | } 202 | } 203 | ``` 204 | 205 | ### Processing Exceptions 206 | 207 | By default, when processing a message that fails, the `TestTransport` catches 208 | the exception and adds to the rejected list. You can change this behaviour: 209 | 210 | ```php 211 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 212 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 213 | 214 | class MyTest extends KernelTestCase // or WebTestCase 215 | { 216 | use InteractsWithMessenger; 217 | 218 | public function test_something(): void 219 | { 220 | // ...some code that routes messages to your configured transport 221 | 222 | // disable exception catching 223 | $this->transport()->throwExceptions(); 224 | 225 | // if processing fails, the exception will be thrown 226 | $this->transport()->process(1); 227 | 228 | // re-enable exception catching 229 | $this->transport()->catchExceptions(); 230 | } 231 | } 232 | ``` 233 | 234 | You can enable exception throwing for your transport(s) by default in the transport dsn: 235 | 236 | ```yaml 237 | # config/packages/messenger.yaml 238 | 239 | # ... 240 | 241 | when@test: 242 | framework: 243 | messenger: 244 | transports: 245 | async: test://?catch_exceptions=false 246 | ``` 247 | 248 | ### Unblock Mode 249 | 250 | By default, messages sent to the `TestTransport` are intercepted and added to a 251 | queue, waiting to be processed manually. You can change this behaviour so messages 252 | are handled as they are sent: 253 | 254 | ```php 255 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 256 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 257 | 258 | class MyTest extends KernelTestCase // or WebTestCase 259 | { 260 | use InteractsWithMessenger; 261 | 262 | public function test_something(): void 263 | { 264 | // disable intercept 265 | $this->transport()->unblock(); 266 | 267 | // ...some code that routes messages to your configured transport 268 | // ...these messages are handled immediately 269 | 270 | // enable intercept 271 | $this->transport()->intercept(); 272 | 273 | // ...some code that routes messages to your configured transport 274 | 275 | // if messages are on the queue when calling unblock(), they are processed 276 | $this->transport()->unblock(); 277 | } 278 | } 279 | ``` 280 | 281 | You can disable intercepting messages for your transport(s) by default in the transport dsn: 282 | 283 | ```yaml 284 | # config/packages/messenger.yaml 285 | 286 | # ... 287 | 288 | when@test: 289 | framework: 290 | messenger: 291 | transports: 292 | async: test://?intercept=false 293 | ``` 294 | 295 | ### Testing Serialization 296 | 297 | By default, the `TestTransport` tests that messages can be serialized and deserialized. 298 | This behavior can be disabled with the transport dsn: 299 | 300 | ```yaml 301 | # config/packages/messenger.yaml 302 | 303 | # ... 304 | 305 | when@test: 306 | framework: 307 | messenger: 308 | transports: 309 | async: test://?test_serialization=false 310 | ``` 311 | 312 | ### Multiple Transports 313 | 314 | If you have multiple transports you'd like to test, change all their dsn's to 315 | `test://` in your test environment: 316 | 317 | ```yaml 318 | # config/packages/messenger.yaml 319 | 320 | # ... 321 | 322 | when@test: 323 | framework: 324 | messenger: 325 | transports: 326 | low: test:// 327 | high: test:// 328 | ``` 329 | 330 | In your tests, pass the name to the `transport()` method: 331 | 332 | ```php 333 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 334 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 335 | 336 | class MyTest extends KernelTestCase // or WebTestCase 337 | { 338 | use InteractsWithMessenger; 339 | 340 | public function test_something(): void 341 | { 342 | $this->transport('high')->queue(); 343 | $this->transport('low')->dispatched(); 344 | } 345 | } 346 | ``` 347 | 348 | ### Support of `DelayStamp` 349 | 350 | Support of `DelayStamp` could be enabled per transport, within its dsn: 351 | 352 | ```yaml 353 | # config/packages/messenger.yaml 354 | 355 | when@test: 356 | framework: 357 | messenger: 358 | transports: 359 | async: test://?support_delay_stamp=true 360 | ``` 361 | 362 | > [!NOTE] 363 | > Support of delay stamp was added in version 1.8.0. 364 | 365 | #### Usage of a clock 366 | 367 | > [!WARNING] 368 | > Support of delay stamp needs an implementation of [PSR-20 Clock](https://www.php-fig.org/psr/psr-20/). 369 | 370 | You can, for example use Symfony's clock component: 371 | ```bash 372 | composer require symfony/clock 373 | ``` 374 | 375 | When using Symfony's clock component, the service will be automatically configured. 376 | Otherwise, you need to configure it manually: 377 | 378 | ```yaml 379 | # config/services.yaml 380 | services: 381 | app.clock: 382 | class: Some\Clock\Implementation 383 | Psr\Clock\ClockInterface: '@app.clock' 384 | ``` 385 | 386 | #### Example of code supporting `DelayStamp` 387 | 388 | > [!NOTE] 389 | > This example uses `symfony/clock` component, but you can use any other implementation of `Psr\Clock\ClockInterface`. 390 | 391 | ```php 392 | 393 | // Let's say somewhere in your app, you register some actions that should occur in the future: 394 | 395 | $bus->dispatch(new Enevelope(new TakeSomeAction1(), [DelayStamp::delayFor(new \DateInterval('P1D'))])); // will be handled in 1 day 396 | $bus->dispatch(new Enevelope(new TakeSomeAction2(), [DelayStamp::delayFor(new \DateInterval('P3D'))])); // will be handled in 3 days 397 | 398 | // In your test, you can check that the action is not yet performed: 399 | 400 | class TestDelayedActions extends KernelTestCase 401 | { 402 | use InteractsWithMessenger; 403 | use ClockSensitiveTrait; 404 | 405 | public function testDelayedActions(): void 406 | { 407 | // 1. mock the clock, in order to perform sleeps 408 | $clock = self::mockTime(); 409 | 410 | // 2. trigger the action that will dispatch the two messages 411 | 412 | // ... 413 | 414 | // 3. assert nothing happens yet 415 | $transport = $this->transport('async'); 416 | 417 | $transport->process(); 418 | $transport->queue()->assertCount(2); 419 | $transport->acknowledged()->assertCount(0); 420 | 421 | // 4. sleep, process queue, and assert some messages have been handled 422 | $clock->sleep(60 * 60 * 24); // wait one day 423 | $transport->process()->acknowledged()->assertContains(TakeSomeAction1::class); 424 | $this->asssertTakeSomeAction1IsHandled(); 425 | 426 | // TakeSomeAction2 is still in the queue 427 | $transport->queue()->assertCount(1); 428 | 429 | $clock->sleep(60 * 60 * 24 * 2); // wait two other days 430 | $transport->process()->acknowledged()->assertContains(TakeSomeAction2::class); 431 | $this->asssertTakeSomeAction2IsHandled(); 432 | } 433 | } 434 | ``` 435 | 436 | #### `DelayStamp` and unblock mode 437 | 438 | "delayed" messages cannot be handled by the unblocking mechanism, `$transport->process()` must be called after a 439 | `sleep()` has been made. 440 | 441 | ### Enable Retries 442 | 443 | By default, the `TestTransport` does not retry failed messages (your retry settings 444 | are ignored). This behavior can be disabled with the transport dsn: 445 | 446 | ```yaml 447 | # config/packages/messenger.yaml 448 | 449 | when@test: 450 | framework: 451 | messenger: 452 | transports: 453 | async: test://?disable_retries=false 454 | ``` 455 | 456 | > [!NOTE] 457 | > When using retries along with `support_delay_stamp` you must mock the time to sleep between retries. 458 | 459 | 460 | ## Bus 461 | 462 | In addition to transport testing you also can make assertions on the bus. You can test message 463 | handling by using the same `InteractsWithMessenger` trait in your `KernelTestCase` / `WebTestCase` tests. 464 | This is especially useful when you only need to test if a message has been dispatched 465 | by a specific bus but don't need to know how the handling has been made. 466 | 467 | It allows you to use your custom transport while asserting your messages are still dispatched properly. 468 | 469 | ### Single bus 470 | 471 | ```php 472 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 473 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 474 | 475 | class MyTest extends KernelTestCase 476 | { 477 | use InteractsWithMessenger; 478 | 479 | public function test_something(): void 480 | { 481 | // ... some code that uses the bus 482 | 483 | // Let's assume two messages are processed 484 | $this->bus()->dispatched()->assertCount(2); 485 | 486 | $this->bus()->dispatched()->assertContains(MessageA::class, 1); 487 | $this->bus()->dispatched()->assertContains(MessageB::class, 1); 488 | } 489 | } 490 | ``` 491 | 492 | ### Multiple buses 493 | 494 | If you use multiple buses you can test that a specific bus has handled its own 495 | messages. 496 | 497 | ```yaml 498 | # config/packages/messenger.yaml 499 | 500 | # ... 501 | 502 | framework: 503 | messenger: 504 | default_bus: bus_c 505 | buses: 506 | bus_a: ~ 507 | bus_b: ~ 508 | bus_c: ~ 509 | ``` 510 | 511 | In your tests, pass the name to the `bus()` method: 512 | 513 | ```php 514 | use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; 515 | use Zenstruck\Messenger\Test\InteractsWithMessenger; 516 | 517 | class MyTest extends KernelTestCase 518 | { 519 | use InteractsWithMessenger; 520 | 521 | public function test_something(): void 522 | { 523 | // ... some code that use bus 524 | 525 | // Let's assume two messages are handled by two different buses 526 | $this->bus('bus-a')->dispatched()->assertCount(1); 527 | $this->bus('bus-b')->dispatched()->assertCount(1); 528 | $this->bus('bus-c')->dispatched()->assertCount(0); 529 | 530 | $this->bus('bus-a')->dispatched()->assertContains(MessageA::class, 1); 531 | $this->bus('bus-b')->dispatched()->assertContains(MessageB::class, 1); 532 | } 533 | } 534 | ``` 535 | 536 | ## Troubleshooting 537 | 538 | ### Detached Doctrine Entities 539 | 540 | When processing messages in your tests that interact with Doctrine entities you may 541 | notice they become detached from the object manager after processing. This is because 542 | of [`DoctrineClearEntityManagerWorkerSubscriber`](https://github.com/symfony/symfony/blob/0e9cfc38e81464d9394ac6fa061e7962a6fe485d/src/Symfony/Bridge/Doctrine/Messenger/DoctrineClearEntityManagerWorkerSubscriber.php) 543 | which clears the object managers after a message is processed. Currently, the only 544 | way to disable this functionality is to disable the service in your `test` environment: 545 | 546 | ```yaml 547 | # config/packages/messenger.yaml 548 | 549 | # ... 550 | 551 | when@test: 552 | # ... 553 | 554 | services: 555 | # DoctrineClearEntityManagerWorkerSubscriber service 556 | doctrine.orm.messenger.event_subscriber.doctrine_clear_entity_manager: 557 | class: stdClass # effectively disables this service in your test env 558 | ``` 559 | --------------------------------------------------------------------------------