---
title: PHPUnit Foundations | Shopware Community Hub
description: >-
  Learn how to configure PHPUnit and write fast, isolated, and maintainable unit
  tests for Shopware plugins.
canonical_url: 'https://hub.shopware.com/learn/unit/phpunit-foundations'
---

# PHPUnit Foundations

<LearningObjectives>

- Understand PHPUnit configuration and test discovery in Shopware projects.
- Apply clear naming, structure, and isolation patterns for unit tests.
- Decide when to mock dependencies and when to use real objects.
- Use test doubles, dummy objects, and data providers effectively to keep tests readable and maintainable.
  
</LearningObjectives>

# PHPUnit Foundations

Building on the basics, you'll bootstrap PHPUnit for a Shopware plugin and learn how to structure fast unit tests that provide rapid feedback. We'll focus on small, deterministic tests that validate core logic without hitting the database or network.

<Callout title="Reminder Prerequisite" type="info">

This learning unit builds on PHPUnit basics already covered in the essentials course: [Best practices for running PHPUnit tests](/learn/unit/phpunit-test).

</Callout>

## Benefits of Writing Unit & Integration Tests

**Early bug detection:**

Testing individual units of code early in development helps developers identify and resolve defects before they escalate into larger system issues.

This approach is not strict TDD (test-driven development), where tests are written before the code itself.

Instead, it encourages critically evaluating the logic within a class or helper to catch defects early. You can think of it as a mindset of "hacking your own code."

**Enables confidence:**

A comprehensive unit test suite allows developers to make changes (primarily bug fixes) with confidence because many regressions are caught early.

This is less true for major refactorings: if contracts change significantly, existing tests may need to be rewritten.

**Improved code quality:**

Writing unit tests encourages more modular code. When a tested object relies on many dependencies, uses unwrapped external libraries, or contains excessive functionality, unit testing can reveal code smells and highlight areas for potential refactoring.

**Simplifies debugging:**

When a unit test fails, it pinpoints the specific code segment with the error, making it quicker and easier to identify and fix the root cause.

This is especially useful when dependencies or libraries change their interfaces. Such breaking changes are often harder to isolate in integration tests where dependencies are not mocked and failures can be less specific.

**Acts as documentation:**

Unit tests function as living documentation, demonstrating how code is intended to be used and what its expected behavior is. This is valuable for other developers or for your future reference.

Integration tests are even closer to real-world scenarios, validating that different components work together as expected against a state.

## What Tests Are Not For

**Performance testing:**

Unit and integration tests are **not designed to measure performance or scalability**. Performance testing requires specialized tools and methodologies.

Tests may give some hints when a method takes unexpectedly long to execute or consumes excessive memory.

However, benchmarking performance is **not** the purpose of unit or integration tests.

**Done for the sake of it:**

Writing tests just to increase coverage metrics without focusing on meaningful scenarios can lead to a false sense of security.

Tests should always be **purpose-driven** and validate critical or error-prone functionality.

For example, when a method is trivial (such as a simple getter or setter), writing a test is often necessary and offers little-added value.

## PHPUnit Configuration and Test Discovery

### PHPUnit Configuration

For a Shopware plugin, always refer to the [official Shopware's dev docs](https://developer.shopware.com/docs/guides/plugins/plugins/testing/php-unit.html) matching your Shopware version, as PHPUnit configuration may differ depending on the major version in use.

## Learn Naming, Structure, and Isolation Patterns for Unit Tests

### Naming a Test Method

A test method should clearly describe **what behavior is being evaluated**, rather than just concatenating ‘test’ with the method name of the class under test.

The goal of a test is not to enforce the structure of an object, but to **validate its behavior**.

**Example:**

```php
public function testCalculateTotalPriceWithDiscountApplied(): void
{
    $calculator = new PriceCalculator();
    $total = $calculator->calculateTotalPrice(100, 0.1);
    $this->assertEquals(90, $total);
}
```

This descriptive name makes it clear that the test verifies the total price calculation **when a discount is applied**.

It is more informative than a generic name like `testCalculateTotalPrice`, which could cover multiple scenarios.

```php
public function testCalculateTotalPrice(): void
{
    ...
}
```

**Deviations:**

When multiple scenarios are grouped using a data provider, this rule applies less strictly.

In such cases, the individual test cases are made explicit by the data provider entries themselves.

```php
#[DataProvider('calculateTotalPriceProvider')]
public function testCalculateTotalPrice(int $price, ?float $discount, int $expected): void
{
    $calculator = new PriceCalculator();
    $total = $calculator->calculateTotalPrice($price, $discount);
    $this->assertSame($expected, $total);
}

public static function calculateTotalPriceProvider(): \Generator
{
    yield  'without discount' => [100, null, 100];
    yield  'with 10% discount' => [100, 0.1, 90];
    yield  'with 20% discount' => [100, 0.2, 80];
}
```

### Tests Structure

As a general guideline, test files should mirror the structure and naming of the `src` directory inside the `tests` directory whenever possible.

Fixtures (dummy file, sample data, or test assets) should be placed in a subfolder named `fixtures` inside the same folder as the test suite.

**Example:**

```txt
[shop_root]
└── custom
    └── plugins
        └── [your_plugin]
             └── src
                 ├── Controllers
                 │── Services
                 │   └── Order
                 │       └── OrderProcessor.php
                 tests
                 ├── Controllers
                 └── Services
                     └── Order
                         │── fixtures
                         │   └── SampleOrderData.php
                         └── OrderProcessorTest.php
```

**Deviations:**

When a test suite file grows beyond **~2,000 lines** or contains many unrelated test methods, this often indicates that the underlying class has taken on too many functionalities.

In such cases, it is recommended to split the test suite file by **subdomain or responsibility** to keep tests readable and maintainable.

Such large test files negatively impact:

- Developer readability
- Navigation and reviewability
- Static analysis performance of PHPStan
- Overall cognitive complexity

**Example**

Imagine a class located in `src/Service/OrderProcessor.php` that handle multiple things such as payment processing, shipping logic, and notification handling.

Without splitting, the test file would be located in `tests/Service/OrderProcessorTest.php` and would have 40+ test methods (not counting cases by any data providers).

A more maintainable approach is to split the tests by responsibility:

```txt
- tests/Service/OrderProcessorPaymentTest.php
- tests/Service/OrderProcessorShippingTest.php
- tests/Service/OrderProcessorNotificationTest.php
```

Shared setup logic can still be reused via a common helper trait if needed.

## Isolation Patterns

### Mocking or Not Mocking

Don’t mock everything. Simple objects should usually be instantiated directly.

In a Shopware project, the following objects are simple data containers or context providers that do **not** require mocking for unit tests:

```text
Struct::class,
Context::class,
Request::class,
ParameterBag::class,
Client::class,
```

There are even **custom PHPStan rules** in Shopware projects that report an issue when such objects are mocked.

These objects are often required to instantiate the object under test or to call a method, but they do not contain complex behavior and usually do not require expectations.

It is unnecessary to mock them since they do not improve test quality.

```php
$context = Context::createCLIContext(); // simple instantiation by helper
$request = new Request(server: ['HTTP_HOST' => 'example.com']); // simple instantiation with parameters
```

<Callout title="Note" type="info">

If you are already familiar with Shopware, you may have seen `Context::createDefaultContext` used in older examples. This method is now marked as `@internal`, and `Context::createCLIContext` is the recommended approach.

</Callout>

**Deviations:**

When a client belongs to an **external library** (not Symfony) is involved, it is usually better to mock it instead of instantiating it.

This avoids network calls during the test execution, which would slow down the tests and introduce flakiness. Typical examples clients should be mocked: `AWS SDK` and `OpenSearch`.

A counter-example is the `GuzzleHttp\Client`, which is often used to make HTTP requests to external services.

When testing an object cache mechanism that rely on `GuzzleHttp\Client`, it is better to mock the client to be able to assert that no request method is executed when the cache is hit.

Also, Collections, Definitions, Entities, Events, and Fields objects are **not** in this list and should be treated according to their behavior and test type.

### Databases

Any database interaction in a **unit test** file must be mocked. The mocked methods should return only the data required for the test to complete.

**Example:**

```php
$connection = $this->createMock(Connection::class);
$connection
    ->method('fetchAllAssociative')
    ->with('SELECT * FROM product WHERE active = 1')
    ->willReturn([
        ['id' => '1', 'name' => 'Product 1'],
        ['id' => '2', 'name' => 'Product 2'],
    ]);
```

It is not mandatory to check how many times a method is called, unless the behavior under test explicitly depends on it (e.g., loops or involving a cache mechanism).

If a class mainly performs SQL queries, mocking can validate structure and interaction, but the overall value of such unit tests is limited.

In these cases, an **integration test** is usually more meaningful than increasing unit test coverage.

### Physical Files

Some units require access to files, which introduces state into the test.

This can be handled in two common ways:

- Using fixtures (static files loading during the test)
- Using in-memory filesystem implementations

**In-memory filesystem example:**

```php
protected function setUp(): void
{
    $this->fileSystem = new InMemoryFilesystemAdapter();
    $this->storage = new Filesystem($this->fileSystem);
}
```

Using `InMemoryFilesystemAdapter` (which is part of the Symfony Filesystem component) avoids real I/O operations.
All filesystem interactions are performed in memory, and no physical files are created.

**Fixture-based example:**

```php
protected function setUp(): void
{
    $this->filePath = __DIR__ . '/fixtures/';
    require_once $this->filePath . 'sample.php';
}
```

Fixture files are typically static PHP files returning arrays or configuration data.

When physical files are generated during the test execution (e.g., temporary files), they must be cleaned up in the `tearDown()` method to avoid side effects between tests.

## Use Test Doubles and Data Providers Effectively

### Test Doubles

Test doubles are a powerful way to isolate the unit under tested by replacing real dependencies with controlled substitutes. For an overview, see the official [PHPUnit definitions for test doubles](https://docs.phpunit.de/en/11.5/test-doubles.html).

When a test double is required, prefer using mocks created by PHPUnit built-in methods.

**Example (Mock):**

```php
$service = $this->createMock(MyService::class);
$service
    ->method('performAction')
    ->willReturn(true);
```

**Example (Stub):**

```php
$service = $this->createStub(MyService::class);
$service
    ->method('performAction')
    ->willReturn(true);
```

Both approaches are valid:

- Using `createStub` is more lightweight and suitable when no expectations on method calls are required.
- Using `createMock` is preferred when you want to assert interactions, such as method call counts or parameters.

In practice, mocks are used more often because they allow verifying behavior, not just outcomes.

From the [PHPUnit major changelog](https://phpunit.de/announcements/phpunit-12.html):

_These methods allow developers to make their intentions clear in the test code they write_

- _Need a test stub to isolate the code under test from a dependency? Use `createStub()` to create a test stub._
- _Need to test communication between two objects? Use `createMock()` to create a mock object._

For a deeper discussion, see [PHPUnit presentation about testing with doubles](https://thephp.cc/presentations/testing-with-doubles-why-when-and-how).

### Dummy Objects

When a dependency is a simple data container with no behavior, it is often better to use a **dummy object** instead of a mock.

**Example:**

```php
class DummyFkResolver extends AbstractFkResolver
{
    public static function getName(): string
    {
        return 'dummy';
    }

    public function resolve(array $map): array
    {
        foreach ($map as $value) {
            $value->resolved = $value->value;
        }

        return $map;
    }
}
```

This dummy object can be used when the object under test requires an `AbstractFkResolver`, but the actual logic of resolving foreign keys is simplified for the test.

Compared to complex mocks, dummy objects are often easier to read and maintain when the behavior is simple.

Shopware tests use dummy objects in several places, for instance, in import/export tests. For now, many dummy objects are located in `namespace Shopware\Core\Test\Stub\`

### Data providers

By convention, data provider method names should end with “Provider.” This is not required for PHPUnit (especially when using `#[DataProvider(...)]`), but it improves readability and discoverability.

Data providers can return either arrays or generators. Using `yield` is preferred because it allows naming individual test cases.

Each test case should be explicit. This is especially important because PHPUnit's default failure prints the entire data set used for the test.

**Example:**

```php
    public static function tokenExpirationDataProvider(): \Generator
    {
        $token = Random::getAlphanumericString(32);
        $customerId = Uuid::randomHex();
        $updatedAt = new \DateTimeImmutable();
        // When we expire the token, we set it to 2 days ago, as there is 1 day expiration

        yield 'it keeps payload when customerId is provided and token is expired' => [
            'token' => $token,
            'customerId' => $customerId,
            'updatedAt' => $updatedAt->sub(new \DateInterval('P2D')),
            'payload' => ['a_key' => 'aValue'],
            'expected' => ['a_key' => 'aValue', 'expired' => true, 'token' => $token],
        ];
        yield 'it withdraws payload when customerId is not provided and token is expired' => [
            'token' => $token,
            'customerId' => null,
            'updatedAt' => $updatedAt->sub(new \DateInterval('P2D')),
            'payload' => ['a_key' => 'aValue', 'anotherKey' => 'anotherValue'],
            'expected' => ['expired' => true, 'token' => $token],
        ];

        yield 'it keeps payload when customerId is not provided and token is not expired' => [
            'token' => $token,
            'customerId' => null,
            'updatedAt' => $updatedAt,
            'payload' => ['a_key' => 'aValue'],
            'expected' => ['a_key' => 'aValue', 'expired' => false, 'token' => $token],
```

### Large Data Providers

When a data provider becomes very large, it is recommended to extract it into a separate class.

This can improve readability and speed up PHPStan analysis. It has **no effect on PHPUnit performance**.

**Example:**

```php

#[DataProvider(CalculatorFixtures::class, 'calculateTotalPriceProvider')]
public function testCalculateTotalPrice(array $pricesRange, ?float $discount, int $expected): void
{
    // test implementation
}
```

The dedicated fixture class `CalculatorFixtures` (`CalculatorFixtures.php`) contains the static method `calculateTotalPriceProvider()`.

```php
class CalculatorFixtures
{
    public static function calculateTotalPriceProvider(): \Generator
    {
        yield  'it keeps the same price without discount' => [
            'pricesRange' [
                'initial' => 100.4,
                'primary' => 50.2,
                'secondary' => 25.2,
                'maximum' => 150.6,
            ],
            'discount' => null,
            'expected' => 100.4
        ];
        yield  'it applies 20% discount, on initial price' => [
            'pricesRange' [
                'initial' => 100,
                'primary' => 50.2,
                'secondary' => 25.2,
                'maximum' => 150.6,
            ],
            'discount' => 0.2,
            'expected' => 80
        ];
        ...
    }
}
```

Note that the class `CalculatorFixtures` does not need to extend `TestCase` since it is not a test suite.

**Avoid abuses:**

A data provider can be understood as a convenient loop over test cases. However, assertion logic should **not** be embedded in closures whenever possible. Using plain values or simple objects as expectations keeps tests easier to read and reason about.

Exception scenarios should usually be tested separately, not mixed into the same data provider.

If a test method starts to contain conditional logic (e.g., `if` statements for assertions or special mock setup per case), this is often a sign that the data provider has become too broad.

In such cases, split the scenarios into separate data providers or dedicated test methods.

**Bad example:**

```php
    public static function calculateProvider(): \Generator
    {
        yield 'it works' => [
            'price' => 200,
            'discount' => 0.1,
            'threshold' => 150,
            'expected' => function($total) {
                static::assertSame(200, $total);
            }
        ];
        yield  'exception test' => [
            'price' => 100,
            'discount' => 0.1,
            'threshold' => null,
            'expected' => null;
        ];
        ...
    }

    #[DataProvider('calculateProvider')]
    public function testCalculateTotalPrice(int $price, ?float $discount, ?Closure $expected): void
    {
        $calculator = new PriceCalculator();
        if ($expected === null) {
            $this->expectException(new TerribleException());
        }
        $total = $calculator->calculateTotalPrice($price, $discount);

        if ($expected != null) {
            $expected($total);
        }
    }
```

Here, the data provider mixes two distinct concerns: the normal calculation flow and the exception case.

As a result, the test method becomes harder to read due to conditional logic. This complexity tends to grow quickly when more cases are added or when additional setup logic is required.

In such situations, it is better to split the scenarios into two distinct data providers or dedicated test methods.

```php
    public static function calculateWithDiscountProvider(): \Generator
    {
        yield 'it applies discount when price is above threshold' => [
            'price' => 200,
            'discount' => 0.1,
            'threshold' => 150,
            'expected' => 180
        ];
        ...
    }

    #[DataProvider('calculateWithDiscountProvider')]
    public function testCalculateTotalPriceWithDiscount(int $price, ?float $discount, int $expected): void
    {
        $calculator = new PriceCalculator();
        $total = $calculator->calculateTotalPrice($price, $discount);

        $this->assertSame($expected, $total);
    }

    public function testCalculateTotalPriceThrowsExceptionWhenThresholdNotMet(): void
    {
        $calculator = new PriceCalculator();
        $this->expectException(new TerribleException());
        $calculator->calculateTotalPrice(100, 0.1);
    }
```

## Summary

Very well! In this learning unit, you learned:

- Test method names should clearly describe the behavior or scenario being tested.
- Test files should mirror the structure of the source code; large test files should be split by subdomain.
- Only mock complex dependencies; simple data containers (such as `Context` or `Request`) should be instantiated directly.
- Prefer PHPUnit's built-in `createMock()` for test doubles; use dummy objects for simple dependencies.
- For unit tests, always mock database connections and external services to keep tests fast and isolated.
- Use in-memory filesystems and fixtures to avoid real file I/O in tests.
- Data providers should be explicit, clearly named with `Provider`, and focus on a single concern.
- Extract large data providers to separate files for better maintainability.
