Production-Ready PHPUnit Checklist: Structure, Speed, Docker, PhpStorm, Magento
AI generated
@test
assert
PHPUnit · Checklist · Docker · Magento · PhpStorm
Production-Ready PHPUnit Checklist
Structure, Speed, Docker, PhpStorm & Magento

A slow, unstructured test suite is not a test suite, it is a drag on the team. This checklist surfaces every place where PHPUnit setups lose time, maintainability and developer trust, and gives a directly actionable step for each point.

18 min read Structure · Runtime · Docker · PhpStorm · Magento PHPUnit 10/11 · PHP 8.3/8.4 · Magento 2.4.x

1. What makes a test suite production ready

A test suite is production ready when it runs fast enough to be used in the daily development cycle, when its output is clear enough to understand failures without debugging, and when its structure is clear enough to add new tests without a long onboarding effort. Most PHP projects do not satisfy all three criteria at once. Usually one of the three dimensions is neglected: either the tests are too slow, or their failure messages are cryptic, or the structure has grown so organically that nobody knows anymore where a new test belongs.

The following checklist systematically walks through every area that makes up a production-ready PHPUnit suite. Every point is directly actionable, with a clear yes/no criterion and a remedy when the criterion is not met. The checklist is designed for PHP projects with a Magento share, but every point on structure, runtime and Docker carries over to any PHP project.

2. Checklist: test structure and naming conventions

A consistent test structure is the precondition for the whole team finding, reading and extending tests without needing to ask around. The directory structure ideally mirrors the structure of the production code: every class in src/Model/ProductEnricher.php has its test class in Test/Unit/Model/ProductEnricherTest.php. The Test suffix is PHPUnit convention and should not be varied. Integration tests and unit tests must live in separate directories and be addressable through separate test suites in phpunit.xml.

Test method names are documentation. A method name like testWorks explains nothing; testReturnsZeroWhenCartIsEmpty explains precondition, action and expected outcome. The Given/When/Then pattern and the test[SUT]_[Action]_[ExpectedBehavior] style are both acceptable conventions, what matters is that the whole team uses the same one. Missing or inconsistent naming conventions are the most common reason new developers do not understand a test suite and therefore do not write new tests.


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Model;

use Mironsoft\Catalog\Model\PriceCalculator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
 * Unit tests for PriceCalculator.
 *
 * Naming convention: test[What]_[Condition]_[ExpectedResult]
 * Each test has exactly one logical assertion.
 * Test class mirrors production class path: Model/PriceCalculator to Test/Unit/Model/PriceCalculatorTest
 */
#[CoversClass(PriceCalculator::class)]
final class PriceCalculatorTest extends TestCase
{
    private PriceCalculator $calculator;

    protected function setUp(): void
    {
        // No mocks needed, PriceCalculator has no external dependencies
        $this->calculator = new PriceCalculator(taxRate: 0.19);
    }

    #[Test]
    public function testCalculateGross_WithNetPrice_ReturnsNetPlusVat(): void
    {
        $gross = $this->calculator->calculateGross(net: 100.00);

        self::assertEqualsWithDelta(119.00, $gross, 0.001);
    }

    #[Test]
    #[DataProvider('negativePriceProvider')]
    public function testCalculateGross_WithNegativePrice_ThrowsInvalidArgument(float $price): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Price must be non-negative');

        $this->calculator->calculateGross(net: $price);
    }

    /** @return array<string, array{float}> */
    public static function negativePriceProvider(): array
    {
        return [
            'minus one cent' => [-0.01],
            'large negative' => [-999.99],
        ];
    }
}

3. Checklist: runtime optimization and test isolation

Slow tests are tests that do not get run. The most common causes of slow PHPUnit suites are: unnecessary database access in unit tests, uncached fixtures, missing test isolation (shared state between tests) and coverage enabled even though it is not needed. Every one of these points can be fixed with a targeted measure.

Test isolation means: every test starts in a defined, clean state and leaves no state behind for the next test. Static variables, singleton instances and global configuration are the most common sources of test contamination. PHPUnit runs tests sequentially by default; parallelizing with paratest or phpunit --processes can reduce runtime by 50 to 80% depending on the suite, provided tests are fully isolated. Without isolation, parallelization produces sporadic failures that are hard to reproduce.


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\Model;

use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use Mironsoft\Catalog\Model\ProductService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
 * Checklist: Test isolation, no shared state, no real I/O.
 * Mocks replace all external dependencies.
 * setUp() runs before EACH test, no state leaks between methods.
 */
final class ProductServiceTest extends TestCase
{
    // Typed property, reset in setUp() before every test
    private MockObject&ProductRepositoryInterface $repositoryMock;
    private ProductService $service;

    protected function setUp(): void
    {
        // Fresh mock for every test, no state from previous tests
        $this->repositoryMock = $this->createMock(ProductRepositoryInterface::class);
        $this->service = new ProductService(repository: $this->repositoryMock);
    }

    public function testGetById_WhenProductExists_ReturnsProduct(): void
    {
        $productMock = $this->createConfiguredMock(
            \Mironsoft\Catalog\Api\Data\ProductInterface::class,
            ['getId' => 42, 'getSku' => 'TEST-001']
        );

        $this->repositoryMock
            ->expects($this->once())  // Verifies exactly 1 call, no unnecessary DB hits
            ->method('getById')
            ->with(42)
            ->willReturn($productMock);

        $result = $this->service->getById(42);

        self::assertSame(42, $result->getId());
    }

    protected function tearDown(): void
    {
        // PHPUnit resets mocks automatically, but explicit cleanup
        // is good practice for resources (open files, connections)
        unset($this->repositoryMock, $this->service);
    }
}

4. Checklist: Docker setup for PHPUnit

A working Docker setup for PHPUnit requires three components: the right PHP version with the necessary extensions (Xdebug or PCOV for coverage, intl, mbstring for Magento), a separate test database isolated from the development database, and correct permissions on the cache and log directories. The most common problem in Docker PHPUnit setups: tests write to the same database as the development instance, which leads to data loss or inconsistent tests.

For Magento integration tests, etc/install-config-mysql.php must point to the test database, not to the development database. This file should not live in version control (it contains credentials), but a template (install-config-mysql.php.dist) should be present. The Mark Shust setup provides the bin/magento wrapper for all Magento commands inside the container; PHPUnit for unit tests can be invoked directly via bin/cli vendor/bin/phpunit.

5. Checklist: PhpStorm integration

Full PhpStorm integration for PHPUnit means: tests start with a click or keyboard shortcut, failure messages open the affected line directly, coverage is visible in the editor and run configurations are versioned for the team. The most common integration gaps in practice: the remote interpreter points to the wrong PHP version, coverage is not configured or too slow to be useful, and run configurations exist only on one developer's machine without team synchronization.

The keyboard shortcut Ctrl+Shift+F10 runs the test under the cursor in PhpStorm without needing to create a run configuration. Ctrl+Shift+R reruns the last executed configuration. These two shortcuts are the core of a fast TDD cycle in PhpStorm. Anyone who does not know them loses several seconds per test run to mouse movement and menu interaction.

6. Checklist: Magento-specific patterns

Magento projects have specific requirements for PHPUnit that go beyond standard PHP tests. Unit tests in Magento test classes without the Magento bootstrap, meaning: no ObjectManager, no DI container, no real repositories. Instead, every dependency is passed in as a mock. Integration tests use the Magento bootstrap and the real ObjectManager to exercise realistic scenarios, at the cost of a runtime of seconds to minutes per test.

ViewModels are the preferred abstraction in Hyva projects. Their advantage for testing: they have no dependency on layout XML or block classes and can be instantiated directly. A ViewModel with three dependencies can be fully configured with mocks in three lines of setUp(). Magento blocks, on the other hand, have implicit dependencies on the ObjectManager that make unit tests harder or impossible.


<?php

declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Unit\ViewModel;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Mironsoft\Catalog\ViewModel\ProductPriceViewModel;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
 * Magento-specific checklist: ViewModel tests.
 * No ObjectManager, no DI container, no Magento bootstrap.
 * All dependencies injected as mocks, fast, isolated, reliable.
 */
#[CoversClass(ProductPriceViewModel::class)]
final class ProductPriceViewModelTest extends TestCase
{
    private ProductPriceViewModel $viewModel;

    protected function setUp(): void
    {
        $scopeConfigMock = $this->createConfiguredMock(
            ScopeConfigInterface::class,
            ['getValue' => '1', 'isSetFlag' => true]
        );

        $priceCurrencyMock = $this->createConfiguredMock(
            PriceCurrencyInterface::class,
            ['format' => '€ 99,00', 'convertAndFormat' => '€ 99,00']
        );

        // ViewModel: no ObjectManager needed, direct injection possible
        $this->viewModel = new ProductPriceViewModel(
            scopeConfig: $scopeConfigMock,
            priceCurrency: $priceCurrencyMock
        );
    }

    #[Test]
    public function testIsPriceDisplayEnabled_WhenConfigEnabled_ReturnsTrue(): void
    {
        self::assertTrue($this->viewModel->isPriceDisplayEnabled());
    }

    #[Test]
    public function testFormatPrice_WithValidAmount_ReturnsFormattedString(): void
    {
        $formatted = $this->viewModel->formatPrice(99.00);

        self::assertStringContainsString('99', $formatted);
    }
}

7. Production ready vs. counterproductive: direct comparison

Many anti-patterns in PHPUnit projects do not come from a lack of knowledge but from time pressure or missing convention. The following table contrasts the most common counterproductive patterns with the production-ready alternatives.

Area Counterproductive Production ready Impact
Test structure tests/ without hierarchy Test/Unit/ + Test/Integration/ Separate suites, targeted execution
Method names testWorks, testOk, test1 testCalculate_EmptyCart_ReturnsZero Self-explanatory failure messages
Coverage in TDD Always enabled Disabled in the feedback cycle 3 to 5x faster test run
Database Shared with dev DB Dedicated test database in Docker No data loss, stable tests
Run configurations Local only, not shared In Git (.idea/runConfigurations/) Immediately usable for every team member

In most teams the single biggest productivity measure is separating the test suites: unit tests separate from integration tests. That makes it possible to get feedback in under 30 seconds without waiting for integration tests. Integration tests then run once in the CI pipeline, not on every local code pass.

Mironsoft

PHPUnit audit, test architecture and productivity setup for PHP teams

Want your PHPUnit setup trimmed for production?

We analyze your PHPUnit setup, identify the speed bottlenecks and implement every point of this checklist, from test structure through Docker integration to PhpStorm configuration for your team.

Test audit

Analyze the existing test suite, identify anti-patterns, measure runtime

Structure refactoring

Separate test suites, introduce naming conventions, optimize phpunit.xml

Team enablement

Configure Docker, PhpStorm and the CI pipeline, run a team workshop

8. Summary

A production-ready PHPUnit suite is not an accident, it is the result of deliberate decisions in five areas: structure, speed, Docker setup, PhpStorm integration and Magento-specific patterns. Structure decides whether new tests can be added without friction. Runtime decides whether tests get used in the daily cycle. The Docker setup decides whether tests run in a controlled, reproducible environment. PhpStorm integration decides whether writing and running tests is productive. The Magento patterns decide whether tests stay maintainable and extensible.

The most common mistake: teams invest a lot of time in writing tests but barely any time in the setup. The result is tests that run slowly, are hard to read and get avoided by new team members. Half a day invested in the setup, test structure, phpunit.xml, run configurations, database isolation, pays for itself within days on a medium-sized codebase through saved debugging time and faster feedback cycles.

Production-Ready PHPUnit Checklist — the essentials at a glance

Structure

Separate Test/Unit/ and Test/Integration/. Method names describe precondition, action and expected result. Every production class has exactly one test class at the mirrored path.

Speed

Disable coverage in the TDD cycle. Isolate unit tests, no database access, no real HTTP calls. With full isolation in place, evaluate parallelization with paratest.

Docker & PhpStorm

Dedicated test database in Docker. Point the remote interpreter at the PHP container. Version run configurations as XML in Git. Ctrl+Shift+F10 for a fast single-test run.

Magento

ViewModels instead of block classes, directly instantiable, no ObjectManager needed. Integration tests only for Magento-specific scenarios with a real DI container.

9. FAQ: Production-Ready PHPUnit Checklist

1How much runtime is acceptable for unit tests?
Under 5 seconds for a single module, under 60 seconds for the entire unit suite. Beyond that, tests stop being run consistently in the development cycle.
2When does paratest pay off?
With full test isolation and more than 200 tests. Without isolation, parallelization produces sporadic failures that are harder to debug than slow tests.
3Does every class need a test class?
No, only classes with business logic and decisions. Pure DTOs, delegation and value objects without logic can be skipped.
4Unit test vs. integration test?
The boundary is the first real I/O call: database access, HTTP request, file system. Before that: unit test. From the first real I/O onward: integration test.
5Unit test needs a DB connection?
That is not a unit test. Move the test to Test/Integration/ or refactor the class to abstract DB access behind a repository interface.
6Managing Magento test fixtures?
Use @magentoDataFixture, keep it minimal. @magentoDbIsolation rollback restores the original state after every test.
7Target for the TDD feedback cycle?
Under 3 seconds from save to result for unit tests. Above 10 seconds, the cycle breaks the development flow.
8Exclude .idea/ from Git completely?
No. Check in .idea/runConfigurations/, put workspace.xml and personal files in .gitignore. Many templates mistakenly ignore the entire .idea/ directory.
9Testing a Magento plugin correctly?
Unit test plugin classes like normal classes, mock the subject. The DI interception behavior itself is an integration test concern.
10Detecting whether tests are really isolated?
Run --order-by=random. If tests fail in a different order, there are state dependencies. --repeat=N surfaces race conditions.