Keeping Plugins and Observers in Magento Testable with PHPUnit
AI generated
@test
assert
Magento 2 · PHPUnit · Plugins · Observers
Keeping Plugins and Observers in Magento Testable
Isolate interceptors, extract observers, mock the EventManager

Magento plugins and observers are powerful extension points, but their tight coupling to the Magento ObjectManager makes them hard to test. With the right refactoring approach, the core logic of any plugin or observer can be extracted and tested in isolation with PHPUnit, entirely without a Magento bootstrap.

15 min read Plugins · Observers · EventManager · Interceptors · Mocks Magento 2.4.x · PHPUnit 11 · PHP 8.2

1. The testability problem with Magento plugins

Magento 2 uses an interceptor mechanism for plugins that generates proxy classes automatically at runtime. These generated classes are not available in unit tests without a full Magento bootstrap. If plugin logic is implemented directly inside the plugin class and that class is deeply dependent on the ObjectManager, the Registry, or generated interfaces, an isolated unit test simply is not possible. This is the origin of the common misconception that Magento code is fundamentally untestable.

The solution does not lie in the testing framework, but in the plugin design. The core principle: the business logic of a plugin is not implemented in the plugin itself, but in a standalone service class. The plugin merely delegates to that service. The service class knows nothing of the ObjectManager or Magento-specific classes and is therefore fully testable in isolation. The plugin itself stays thin, ideally five lines minus the delegation.

The same logic applies to observers. The observer receives an Observer object carrying the event, extracts the data from it, and passes it on to a service. The observer itself contains no business logic, only data access and delegation. With this pattern the observer, too, is testable in isolation, because the event object is easy to mock and the service is tested separately.

2. Anatomy of a Magento plugin

A Magento plugin is an ordinary PHP class that gets bound to another class through di.xml configuration. Plugins can define three kinds of methods: before{MethodName} is called before the original method and can modify its arguments. after{MethodName} is called after the original method and can modify its result. around{MethodName} wraps the original method entirely and receives a callable $proceed argument that can be used to invoke the original method.

Around plugins are powerful, but problematic: they can accidentally fail to call the original method, and they make debugging considerably harder when several around plugins act on the same method. For tests, around plugins mean that the $proceed callable has to be mocked or simulated with a closure. For most use cases, before and after plugins are preferable, because they are simpler to test and less error-prone.


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Model\Product;
use Mironsoft\Catalog\Service\PriceModifierService;

/**
 * Plugin for Magento Product, delegates to PriceModifierService.
 *
 * The plugin itself contains no business logic.
 * All logic lives in PriceModifierService, which is unit-testable.
 */
class ProductPricePlugin
{
    public function __construct(
        private readonly PriceModifierService $priceModifier
    ) {}

    /**
     * Modify price after getPrice() is called on Product.
     *
     * @param Product $subject
     * @param float|null $result
     * @return float|null
     */
    public function afterGetPrice(Product $subject, float|null $result): float|null
    {
        if ($result === null) {
            return null;
        }

        return $this->priceModifier->applyModifications(
            price: $result,
            productType: $subject->getTypeId(),
            customerGroupId: (int) $subject->getCustomerGroupId()
        );
    }
}

3. Extracting logic from plugins

The first step toward a testable plugin architecture is consistently extracting the business logic into a dedicated service. This service has no knowledge that it is being called from a plugin. It receives scalar values or value objects as parameters and returns scalar values or value objects. It has no dependency on Magento classes like Product, Order, or Quote, at least not on those that require the ObjectManager to be instantiated.

If the service does depend on Magento classes, those are declared as interfaces that can be mocked easily in tests. A PriceModifierService that relies on CustomerGroupRepositoryInterface to look up group discounts receives that repository as a constructor parameter. In the test, the repository is replaced with a mock that returns controlled test values.

The result: the service is fully testable in isolation. The plugin is too thin to need its own tests, its only job is delegation. If the service works correctly, the plugin works too. A single integration test that verifies the plugin actually fires is enough for the plugin itself.

4. Testing the plugin class with PHPUnit

Even though the plugin is thin, it can make sense to test it, in particular the interplay between plugin and service. In a unit test, the service is mocked, and so is the subject object (e.g. Product). The test checks whether the plugin calls the service with the correct parameters and whether the service's result is passed through correctly.

PHPUnit offers the createMock() method for this, which creates a stub implementation of the interface or class. With expects($this->once())->method('applyModifications')->with(...)->willReturn(...), it is precisely specified which method should be called with which arguments and what value it returns. That is a precise behavior specification, not a loose check.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Unit\Catalog\Plugin;

use Magento\Catalog\Model\Product;
use Mironsoft\Catalog\Plugin\ProductPricePlugin;
use Mironsoft\Catalog\Service\PriceModifierService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

/**
 * Unit test for ProductPricePlugin.
 *
 * Tests delegation to PriceModifierService without Magento bootstrap.
 */
final class ProductPricePluginTest extends TestCase
{
    private PriceModifierService&MockObject $priceModifier;
    private Product&MockObject $product;
    private ProductPricePlugin $plugin;

    protected function setUp(): void
    {
        $this->priceModifier = $this->createMock(PriceModifierService::class);
        $this->product = $this->createMock(Product::class);
        $this->plugin = new ProductPricePlugin($this->priceModifier);
    }

    #[\PHPUnit\Framework\Attributes\Test]
    public function delegatesToPriceModifierWithCorrectArguments(): void
    {
        $this->product->method('getTypeId')->willReturn('simple');
        $this->product->method('getCustomerGroupId')->willReturn('1');

        $this->priceModifier
            ->expects($this->once())
            ->method('applyModifications')
            ->with(price: 99.99, productType: 'simple', customerGroupId: 1)
            ->willReturn(89.99);

        $result = $this->plugin->afterGetPrice($this->product, 99.99);
        $this->assertSame(89.99, $result);
    }

    #[\PHPUnit\Framework\Attributes\Test]
    public function returnsNullWhenOriginalPriceIsNull(): void
    {
        $this->priceModifier->expects($this->never())->method('applyModifications');
        $result = $this->plugin->afterGetPrice($this->product, null);
        $this->assertNull($result);
    }
}

5. Observer structure for testability

A Magento observer implements Magento\Framework\Event\ObserverInterface and has a single method: execute(Observer $observer): void. The observer receives an Observer object holding the fired event. Event data is extracted through $observer->getEvent()->getData('quote') or more specific methods such as $observer->getEvent()->getQuote().

The testable structure: the observer extracts the data from the event and passes it as typed parameters to a service. All business logic lives in the service. The observer object is mocked in the test, as is the event object. With PHPUnit, a method chain such as $observer->getEvent()->getQuote() can be simulated through nested mocks.


<?php

declare(strict_types=1);

namespace Mironsoft\Tests\Unit\Checkout\Observer;

use Magento\Framework\Event;
use Magento\Framework\Event\Observer;
use Magento\Quote\Model\Quote;
use Mironsoft\Checkout\Observer\ApplyCartRulesObserver;
use Mironsoft\Checkout\Service\CartRuleApplierService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;

/**
 * Unit test for ApplyCartRulesObserver.
 *
 * Verifies that the observer extracts Quote from Event
 * and delegates to CartRuleApplierService.
 */
final class ApplyCartRulesObserverTest extends TestCase
{
    private CartRuleApplierService&MockObject $ruleApplier;
    private Observer&MockObject $observerMock;
    private Event&MockObject $eventMock;
    private Quote&MockObject $quoteMock;

    protected function setUp(): void
    {
        $this->ruleApplier = $this->createMock(CartRuleApplierService::class);
        $this->observerMock = $this->createMock(Observer::class);
        $this->eventMock = $this->createMock(Event::class);
        $this->quoteMock = $this->createMock(Quote::class);

        $this->observerMock->method('getEvent')->willReturn($this->eventMock);
        $this->eventMock->method('getQuote')->willReturn($this->quoteMock);
    }

    #[\PHPUnit\Framework\Attributes\Test]
    public function appliesCartRulesForValidQuote(): void
    {
        $this->ruleApplier
            ->expects($this->once())
            ->method('applyRulesForQuote')
            ->with($this->quoteMock);

        $observer = new ApplyCartRulesObserver($this->ruleApplier);
        $observer->execute($this->observerMock);
    }
}

6. Mocking the EventManager and event objects

If the code under test itself dispatches events (e.g. $this->eventManager->dispatch('mironsoft_price_changed', ['product' => $product])), the EventManager needs to be mocked. The interface Magento\Framework\Event\ManagerInterface has a single method, dispatch(string $eventName, array $data = []): void, which is easy to mock. The test checks that the EventManager is called with the correct event name and the correct data.

If the code under test depends on data returned from the event, in Magento dispatch() is void, but data is sometimes returned through referenced objects in the data array, the mock configuration needs to account for that. The mocked object is passed directly in the data array, so the code under test can call methods on it and the mock returns controlled values.

7. Safely testing around plugins

Around plugins receive the subject as the first parameter and the $proceed callable as the second. In the test, this callable needs to be simulated. That works with a simple PHP closure that returns the expected value. This makes it possible to verify whether the around plugin calls the original method ($proceed is called with the correct arguments), whether it modifies the result correctly, and whether it skips the original method in error cases.

The test for an around plugin that checks a cache and skips the original method on a cache hit passes a closure as $proceed that sets a spy flag when it is called. After invoking the around plugin, the test checks whether the spy flag was set or not, depending on the expected behavior. That is more elegant than a full mock and considerably easier to read.

8. Integration with Magento integration tests

Not every plugin and observer test can be a unit test. If a plugin depends on the Magento data model and the correctness of the interplay needs to be verified, an integration test is the right approach. Magento ships its own integration test framework under dev/tests/integration/, which performs a full Magento bootstrap and runs tests against a real test database.

In Magento integration tests, objects are instantiated through the ObjectManager ($this->objectManager->create(ProductPricePlugin::class)), which takes the full DI configuration into account. That is slower than unit tests (seconds instead of milliseconds), but necessary to verify that the di.xml configuration is correct and that the plugin actually attaches to the right method. Unit tests for logic, integration tests for wiring, this split is especially important in Magento projects.

9. Test strategies compared

The choice of test strategy for plugins and observers has a direct impact on test speed and the strength of the signal a test provides.

Test strategy Advantages Drawbacks Use case
Unit test: service Milliseconds, no Magento No di.xml test Testing business logic in isolation
Unit test: plugin Checks delegation and null guards No interceptor test Verifying plugin coupling
Magento integration Full DI, real data Slow, needs a database Verifying wiring and di.xml
No test Quickly "shipped" Regressions go unnoticed Acceptable only for throwaway code

Mironsoft

Magento 2 development, plugin architecture, and test strategy

Want your Magento plugins to be testable?

We analyze your Magento plugins and observers, extract business logic into testable services, and build a test architecture that combines fast unit tests with targeted integration tests.

Plugin refactoring

Move logic into testable services without breaking existing functionality

Test architecture

Build unit tests for services and integration tests for DI wiring

CI pipeline

Integrate PHPUnit into your Magento CI pipeline with test suites and coverage

10. Summary

Keeping Magento plugins and observers testable is not a question of the framework, but of the design. Anyone who consistently extracts business logic out of plugins and observers into standalone services can test those services in isolation with PHPUnit, without a Magento bootstrap, without a database connection, in milliseconds. The plugin or observer itself stays thin and needs hardly any tests of its own, because its only responsibility is delegation.

The combination of fast unit tests for services and targeted integration tests for the DI wiring is the practical test strategy for Magento 2 projects. It enables fast feedback during development and complete coverage before deployment. Anyone who applies this pattern consistently discovers that Magento code is in fact testable, it just needs the right architectural foundation.

Keeping Magento plugins and observers testable: the essentials at a glance

Extract logic

Move business logic out of the plugin and observer into standalone services. Plugin and observer only delegate, no logic of their own.

Test services in isolation

Services have no dependency on the ObjectManager or generated classes. PHPUnit unit tests run without a Magento bootstrap in milliseconds.

Mocks for events

Observer tests mock the event and observer object. EventManager tests mock ManagerInterface. Use nested mocks for method chains.

Integration tests

Verify DI wiring and di.xml configuration only through Magento integration tests. Unit tests for logic, integration tests for wiring.

11. FAQ: Testing Magento Plugins and Observers

1Testing plugins without a Magento bootstrap?
Yes, if logic has been extracted into services. The service knows nothing about Magento and is testable with PHPUnit without a bootstrap.
2Why are around plugins harder to test?
The $proceed callable has to be simulated with a closure. Around plugins can accidentally skip the original method.
3Mocking nested event objects?
The observer mock returns the event mock, the event mock returns the data. Create and configure both mocks separately with willReturn().
4Which parts of a plugin need unit tests?
The extracted service needs extensive tests. The plugin itself only needs tests for delegation and guard conditions. Wiring is verified by an integration test.
5Mocking Magento models such as Product?
Yes, createMock(Product::class) works. Problematic are classes that use generated interceptors or the ObjectManager in the constructor.
6Unit test vs. Magento integration test?
Unit tests: no bootstrap, milliseconds, isolated classes. Integration tests: full bootstrap, database, DI wiring, and interplay of real classes.
7Does every observer need to be tested?
Yes, if it contains business logic. If the observer only delegates, a service test is enough. The observer test verifies event extraction and correct delegation.
8Testing plugin wiring?
Only through a Magento integration test. The ObjectManager instantiates the plugin with real DI, calls the original class, and checks the result.
9Around plugin: not forgetting the original method?
Pass a closure as $proceed with a counter variable. After invoking the plugin, check that the counter is exactly 1.
10Mocking the EventManager in unit tests?
createMock(ManagerInterface::class) and expects($this->once())->method('dispatch')->with('event_name') to check whether the right event is dispatched.