Magento 2 Unit Tests with PHPUnit: A Practical Guide
Unit tests in Magento have a reputation for being painful. Classes with ten constructor arguments, abstract models that touch the database in the constructor, and a test suite that takes ages to run if you start it from the wrong place. Most of that pain comes from testing the wrong kind of class, or from code that was never designed to be tested.
This article shows how to write unit tests that are worth keeping in a Magento 2.4.8 project: where they live, how to run only yours, how to mock dependencies, and how to shape your own classes so that testing them is easy.
What a unit test is in Magento
A unit test exercises one class in isolation. It does not boot Magento, does not touch the database and does not read configuration files. Every dependency is replaced by a test double. Because of that, a unit test runs in milliseconds and fails for exactly one reason.
That makes unit tests a good fit for:
- Calculations: prices, fees, shipping thresholds, dates
- Mapping and transformation, for example building an ERP payload from an order
- Decision logic in plugins and observers
- Validators and parsers
They are a poor fit for repositories, collections, layout and anything whose value is in how it talks to the framework. Those belong in integration tests.
Folder layout and naming
Tests live inside the module, under Test/Unit, mirroring the class structure:
app/code/Vendor/Shipping/
Model/FreeShippingThreshold.php
Test/Unit/Model/FreeShippingThresholdTest.php
The test class name ends with Test, extends PHPUnit\Framework\TestCase, and its namespace follows the folder: Vendor\Shipping\Test\Unit\Model. The Magento coding standard and most tooling assume this layout, so stick to it.
A class worth testing
Here is a small service that tells the customer how much is left to reach free shipping. It reads a threshold from configuration and does some arithmetic.
<?php
declare(strict_types=1);
namespace Vendor\Shipping\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class FreeShippingThreshold
{
private const XML_PATH_THRESHOLD = 'vendor_shipping/general/free_threshold';
public function __construct(
private readonly ScopeConfigInterface $scopeConfig
) {
}
public function getRemaining(float $subtotal, ?int $storeId = null): float
{
$threshold = (float) $this->scopeConfig->getValue(
self::XML_PATH_THRESHOLD,
ScopeInterface::SCOPE_STORE,
$storeId
);
if ($threshold <= 0) {
return 0.0;
}
return max(0.0, round($threshold - $subtotal, 2));
}
}
It depends only on an interface, takes it through the constructor, and has no side effects. That is the shape that makes unit tests easy.
The test
<?php
declare(strict_types=1);
namespace Vendor\Shipping\Test\Unit\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Vendor\Shipping\Model\FreeShippingThreshold;
class FreeShippingThresholdTest extends TestCase
{
private ScopeConfigInterface&MockObject $scopeConfig;
private FreeShippingThreshold $model;
protected function setUp(): void
{
$this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
$this->model = new FreeShippingThreshold($this->scopeConfig);
}
/**
* @dataProvider remainingProvider
*/
public function testGetRemaining(?string $configValue, float $subtotal, float $expected): void
{
$this->scopeConfig->expects($this->once())
->method('getValue')
->with('vendor_shipping/general/free_threshold', ScopeInterface::SCOPE_STORE, 1)
->willReturn($configValue);
$this->assertSame($expected, $this->model->getRemaining($subtotal, 1));
}
public static function remainingProvider(): array
{
return [
'below threshold' => ['200', 150.0, 50.0],
'exactly threshold' => ['200', 200.0, 0.0],
'above threshold' => ['200', 250.0, 0.0],
'rounding' => ['99.90', 33.33, 66.57],
'not configured' => [null, 10.0, 0.0],
'zero threshold' => ['0', 10.0, 0.0],
];
}
}
A few things to notice:
- The class under test is created with
new. No object manager, no helper. - The mock is of the interface, not of a concrete config class.
- The data provider covers the edges: equal, above, rounding, missing configuration. Most bugs live there.
- The data provider is
static. Newer PHPUnit versions require that, and older versions accept it, so write it that way from the start. - Configuration values come back as strings or null from
ScopeConfigInterface, so the test returns strings, which is what happens in production.
PHPUnit 10 and later also support attributes such as #[DataProvider('remainingProvider')]. Check which PHPUnit version your Magento release ships in composer.lock before choosing between annotations and attributes.
Running only your tests
Magento ships a PHPUnit configuration at dev/tests/unit/phpunit.xml.dist. Pass it with -c and point PHPUnit at your code:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/Vendor/Shipping/Test/Unit
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --filter FreeShippingThresholdTest app/code/Vendor
Running the full core unit suite is rarely useful in a project. It is slow, and failures in core tests are not yours to fix. Many teams copy the dist file to dev/tests/unit/phpunit.xml and change the test suite directories to their own namespaces only. Run the tests inside the PHP container of your local environment so the PHP version matches production, as described in the Warden setup article.
Mocking Magento classes
Factories
Generated factories do not exist until Magento generates them, but PHPUnit can still mock them if the generated folder is present, or you can mock with getMockBuilder() and disableOriginalConstructor(). The cleaner option is to depend on an interface and its factory only where you really need new instances, and keep the logic that uses the created object in a separate, testable method.
Magic getters
Models that extend DataObject have methods like getCustomAttribute that exist, and methods like getSomeField that only exist through __call. To mock a magic method, use addMethods() on the mock builder in PHPUnit 9. In newer versions addMethods() is deprecated, which is one more reason to depend on interfaces with real getters, such as OrderInterface or ProductInterface, instead of the model classes.
Consecutive calls
withConsecutive() was removed in PHPUnit 10. If you need different return values for different arguments, use willReturnMap() or a willReturnCallback() that checks the arguments. Avoid it in new tests even if your current version still has it.
Designing for testability
The best way to make Magento code testable is to move logic out of framework classes. A few rules that work well:
- Keep plugins and observers thin. An
afterGetPriceplugin should call a service that does the calculation. Test the service. The plugin becomes two lines. - Depend on interfaces.
ScopeConfigInterface,ProductRepositoryInterfaceandLoggerInterfaceare easy to mock. Concrete resource models are not. - Avoid the object manager in your code. Calling
ObjectManager::getInstance()makes a class untestable without booting Magento. - Pass values, not containers. A method that takes a subtotal and a store ID is easier to test than one that takes the whole quote.
- No logic in constructors. Constructors should only assign dependencies. Magento's DI and proxies depend on that too, as explained in dependency injection and virtual types.
What not to test
Do not write unit tests that only check that a mock was called with the arguments you just configured. A test that mirrors the implementation line by line breaks on every refactor and catches nothing. Test behavior: given these inputs and these dependency answers, the result is this. Also skip trivial getters and setters and configuration XML. Static analysis and integration tests cover those better.
Keeping the suite healthy
Run unit tests on every pull request in CI, fail the build on any failure, and keep the suite fast. If a test is flaky, fix it or delete it, since a red build that everybody ignores is worse than no build. Coverage reports are useful to find untested business logic, but a coverage target on its own tends to produce tests for getters, not tests for the code that matters.
FAQ
Where do unit tests go in a Magento 2 module?
In Test/Unit inside the module, mirroring the class path. A test for Model/Calculator.php goes in Test/Unit/Model/CalculatorTest.php with the namespace Vendor\Module\Test\Unit\Model.
Do Magento unit tests need a database?
No. Unit tests run without a Magento installation. Everything the class depends on is replaced by mocks. If a test needs the database, it is an integration test.
Should I mock concrete classes or interfaces?
Prefer interfaces. Mocking an interface is stable and does not run any constructor logic. Mocking concrete Magento classes works but couples the test to implementation details that change between versions.
Is the Magento ObjectManager test helper still needed?
Rarely. With constructor property promotion and interfaces, you can create the class under test with new and pass mocks directly. The helper is mainly useful for old classes with long constructors.
I am available for freelance Magento and Adobe Commerce work, including adding tests to existing modules. If you need help with this, get in touch.
Related articles
Magento 2 Integration Tests: Setup, Fixtures and Tips
Set up and run Magento 2 integration tests: the test database, install-config-mysql.php, DB isolation, config and data fixtures, and keeping the suite fast.
Common Magento 2 Errors and How to Fix Them
Fixes for classic Magento 2 errors: Area code is not set, Class does not exist, Invalid Form Key, stuck indexers, Unable to unserialize value, static 404s.
Magento 2 GraphQL: Add a Custom Query and Resolver
How to add a custom GraphQL query in Magento 2.4: schema.graphqls, a resolver with ResolverInterface, errors, auth checks and cache identity for Varnish.
PHPStan for Magento 2: Static Analysis Setup Guide
Run PHPStan on Magento 2 custom code: the phpstan-magento extension for generated classes, choosing a level, baselines for legacy code and CI integration.