scenarios realistically in PHPUnit tests
A store-scope bug often only shows up in production because tests only ever exercise the default store. Mocking ScopeConfigInterface deliberately covers store and website differences without maintaining real database fixtures for every store.
Table of Contents
- 1. Why multi-store bugs slip past tests so often
- 2. Deliberately mocking ScopeConfigInterface for multiple stores
- 3. Simulating StoreManagerInterface for multiple stores and websites
- 4. Why mocks are preferable here to real DB fixtures
- 5. Correctly testing the default store as a fallback
- 6. Testing cross-website logic: shared catalogs and customer accounts
- 7. Using a data provider for multiple store combinations
- 8. When a real multi-store integration test is still needed
- 9. Fitting multi-store tests into your CI strategy
- 10. Summary
- 11. FAQ
1. Why multi-store bugs slip past tests so often
Magento shops with multiple stores or websites share code, but not necessarily configuration. Tax classes, shipping methods, price rules, display text: almost any setting can be overridden per store, per store view, or per website. Anyone who tests a module only against the default store is checking exactly one of many possible configuration states and systematically misses everything that differs in a second store.
The tricky part is that the code works perfectly in the test, because ScopeConfigInterface::getValue() automatically reads the current default scope when no explicit scope parameter is passed. Only in production, when a customer orders through the second website with a different currency or a different tax rate, does it become clear that the business logic never forwarded the store parameter at all. A test that deliberately mocks store-specific values catches exactly this gap before it goes live.
2. Deliberately mocking ScopeConfigInterface for multiple stores
The core of any multi-store test situation is a mock of ScopeConfigInterface that returns different values depending on the store code passed in. Instead of a single willReturn(), you use willReturnCallback() or willReturnMap() to define a separate configuration value for each store code. This lets a single unit test simulate that store 'de' has a different minimum order value than store 'at', without ever hitting a real database.
It is important that the class under test consistently forwards the store code as the scope parameter, instead of relying on the implicit current scope. That is exactly what the following test makes visible: it calls the business logic once with store 'de' and once with store 'at' and expects different results. If the class does not correctly forward the scope, the mock returns the same value in both cases and the test fails.
<?php
declare(strict_types=1);
namespace Mironsoft\Shipping\Test\Unit\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
use Mironsoft\Shipping\Model\MinimumOrderResolver;
use PHPUnit\Framework\TestCase;
class MinimumOrderResolverTest extends TestCase
{
private ScopeConfigInterface $scopeConfigMock;
private MinimumOrderResolver $resolver;
protected function setUp(): void
{
$this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class);
$this->resolver = new MinimumOrderResolver($this->scopeConfigMock);
}
public function testMinimumOrderValueDiffersPerStore(): void
{
$this->scopeConfigMock
->method('getValue')
->with('mironsoft_shipping/general/minimum_order', ScopeInterface::SCOPE_STORE, $this->anything())
->willReturnMap([
['mironsoft_shipping/general/minimum_order', ScopeInterface::SCOPE_STORE, 'de', '50.00'],
['mironsoft_shipping/general/minimum_order', ScopeInterface::SCOPE_STORE, 'at', '80.00'],
]);
self::assertSame(50.0, $this->resolver->getMinimumOrderValue('de'));
self::assertSame(80.0, $this->resolver->getMinimumOrderValue('at'));
}
}
3. Simulating StoreManagerInterface for multiple stores and websites
Besides the configuration itself, a realistic multi-store test often also needs a mock of StoreManagerInterface that returns several store objects with different IDs, codes, and website assignments. This matters especially when code determines the current store dynamically instead of receiving it as a parameter, for example in a plugin or observer that accesses getStore().
In practice it pays off to write a small test factory method for this that creates store mocks with the desired properties. That lets each test define exactly which stores belong to which website, without repeating the setup by hand every time. This factory method quickly becomes a reusable building block for every test that checks store- or website-dependent behavior.
<?php
declare(strict_types=1);
namespace Mironsoft\Shipping\Test\Unit\Model;
use Magento\Store\Api\Data\StoreInterface;
use Magento\Store\Model\StoreManagerInterface;
use PHPUnit\Framework\TestCase;
trait StoreMockFactoryTrait
{
private function createStoreMock(int $id, string $code, int $websiteId): StoreInterface
{
$storeMock = $this->createMock(StoreInterface::class);
$storeMock->method('getId')->willReturn($id);
$storeMock->method('getCode')->willReturn($code);
$storeMock->method('getWebsiteId')->willReturn($websiteId);
return $storeMock;
}
private function createStoreManagerMock(array $stores, int $currentStoreId): StoreManagerInterface
{
$storeManagerMock = $this->createMock(StoreManagerInterface::class);
$storeManagerMock->method('getStores')->willReturn($stores);
$storeManagerMock->method('getStore')->willReturnCallback(
static fn (?int $storeId = null) => $stores[$storeId ?? $currentStoreId]
);
return $storeManagerMock;
}
}
4. Why mocks are preferable here to real DB fixtures
The obvious instinct for multi-store tests is to actually create several stores and websites via a fixture script in an integration test. That works, but it is expensive: every additional store means extra database setup time, extra cleanup after the test, and tighter coupling to the concrete state of the test database. Across ten or twenty unit tests that all check store-dependent behavior, that quickly adds up to a noticeably slower test suite.
A mocked ScopeConfigInterface and a mocked StoreManagerInterface deliver the same test assertion in milliseconds instead of seconds, because no database is involved. For pure business logic that reacts to configuration values, that is entirely sufficient. Real multi-store fixtures remain useful for a small number of targeted integration tests that actually check whether the store configuration is correctly stored in the database, not for the bulk of unit tests.
5. Correctly testing the default store as a fallback
A common bug in multi-store setups is faulty fallback logic: a store view is supposed to inherit from the default value, but the code mistakenly checks only the specific store scope and ignores that Magento automatically falls back to website and then default scope when a store value is missing. A test should therefore explicitly cover the case where no value is set for a store and the website or default value must take effect instead.
To do that, you configure the ScopeConfigInterface mock to return null for the specific store scope and only supply a value at website or default scope level. If the tested class does not forward the fallback itself but wrongly assumes a store value always exists, exactly this test catches the gap before it leads to empty or wrong values in production.
6. Testing cross-website logic: shared catalogs and customer accounts
Websites in Magento can share catalogs or customer accounts, depending on the 'Shared Catalog' and 'Shared Customer Accounts' configuration. Business logic that assumes a customer belongs to only one website can react incorrectly in a setup with shared customer accounts. A test should therefore explicitly model the case where a customer account is valid across multiple websites.
To do that, you mock the corresponding configuration so that 'Shared Customer Accounts' is active and check whether the business logic then correctly acts across websites, for example when merging order history or checking discount eligibility. Without this test, an implicit assumption in the code remains undiscovered until a customer who shops across two websites runs into it.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Test\Unit\Model;
use Magento\Customer\Api\Data\GroupInterface;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Mironsoft\Loyalty\Model\CrossWebsiteEligibilityChecker;
use PHPUnit\Framework\TestCase;
class CrossWebsiteEligibilityCheckerTest extends TestCase
{
public function testEligibleWhenSharedCustomerAccountsEnabled(): void
{
$scopeConfigMock = $this->createMock(ScopeConfigInterface::class);
$scopeConfigMock->method('isSetFlag')
->with('customer/account_share/scope')
->willReturn(true);
$checker = new CrossWebsiteEligibilityChecker($scopeConfigMock);
self::assertTrue($checker->isEligibleAcrossWebsites());
}
public function testNotEligibleWhenAccountsAreWebsiteScoped(): void
{
$scopeConfigMock = $this->createMock(ScopeConfigInterface::class);
$scopeConfigMock->method('isSetFlag')
->with('customer/account_share/scope')
->willReturn(false);
$checker = new CrossWebsiteEligibilityChecker($scopeConfigMock);
self::assertFalse($checker->isEligibleAcrossWebsites());
}
}
7. Using a data provider for multiple store combinations
Instead of writing a separate test method name for every store, like testMinimumOrderForStoreDe() and testMinimumOrderForStoreAt(), a data provider bundles all store combinations into a single parameterized test method. That significantly reduces duplication and makes it easy to add another store simply as an extra row in the data provider, instead of writing a whole new test method.
This becomes especially valuable for modules with many stores, for example in internationally oriented shops with ten or more store views. A data provider with store code, expected configuration value, and expected result gives an at-a-glance overview of the expected behavior per store, and turns the test file itself into a kind of living documentation of the store differences.
<?php
declare(strict_types=1);
namespace Mironsoft\Shipping\Test\Unit\Model;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class FreeShippingThresholdTest extends TestCase
{
#[DataProvider('storeThresholdProvider')]
public function testThresholdPerStore(string $storeCode, string $configValue, float $expected): void
{
$scopeConfigMock = $this->createMock(\Magento\Framework\App\Config\ScopeConfigInterface::class);
$scopeConfigMock->method('getValue')->willReturn($configValue);
$resolver = new \Mironsoft\Shipping\Model\FreeShippingThreshold($scopeConfigMock);
self::assertSame($expected, $resolver->getThreshold($storeCode));
}
public static function storeThresholdProvider(): array
{
return [
'default store de' => ['de', '75.00', 75.0],
'austria store at' => ['at', '90.00', 90.0],
'switzerland store ch' => ['ch', '120.00', 120.0],
];
}
}
8. When a real multi-store integration test is still needed
Despite all the benefits of mocking, there are cases where a real integration test with actually created stores remains indispensable: whenever you need to check whether Magento itself resolves scope correctly, for example with complex inheritance chains between website and store view configuration, or with indexer logic that actually reads store-specific data from the database.
The pragmatic approach is a clear division of labor: unit tests with mocked configuration cover the business logic itself and run in milliseconds. A single, deliberately lean integration test with a @magentoConfigFixture annotation for the second store additionally confirms that the configuration actually arrives in the system the way the unit tests assume. Both levels complement each other but do not replace one another.
<?php
declare(strict_types=1);
namespace Mironsoft\Shipping\Test\Integration\Model;
use Magento\TestFramework\Helper\Bootstrap;
use Mironsoft\Shipping\Model\MinimumOrderResolver;
use PHPUnit\Framework\TestCase;
/**
* @magentoConfigFixture default_store mironsoft_shipping/general/minimum_order 50.00
* @magentoConfigFixture fixturestore_store mironsoft_shipping/general/minimum_order 80.00
*/
class MinimumOrderResolverIntegrationTest extends TestCase
{
public function testResolverReadsRealStoreConfig(): void
{
$objectManager = Bootstrap::getObjectManager();
/** @var MinimumOrderResolver $resolver */
$resolver = $objectManager->create(MinimumOrderResolver::class);
self::assertSame(80.0, $resolver->getMinimumOrderValue('fixturestore_store'));
}
}
9. Fitting multi-store tests into your CI strategy
In a CI pipeline, multi-store unit tests should run just as fast as any other unit test, since they only use mocked dependencies. They therefore belong in the same fast test phase that runs on every commit. The few real multi-store integration tests with actual fixtures, on the other hand, belong in a separate, slower phase, for example one that only runs before a merge into the main branch.
This separation prevents developers from being tempted to skip multi-store tests entirely to save time. A team that knows the fast unit tests already cover store-specific behavior well can deliberately stay frugal with the number of expensive integration tests, without risking blind spots in store coverage.
| Scenario | Test type | Effort | When it makes sense |
|---|---|---|---|
| Store-specific configuration value | Unit test with mocked ScopeConfigInterface | Very low | Always, as the default case |
| Fallback to website or default scope | Unit test with mocked ScopeConfigInterface (null return) | Low | For every fallback logic |
| Cross-website customer accounts | Unit test with mocked isSetFlag | Low | For shared customer account logic |
| Multiple stores at once | Data provider with store combinations | Low | From three or more stores |
| Real scope resolution by Magento | Integration test with @magentoConfigFixture | High | Only for a few targeted cases |
Mironsoft
Test automation, Magento quality assurance, and CI integration
Tests that catch real bugs instead of just turning green?
We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.
Test Audit
Reviewing existing suites for mocking antipatterns and blind spots.
Test Strategy
Meaningfully combining unit, integration, and MFTF tests for Magento projects.
CI Integration
Setting up fast, reliable test runs in GitLab CI or GitHub Actions.
10. Summary
Multi-Store Testing: Key Takeaways
Core idea
Mock ScopeConfigInterface and StoreManagerInterface deliberately instead of building real multi-store fixtures.
Tool
willReturnMap() or willReturnCallback() for store-dependent return values.
Common mistake
Fallback to website or default scope is never tested in code and fails in production.
Limit of mocking
Real scope resolution by Magento itself still needs a lean integration test.