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.
Table of Contents
- 1. The bootstrap problem in Magento tests
- 2. What is meaningfully testable without bootstrap
- 3. Testing ViewModels: data transformation without layout
- 4. Testing plugins: verifying argument manipulation in isolation
- 5. Service classes and pricing logic
- 6. Where unit tests reach their limits
- 7. Unit vs. integration: a decision matrix
- 8. Summary
- 9. FAQ
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.