Magento Unit Tests: What Actually Works Without Bootstrap
AI generated
@test
assert
Magento 2 · PHPUnit · Unit Tests · ViewModel
Magento Unit Tests:
What Actually Works Without Bootstrap

Magento unit tests without bootstrap run in seconds instead of minutes. But not every Magento class can be tested meaningfully without framework initialization. ViewModels, pure service classes, plugins performing simple data transformations and pricing logic are ideal candidates. Block rendering, layout processing and observer chains, on the other hand, need the bootstrap, and that is not a failure of the test.

16 min read ViewModel · Plugin · ServiceClass · Repository · DataObject Magento 2.4.8 · PHP 8.4 · PHPUnit 11

1. The bootstrap problem in Magento tests

Magento 2 ships with two distinct PHPUnit configurations: one for unit tests (dev/tests/unit/phpunit.xml) and one for integration and functional tests (dev/tests/integration/phpunit.xml). The decisive difference: unit tests do not use a Magento bootstrap. They load only autoloading and the test class itself, with no database connection, no dependency injection container initialization, no event observer registration. That makes unit tests extremely fast, typically under 5 seconds for a complete suite.

But that raises an important question: which Magento classes can be tested meaningfully without bootstrap? The answer depends on how much of the class relies on Magento infrastructure. A class that processes only plain PHP objects and receives all dependencies through constructor injection is ideal for unit tests. A class that internally calls $this->_objectManager->get() or accesses the event bus cannot be tested meaningfully without bootstrap, not because PHPUnit fails, but because the test would be the wrong tool for the job.

2. What is meaningfully testable without bootstrap

The rule for Magento unit tests is simple: anything that receives its dependencies exclusively through constructor parameters and makes no internal calls to the Magento DI container, the database layer or the event system is testable without bootstrap. In a well-structured Magento module, that covers the bulk of your own business logic:

ViewModels implement ArgumentInterface and receive all dependencies through the constructor. They transform data from repositories and configuration into template-ready formats, pure PHP logic, perfect for unit tests. Service classes that implement business logic are equally ideal: calculating tiered prices, applying discounts, estimating delivery dates. Plugins (around, before, after) on your own classes that make no internal Magento calls can be tested fully in isolation. DataObjects and value objects are the simplest case: setters, getters and validation logic with no infrastructure dependency whatsoever.


<?php
// tests/Unit/ViewModel/ProductBadgeViewModelTest.php
declare(strict_types=1);

namespace Tests\Unit\ViewModel;

use PHPUnit\Framework\TestCase;
use Mironsoft\Catalog\ViewModel\ProductBadgeViewModel;
use Mironsoft\Catalog\Model\BadgeConfig;

class ProductBadgeViewModelTest extends TestCase
{
    private ProductBadgeViewModel $viewModel;

    protected function setUp(): void
    {
        $configMock = $this->createMock(BadgeConfig::class);
        $configMock->method('getNewBadgeDays')->willReturn(14);
        $configMock->method('isSaleBadgeEnabled')->willReturn(true);

        $this->viewModel = new ProductBadgeViewModel($configMock);
    }

    public function testIsNewProductWithinConfiguredDays(): void
    {
        $createdAt = new \DateTimeImmutable('-7 days');
        $this->assertTrue($this->viewModel->isNew($createdAt));
    }

    public function testIsNotNewProductBeyondConfiguredDays(): void
    {
        $createdAt = new \DateTimeImmutable('-20 days');
        $this->assertFalse($this->viewModel->isNew($createdAt));
    }

    public function testSaleBadgeLabelContainsCurrency(): void
    {
        $label = $this->viewModel->getSaleBadgeLabel(100.00, 79.95, 'EUR');
        $this->assertStringContainsString('EUR', $label);
        $this->assertStringContainsString('20', $label); // ~20% discount
    }
}

3. Testing ViewModels: data transformation without layout

ViewModels are the most easily testable classes in a Magento module because, by design, they inherit no block infrastructure. Unlike block classes, they have no _toHtml() method, no template engine call and no layout dependency. They implement only business logic for the template. That makes them ideal candidates for unit tests without bootstrap.

A well-structured ViewModel unit test verifies the data transformation entirely in isolation: are prices formatted correctly? Does the method return the correct stock status for a sold-out item? Are text fragments assembled correctly depending on configuration? These tests document the behavior of the template helper functions and guard against regressions when configuration changes. With a data provider covering various price and discount combinations, many edge cases can be covered with very little code.


<?php
// tests/Unit/ViewModel/ShippingEstimatorViewModelTest.php
declare(strict_types=1);

namespace Tests\Unit\ViewModel;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Mironsoft\Checkout\ViewModel\ShippingEstimatorViewModel;
use Mironsoft\Checkout\Service\ShippingDaysCalculator;

class ShippingEstimatorViewModelTest extends TestCase
{
    #[DataProvider('shippingScenarioProvider')]
    public function testFormatsShippingMessageCorrectly(
        int   $daysUntilDelivery,
        bool  $isExpress,
        string $expectedFragment
    ): void {
        $calculatorMock = $this->createMock(ShippingDaysCalculator::class);
        $calculatorMock->method('calculate')->willReturn($daysUntilDelivery);

        $viewModel = new ShippingEstimatorViewModel($calculatorMock);
        $message   = $viewModel->getEstimatedDeliveryMessage($isExpress);

        $this->assertStringContainsString($expectedFragment, $message);
    }

    public static function shippingScenarioProvider(): array
    {
        return [
            'standard next day'     => [1, false, 'morgen'],
            'standard 3 days'       => [3, false, '3 Werktage'],
            'express same day'      => [0, true,  'heute'],
            'express next day'      => [1, true,  'morgen'],
            'standard long delay'   => [7, false, '7 Werktage'],
        ];
    }

    public function testReturnsEmptyStringWhenCalculationFails(): void
    {
        $calculatorMock = $this->createMock(ShippingDaysCalculator::class);
        $calculatorMock->method('calculate')
            ->willThrowException(new \RuntimeException('Service unavailable'));

        $viewModel = new ShippingEstimatorViewModel($calculatorMock);

        // ViewModel must handle exceptions gracefully, templates cannot catch exceptions
        $this->assertSame('', $viewModel->getEstimatedDeliveryMessage(false));
    }
}

4. Testing plugins: verifying argument manipulation in isolation

Magento plugins (interceptors) are another type that can be tested well without bootstrap, provided the plugin itself has no internal dependencies on Magento infrastructure. An AroundPlugin that transforms arguments or skips certain calls based on conditions can be tested fully in isolation: you call the aroundMethodName() function directly and pass a mocked subject and a mocked callable as the proceed closure.

The test pattern for around plugins is always the same: a closure is passed as the $proceed argument that returns a predefined value. The test then checks whether the plugin calls the closure under certain conditions (the normal path) or skips it (the bypass path), and whether the arguments were modified correctly before being passed on. This pattern works without any knowledge of the actual plugin subject, the plugin's business logic is entirely front and center.


<?php
// tests/Unit/Plugin/TaxExemptionPluginTest.php
declare(strict_types=1);

namespace Tests\Unit\Plugin;

use PHPUnit\Framework\TestCase;
use Mironsoft\Tax\Plugin\TaxExemptionPlugin;
use Magento\Tax\Api\TaxCalculationInterface;
use Magento\Tax\Api\Data\QuoteDetailsInterface;

class TaxExemptionPluginTest extends TestCase
{
    private TaxExemptionPlugin $plugin;

    protected function setUp(): void
    {
        $this->plugin = new TaxExemptionPlugin();
    }

    public function testSkipsTaxCalculationForExemptCustomer(): void
    {
        $subject = $this->createMock(TaxCalculationInterface::class);
        $details = $this->createMock(QuoteDetailsInterface::class);

        // Customer extension attributes indicate exemption
        $extensionMock = $this->createMock(\Magento\Tax\Api\Data\QuoteDetailsExtensionInterface::class);
        $extensionMock->method('getIsTaxExempt')->willReturn(true);
        $details->method('getExtensionAttributes')->willReturn($extensionMock);

        $proceedCalled = false;
        $proceed = function () use (&$proceedCalled) {
            $proceedCalled = true;
            return null;
        };

        $result = $this->plugin->aroundCalculateTax($subject, $proceed, $details, 'DE', true);

        $this->assertFalse($proceedCalled, 'Proceed must not be called for exempt customers');
        $this->assertNull($result);
    }

    public function testPassesThroughForNonExemptCustomer(): void
    {
        $subject        = $this->createMock(TaxCalculationInterface::class);
        $details        = $this->createMock(QuoteDetailsInterface::class);
        $extensionMock  = $this->createMock(\Magento\Tax\Api\Data\QuoteDetailsExtensionInterface::class);
        $extensionMock->method('getIsTaxExempt')->willReturn(false);
        $details->method('getExtensionAttributes')->willReturn($extensionMock);

        $expectedResult = $this->createMock(\Magento\Tax\Api\Data\TaxDetailsInterface::class);
        $proceed = fn() => $expectedResult;

        $result = $this->plugin->aroundCalculateTax($subject, $proceed, $details, 'DE', true);

        $this->assertSame($expectedResult, $result);
    }
}

5. Service classes and pricing logic

Pure service classes, classes that implement only business logic and receive all dependencies through the constructor, are the most testable components in Magento projects. A class that calculates tiered prices, applies quantity discounts and checks minimum order quantities has no infrastructure dependencies and can be tested with fully mocked repositories in milliseconds.

What matters here is the distinction between price calculation (pure logic, ideal for unit tests) and price rendering (template engine plus locale and currency formatting, which partly needs the framework). Calculating whether a discount applies and how large it is belongs in a testable service class. Formatting the price as € 49.95 belongs in a Hyva ViewModel, which is itself testable, but receives the formatting engine as a mock.

6. Where unit tests reach their limits

There are Magento classes that cannot be tested meaningfully without bootstrap, and it is important to know these limits so you do not waste time on brittle test setups. Block classes that inherit from AbstractBlock have context object dependencies further up their inheritance chain that in turn require the DI container. Observers triggered through the event system cannot be tested meaningfully without the full Magento event infrastructure.

Repository implementations also belong in the integration test category: they talk directly to the resource model, which needs a database connection. The correct approach is to mock the repository interface and cover the implementation with integration tests. That is not a compromise, it is the correct tool for the job: repository implementations test the interaction with MySQL, not the business logic, and MySQL tests require a bootstrap.

7. Unit vs. integration: a decision matrix

The decision as to which test type is appropriate for which Magento component follows a simple principle: if the class has framework dependencies that cannot be replaced by mocks, it needs an integration test.

Magento component Unit test Integration test Reasoning
ViewModel Yes Not needed Pure logic, no block infrastructure
Service class Yes Optional for DB-adjacent services Mockable dependencies via constructor
Plugin (simple) Yes Optional Proceed closure is testably simulated
Repository interface Mock in unit test Implementation Mock the interface, test the impl. with a DB
Block (AbstractBlock) Not meaningful Yes Context requires DI container
Observer Logic extraction only Yes Event system needs bootstrap

The key takeaway from the table: unit tests are meaningful for every component that receives its dependencies through the constructor and makes no internal Magento framework calls. The ViewModel architecture in Magento 2 (and especially in Hyva) is explicitly designed so that ViewModels have this property. Anyone who consistently favors ViewModels over block classes automatically builds a more testable codebase.

8. Summary

Magento unit tests without bootstrap are not a compromise, they are the right choice for every class that needs no framework infrastructure. ViewModels, service classes, simple plugins and DataObjects can be tested fully in isolation: fast, deterministic and without a database connection. The runtime of a well-structured unit test suite for a Magento module stays under 5 seconds, which makes it ideal for the CI pipeline on every commit.

The decision for or against unit tests is not a question of test quality, but of tool choice. Repository implementations, blocks and observer event handling belong in integration tests, not because unit tests would be harder, but because the infrastructure dependencies can only be incompletely replaced by mocks. A clear mental model of which classes get tested where is more efficient than trying to force everything into unit tests, or everything into slow integration tests.

Magento unit tests without bootstrap. The essentials at a glance

ViewModel = ideal for unit tests

No block infrastructure, pure logic, mockable dependencies. Data provider tests cover every input combination.

Plugins: simulate the proceed closure

Call the AroundPlugin directly, pass a closure as proceed. Check whether proceed was called and whether arguments were modified correctly.

Know the limits

Block (AbstractBlock), repository implementation and event observer need integration tests with bootstrap, that is the correct tool choice.

Suite runtime

Under 5 seconds with the right boundaries. Unit tests in CI on every commit, integration tests on dedicated runs or nightly.

9. FAQ: Magento Unit Tests Without Bootstrap

1Unit test vs. integration test in Magento?
Unit tests: no bootstrap, runtime in seconds. Integration tests: full Magento stack with DB, runtime in minutes. Unit tests for logic, integration tests for interaction.
2Why are ViewModels more testable than blocks?
No context inheritance, no need for a DI container. All dependencies via constructor, fully mockable in unit tests without bootstrap.
3Using DataObjects in unit tests?
Simple DataObject subclasses without internal ObjectManager access work fine. With DataObjectFactory internally: mock the factory or use an integration test.
4Testing an around plugin in unit tests?
Call aroundMethodName() directly with a mocked subject and a closure as $proceed. A flag variable checks whether proceed was called.
5Which classes need integration tests?
Repository implementations, block classes, observer behavior in the event context, setup patches. Anything that needs a DB or the DI container.
6Setting up PHPUnit for Magento unit tests?
Use dev/tests/unit/phpunit.xml or a custom configuration in the module. Autoloading via composer.json. No bootstrap needed.
7Mock Magento core classes or instantiate them for real?
Always mock interfaces. Simple DataObjects without internal framework calls can be instantiated for real. Always mock ProductInterface.
8Testing configuration values in unit tests?
Mock ScopeConfigInterface: method('getValue')->with('path/to/config')->willReturn('value'). Data provider for different configuration states.
9Fixtures for product data in unit tests?
In unit tests, supply product data via mocked repositories. DB fixtures are integration test tools. Mocks are more deterministic and faster.
10How often should unit tests run in CI?
On every commit and PR. Under 5 seconds runtime, no reason to restrict. Integration tests on separate runs or nightly.