Magento 2: Testing Repositories, Services and ViewModels with PHPUnit
AI generated
@test
assert
PHPUnit · Magento 2 · Repository · ViewModel · Service Contract
Testing Repositories, Services and ViewModels
Unit tests for the core layers of Magento 2

Anyone writing Magento code without tests learns during the first refactoring how fragile the dependencies really are. PHPUnit tests for repositories, service contracts and ViewModels cover the logic of the most important layers, without needing the full Magento bootstrap.

15 min read Repository · Service Contract · ViewModel · Mocking · PHP 8.4 Magento 2.4 · PHPUnit 10 · Constructor Property Promotion

1. Why test repositories, services and ViewModels?

Magento 2 follows a clear layered model: repositories encapsulate data access, service contracts define the public API of a module, ViewModels prepare data for templates. Covering these three layers with PHPUnit secures the entire business logic without relying on the full Magento bootstrap. The result is fast, deterministic tests that can run in any CI pipeline without a special database connection.

The decisive advantage over manual tests or browser tests lies in repeatability. A change to a repository filter immediately shows whether the expected behavior is preserved as soon as the test runs. Especially in Magento projects with a growing module stack, where many dependencies interlock, this kind of regression protection is indispensable. The test does not cover the database interaction, that is the job of integration tests, but the logic that the repository builds from the parameters it receives.

In PHP 8.4 with constructor property promotion and strict types, the code is structured more precisely anyway. PHPUnit 10 uses attributes instead of annotations, which makes the tests themselves more readable. Both developments together make testing repositories, services and ViewModels in modern Magento projects easier than it was in Magento 2.3.

2. Test setup without the Magento bootstrap

The first step for unit tests in Magento 2 is understanding that dev/tests/unit/ ships its own phpunit.xml, which does not start a full Magento bootstrap. Instead, only the relevant classes of the module under test are loaded, while all dependencies are passed in as mocks. The test directory in the custom module lives under app/code/Vendor/Module/Test/Unit/ and follows the module's PSR-4 namespacing convention.

The phpunit.xml in the project root references the bootstrap dev/tests/unit/framework/bootstrap.php, which pulls in Magento's autoloader without initializing the whole application. That means no database connection, no session handling, no event system, just plain PHP with the module's classes and the mock objects PHPUnit provides. As a result, a typical unit test suite with a hundred tests runs in under three seconds.

<?php
// File: app/code/Mironsoft/Catalog/Test/Unit/Model/ProductServiceTest.php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Model;

use Mironsoft\Catalog\Api\Data\ProductDtoInterface;
use Mironsoft\Catalog\Model\ProductService;
use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(ProductService::class)]
class ProductServiceTest extends TestCase
{
    private ProductRepositoryInterface&MockObject $repositoryMock;
    private ProductService $service;

    protected function setUp(): void
    {
        $this->repositoryMock = $this->createMock(ProductRepositoryInterface::class);
        $this->service = new ProductService(
            repository: $this->repositoryMock,
        );
    }

    #[Test]
    public function getActiveReturnsOnlyEnabledProducts(): void
    {
        $dto = $this->createMock(ProductDtoInterface::class);
        $dto->method('isEnabled')->willReturn(true);

        $this->repositoryMock
            ->expects($this->once())
            ->method('getList')
            ->willReturn([$dto]);

        $result = $this->service->getActive();

        $this->assertCount(1, $result);
        $this->assertTrue($result[0]->isEnabled());
    }
}

3. Testing repositories with PHPUnit

A Magento repository implements an interface from the service contract layer, for example ProductRepositoryInterface. In the unit test the goal is not to verify the database query, that is the domain of integration tests. Instead, you test whether the repository calls the right methods on the ResourceModel, whether exceptions are transformed correctly, and whether the filter logic maps the given search criteria correctly into a SearchCriteria object.

Mocking the SearchCriteriaBuilder is particularly important here, because this builder works via a fluent interface and every method returns $this. PHPUnit 10 supports chaining mocks via willReturnSelf(), which covers exactly this pattern. This lets you test whether addFilter('status', 1) is called exactly once before create() delivers the final object.

4. Checking service contracts in isolation

Service contracts in Magento 2 are interfaces in the Api/ directory that define the public API of the module. A concrete implementation of these interfaces contains the actual business logic and is the primary candidate for unit tests. A typical service coordinates several repositories, validates input data, and throws exceptions declared in the interface.

In the unit test you mock all repositories and check the service's behavior for different inputs: valid data, empty result, exception from repository. The focus is on the branches in the logic, what happens when the repository throws NoSuchEntityException? Is it forwarded correctly or transformed into a module-specific exception? These case distinctions are exactly what PHPUnit covers best.

<?php
// File: app/code/Mironsoft/Catalog/Test/Unit/Model/ProductRepositoryTest.php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Model;

use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Catalog\Model\ProductRepository;
use Mironsoft\Catalog\Model\ResourceModel\Product as ProductResource;
use Mironsoft\Catalog\Model\ProductFactory;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

class ProductRepositoryTest extends TestCase
{
    private ProductResource $resourceMock;
    private ProductFactory $factoryMock;
    private SearchCriteriaBuilder $criteriaBuilderMock;
    private ProductRepository $repository;

    protected function setUp(): void
    {
        $this->resourceMock     = $this->createMock(ProductResource::class);
        $this->factoryMock      = $this->createMock(ProductFactory::class);
        $this->criteriaBuilderMock = $this->createMock(SearchCriteriaBuilder::class);

        $this->repository = new ProductRepository(
            resource: $this->resourceMock,
            productFactory: $this->factoryMock,
            criteriaBuilder: $this->criteriaBuilderMock,
        );
    }

    #[Test]
    public function getByIdThrowsNoSuchEntityExceptionForMissingProduct(): void
    {
        $product = $this->createMock(\Mironsoft\Catalog\Model\Product::class);
        $product->method('getId')->willReturn(null);
        $this->factoryMock->method('create')->willReturn($product);
        $this->resourceMock->method('load')->willReturnArgument(0);

        $this->expectException(NoSuchEntityException::class);
        $this->repository->getById(9999);
    }

    #[Test]
    #[DataProvider('validIdProvider')]
    public function getByIdReturnsProductForValidId(int $id): void
    {
        $product = $this->createMock(\Mironsoft\Catalog\Model\Product::class);
        $product->method('getId')->willReturn($id);
        $this->factoryMock->method('create')->willReturn($product);
        $this->resourceMock->method('load')->willReturnArgument(0);

        $result = $this->repository->getById($id);
        $this->assertSame($id, $result->getId());
    }

    public static function validIdProvider(): array
    {
        return [[1], [42], [100]];
    }
}

5. ViewModels: logic without block overhead

ViewModels in Magento 2 implement ArgumentInterface and contain the logic that used to live in block classes. They are particularly well suited for unit tests because they do not inherit a block hierarchy and have no layout dependencies. A ViewModel gets its dependencies injected via the constructor and outputs data for the template without rendering HTML itself.

In the unit test you instantiate the ViewModel directly with mocked dependencies and check whether the public methods return the expected values. A ViewModel that formats prices calls a PriceCurrencyInterface mock and returns the result unchanged, the test checks exactly this behavior without loading the full Magento pricing stack.

6. Mocking strategy for Magento dependencies

Magento classes are often hard to instantiate because they have deeply nested dependencies or are final classes. PHPUnit 10 offers createMock() for interfaces and non-final classes. For final classes you either need the Mockery library, or you write your own test double by implementing the relevant interface. In the Magento context, the second path is often cleaner, because most classes implement an interface.

A common mistake is mocking classes that do not implement any interfaces, such as AbstractModel subclasses. Here it is better to define your own model interface and test against that. The rule is: in the unit test always mock against interfaces, never against concrete classes. That is not only better for testability, it also forces a clean separation between interface and implementation.

<?php
// File: app/code/Mironsoft/Catalog/Test/Unit/ViewModel/ProductPriceViewModelTest.php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\ViewModel;

use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\ViewModel\ProductPriceViewModel;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;

class ProductPriceViewModelTest extends TestCase
{
    private PriceCurrencyInterface $currencyMock;
    private ProductRepositoryInterface $productRepoMock;
    private ProductPriceViewModel $viewModel;

    protected function setUp(): void
    {
        $this->currencyMock    = $this->createMock(PriceCurrencyInterface::class);
        $this->productRepoMock = $this->createMock(ProductRepositoryInterface::class);

        $this->viewModel = new ProductPriceViewModel(
            priceCurrency: $this->currencyMock,
            productRepository: $this->productRepoMock,
        );
    }

    #[Test]
    public function getFormattedPriceReturnsCurrencyFormattedString(): void
    {
        $product = $this->createMock(ProductInterface::class);
        $product->method('getFinalPrice')->willReturn(19.99);

        $this->currencyMock
            ->expects($this->once())
            ->method('format')
            ->with(19.99)
            ->willReturn('19,99 €');

        $result = $this->viewModel->getFormattedPrice($product);
        $this->assertSame('19,99 €', $result);
    }

    #[Test]
    public function getFormattedPriceReturnsEmptyStringForZeroPrice(): void
    {
        $product = $this->createMock(ProductInterface::class);
        $product->method('getFinalPrice')->willReturn(0.0);

        $this->currencyMock->method('format')->willReturn('0,00 €');

        $result = $this->viewModel->getFormattedPrice($product);
        $this->assertSame('0,00 €', $result);
    }
}

7. Constructor property promotion and dependency injection

PHP 8.4 with constructor property promotion makes the code of ViewModels and services considerably more compact. Instead of a separate property declaration and constructor assignment, you write public readonly PriceCurrencyInterface $priceCurrency directly in the constructor parameter. That also changes the test setup slightly: the mocked objects are passed directly in setUp() as constructor arguments, named arguments making explicit which parameter receives which mock.

The pattern with readonly properties prevents dependencies from being overwritten after construction, which leads to cleaner test setups in a testing context. A ViewModel with three readonly dependencies needs exactly three mocks in setUp(). Every method of the ViewModel that calls one of these dependencies is directly testable without further configuration. That is significantly clearer than the old approach with $this->objectManager->create() in the test.

8. Common pitfalls and how to avoid them

The most common pitfall when testing Magento repositories is assuming that mocking the ResourceModel is enough. In reality, many repositories have further dependencies like CollectionFactory or SearchResultsFactory, which also need to be mocked. If you forget these, running the test does not give you a clear error message, but a TypeError or a Fatal error due to missing mock configuration.

A second pitfall is Magento-specific classes such as Phrase (for translations) or DataObject. These classes have no complex dependencies and can be instantiated directly, they do not need to be mocked. That significantly reduces mock overhead. A third pitfall: anyone using createConfiguredMock() instead of createMock() has to specify all method return values in an array, which quickly becomes confusing for interfaces with many methods. It is better to configure individual method()->willReturn() chains as needed.

9. Comparison: what unit tests can and cannot do

Unit tests for repositories, services and ViewModels are not a replacement for integration tests. They check the logic of the class in isolation, not its interplay with the database, cache or event system. A repository unit test cannot ensure that the correct SQL is generated, that requires a database test. What it does ensure is that the right methods are called in the right order and that exceptions are handled correctly.

Aspect Unit test (PHPUnit) Integration test Recommendation
Business logic Fully coverable Possible, but slower Prefer unit test
Database queries Not testable Fully testable Integration test required
Exception handling Ideally testable Cumbersome Prefer unit test
Event dispatching Only call verifiable Observer effects verifiable Depends on requirement
Execution speed Milliseconds Seconds to minutes Unit test for CI feedback

The rule of thumb is: everything that can be isolated with mocks and requires no external systems belongs in the unit test. Everything concerning the real database schema, the Magento DI container, or the event system is the job of the integration test. This clear separation ensures a fast feedback loop during development and reliable regression protection in CI.

Mironsoft

Magento 2 development, PHPUnit test strategy and code quality

Magento code that can be refactored safely?

We implement unit test suites for existing Magento modules, repositories, services and ViewModels, and integrate them into the CI pipeline so every change is checked immediately.

Test audit

Analysis of existing Magento modules for unit test gaps and critical paths without coverage

Test implementation

PHPUnit tests for repositories, service contracts and ViewModels following modern PHP 8.4 standards

CI integration

Integrating the test suite into GitHub Actions or GitLab CI with coverage report and quality thresholds

10. Summary

PHPUnit tests for repositories, services and ViewModels in Magento 2 cover the most important layer of business logic without needing the full Magento bootstrap. The result is fast, deterministic tests that run in under a second per class and work in any CI pipeline without a database connection. Constructor property promotion in PHP 8.4 makes the test setup shorter and clearer, three mocks in setUp(), named arguments in the constructor, done.

The clear separation between unit test (logic in isolation) and integration test (database interaction, event system) is the decisive factor for a maintainable test strategy. Anyone who has unit tests for all public methods of repositories, services and ViewModels can refactor with confidence, swap dependencies and implement new features, without the fear of breaking existing functionality.

Testing repositories, services and ViewModels: the essentials at a glance

Test structure

app/code/Vendor/Module/Test/Unit/ with its own bootstrap, no database access, only the PHP autoloader and PHPUnit mocks.

Mocking rule

Always mock against interfaces, not against concrete classes. createMock(InterfaceName::class) is the standard approach in PHPUnit 10.

PHP 8.4 advantage

Constructor property promotion + named arguments = a compact setUp() without redundancy. readonly properties prevent accidental overwriting.

Boundary

Unit test: logic, exception handling, method calls. Integration test: SQL, event system, DI container. Do not mix them.

11. FAQ: Testing repositories, services and ViewModels with PHPUnit

1Do I need Magento installed for unit tests?
No. The bootstrap in dev/tests/unit/ only starts the autoloader, no database connection. All dependencies are passed in as PHPUnit mocks.
2Which files do I need in the test directory?
Test classes under app/code/Vendor/Module/Test/Unit/, PSR-4 namespacing. The Magento-wide phpunit.xml under dev/tests/unit/ can be used directly.
3Mock SearchCriteriaBuilder in the test?
willReturnSelf() for fluent interface methods. create() returns a mocked SearchCriteriaInterface. This checks whether filters are set correctly.
4Why test ViewModels instead of block classes?
ViewModels do not inherit a block hierarchy, have no layout dependencies, and can be instantiated directly. Significantly easier to test than AbstractBlock subclasses.
5Mock final Magento classes?
Use Mockery, or write your own code against an interface and mock that. Interface-first is the cleaner approach.
6Annotations or attributes in PHPUnit 10?
Attributes (#[Test], #[DataProvider]) are recommended. Annotations like @test are deprecated as of PHPUnit 10. IDE support is better with attributes.
7Test exception handling in a service?
Configure the repository mock with willThrowException(), then use expectException() to check whether the service reacts correctly, forwarding or transforming.
8One test class per ViewModel?
Yes. Every production class gets its own test class in the same namespace path. Makes navigation, coverage reporting, and error assignment easier.
9Use DataProvider for repository tests?
Mark a static method with #[DataProvider('name')]. Returns an array of arrays. Cover multiple inputs with a single test method.
10Overwrite readonly properties in a test?
No. readonly properties cannot be changed after construction. All dependencies must be configured correctly in setUp(), which forces clean test setup.