Modeling Multi-Store and Multi-Website Scenarios in Magento Tests
AI generated
@test
assert
PHPUnit · Magento · Multi-Store
Modeling Multi-Store and Multi-Website
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.

15 min read ScopeConfigInterface StoreManagerInterface Website scope

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.

11. FAQ: Multi-Store Testing: Key Takeaways

1Do I need a real record in the test database for every store?
No, for most cases a mocked ScopeConfigInterface that returns different values depending on store code is enough. Real store fixtures are only worthwhile for a few targeted integration tests.
2How do I mock different configuration values for multiple stores in PHPUnit?
With willReturnMap() on the ScopeConfigInterface mock, where each row consists of configuration path, scope type, scope code, and return value. willReturnCallback() also works well for more complex logic.
3What is the most common mistake in multi-store tests?
The class under test implicitly relies on the current default scope instead of forwarding the given store code to ScopeConfigInterface. The test then passes despite a real bug, because it never simulates a second store.
4How do I test the fallback from store to website or default scope?
The ScopeConfigInterface mock returns null for the specific store scope and only supplies a value at website or default level. That shows whether the class handles the fallback correctly itself or wrongly expects a value to always exist.
5Do I also need StoreManagerInterface for multi-website tests?
Yes, as soon as code determines the current store dynamically instead of receiving it as a parameter. A mocked StoreManagerInterface with several store objects and website assignments covers that.
6How do I test shared customer account logic between websites?
You mock isSetFlag() for the configuration path customer/account_share/scope and check both cases: enabled and disabled. That shows whether the business logic correctly reacts across websites.
7Is a data provider worthwhile for store tests?
Yes, especially from three or more stores. A data provider bundles all store combinations into one method and turns the test file into an overview of the expected store differences at the same time.
8When is a real multi-store integration test still needed?
When you need to check whether Magento itself resolves scope correctly, for example with complex inheritance chains or indexer logic that really reads store-specific data from the database.
9How much faster are multi-store unit tests with mocks compared to real fixtures?
Significantly faster, since no database is involved. A mocked test typically runs in milliseconds, while an integration test with real store fixtures takes noticeably longer due to setup and cleanup.
10How do I handle @magentoConfigFixture for non-default stores?
The annotation lets you set a configuration value for a specific store code directly, for example @magentoConfigFixture fixturestore_store path value. That lets you model a second store deliberately in an integration test.