Comprehensive Testing Strategies for Magento 2
Developer guide: Comprehensive Testing Strategies for Magento 2
Comprehensive Testing Strategies for Magento 2
Overview
Comprehensive testing is non-negotiable for production Magento applications. This guide provides battle-tested strategies for unit, integration, API functional, and MFTF testing, with patterns that ensure code quality, prevent regressions, and enable confident deployments.
What you'll learn: - Unit testing with PHPUnit (mocking, ObjectManager usage) - Integration tests with database fixtures - API functional testing for REST/GraphQL endpoints - MFTF (Magento Functional Testing Framework) for E2E scenarios - Test coverage requirements and tooling - CI/CD integration patterns
Prerequisites: - PHP 8.2+ knowledge (typed properties, attributes) - Magento module structure and DI - Basic PHPUnit experience - Understanding of service contracts
Testing Pyramid for Magento
/\
/ \ E2E/MFTF (10%)
/____\
/ \ Integration (30%)
/________\
/ \ Unit Tests (60%)
/__________\
Distribution rationale: - Unit tests (60%): Fast, isolated, test business logic - Integration tests (30%): Verify DI, DB interactions, service contracts - MFTF/E2E (10%): Critical user journeys, smoke tests
Unit Testing with PHPUnit
Principles
- Isolated: No database, no filesystem, no network
- Fast: <100ms per test
- Deterministic: Same input always produces same output
- Single responsibility: One assertion per test (guideline)
Test Structure
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\Unit\Model;
use PHPUnit\Framework\TestCase;
use Vendor\Module\Model\OrderProcessor;
use Vendor\Module\Api\OrderValidatorInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Api\Data\OrderInterface;
use Psr\Log\LoggerInterface;
class OrderProcessorTest extends TestCase
{
private OrderRepositorInterface $orderRepositoryMock;
private OrderValidatorInterface $validatorMock;
private LoggerInterface $loggerMock;
private OrderProcessor $orderProcessor;
protected function setUp(): void
{
// Create mocks
$this->orderRepositoryMock = $this->createMock(OrderRepositoryInterface::class);
$this->validatorMock = $this->createMock(OrderValidatorInterface::class);
$this->loggerMock = $this->createMock(LoggerInterface::class);
// Instantiate SUT (System Under Test)
$this->orderProcessor = new OrderProcessor(
$this->orderRepositoryMock,
$this->validatorMock,
$this->loggerMock
);
}
public function testProcessOrderSuccess(): void
{
// Arrange
$orderId = 123;
$orderMock = $this->createMock(OrderInterface::class);
$orderMock->method('getId')->willReturn($orderId);
$orderMock->method('getStatus')->willReturn('pending');
$this->orderRepositoryMock->method('get')
->with($orderId)
->willReturn($orderMock);
$this->validatorMock->method('validate')
->with($orderMock)
->willReturn(true);
$this->orderRepositoryMock->expects($this->once())
->method('save')
->with($orderMock);
// Act
$result = $this->orderProcessor->process($orderId);
// Assert
$this->assertTrue($result);
}
public function testProcessOrderValidationFails(): void
{
// Arrange
$orderId = 123;
$orderMock = $this->createMock(OrderInterface::class);
$this->orderRepositoryMock->method('get')
->with($orderId)
->willReturn($orderMock);
$this->validatorMock->method('validate')
->with($orderMock)
->willReturn(false);
$this->loggerMock->expects($this->once())
->method('warning')
->with(
$this->stringContains('Order validation failed'),
$this->arrayHasKey('order_id')
);
// Act
$result = $this->orderProcessor->process($orderId);
// Assert
$this->assertFalse($result);
}
public function testProcessOrderThrowsExceptionOnRepositoryError(): void
{
// Arrange
$orderId = 123;
$exception = new \Magento\Framework\Exception\NoSuchEntityException(
__('Order not found')
);
$this->orderRepositoryMock->method('get')
->with($orderId)
->willThrowException($exception);
// Assert
$this->expectException(\Magento\Framework\Exception\NoSuchEntityException::class);
$this->expectExceptionMessage('Order not found');
// Act
$this->orderProcessor->process($orderId);
}
}
Mocking Strategies
1. Simple mocks (no behavior):
$mock = $this->createMock(MyInterface::class);
2. Stub methods (return values):
$mock = $this->createMock(OrderInterface::class);
$mock->method('getId')->willReturn(123);
$mock->method('getStatus')->willReturn('pending');
3. Consecutive calls:
$mock->method('getStatus')
->willReturnOnConsecutiveCalls('pending', 'processing', 'complete');
4. Callback logic:
$mock->method('validate')
->willReturnCallback(function (OrderInterface $order) {
return $order->getGrandTotal() > 0;
});
5. Expectations (verify calls):
$mock->expects($this->once())
->method('save')
->with($this->equalTo($expectedOrder));
Testing Exceptions
public function testInvalidInputThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Order ID must be positive');
$this->orderProcessor->process(-1);
}
Data Providers for Parametric Tests
/**
* @dataProvider orderStatusProvider
*/
public function testGetStatusLabel(string $status, string $expectedLabel): void
{
$this->assertEquals($expectedLabel, $this->orderProcessor->getStatusLabel($status));
}
public function orderStatusProvider(): array
{
return [
'pending' => ['pending', 'Pending'],
'processing' => ['processing', 'Processing'],
'complete' => ['complete', 'Complete'],
'canceled' => ['canceled', 'Canceled'],
];
}
Avoid ObjectManager in Unit Tests
BAD:
$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
$orderProcessor = $objectManager->create(OrderProcessor::class);
GOOD:
$orderProcessor = new OrderProcessor(
$this->orderRepositoryMock,
$this->validatorMock,
$this->loggerMock
);
Why? Unit tests must be isolated. ObjectManager loads entire Magento framework, making tests slow and brittle.
Integration Testing
When to Use Integration Tests
Use integration tests when:
- Testing database interactions (repositories, resource models)
- Verifying DI configuration (di.xml)
- Testing service contracts with real implementations
- Validating events/observers
- Testing plugins (interceptors)
Basic Integration Test Structure
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\Integration\Model;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Vendor\Module\Api\OrderProcessorInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
class OrderProcessorTest extends TestCase
{
private OrderProcessorInterface $orderProcessor;
private OrderRepositoryInterface $orderRepository;
protected function setUp(): void
{
$objectManager = Bootstrap::getObjectManager();
$this->orderProcessor = $objectManager->get(OrderProcessorInterface::class);
$this->orderRepository = $objectManager->get(OrderRepositoryInterface::class);
}
/**
* @magentoDataFixture Magento/Sales/_files/order.php
* @magentoDbIsolation enabled
* @magentoAppIsolation enabled
*/
public function testProcessOrderUpdatesStatus(): void
{
// Fixture creates order with ID 100000001
$orderId = 100000001;
$this->orderProcessor->process($orderId);
$order = $this->orderRepository->get($orderId);
$this->assertEquals('processing', $order->getStatus());
}
/**
* @magentoDataFixture Vendor_Module::Test/Integration/_files/order_with_items.php
* @magentoDbIsolation enabled
*/
public function testProcessOrderWithItems(): void
{
$orderId = 100000002; // From fixture
$result = $this->orderProcessor->process($orderId);
$this->assertTrue($result);
$order = $this->orderRepository->get($orderId);
$this->assertCount(2, $order->getItems());
}
}
Creating Custom Fixtures
File: Test/Integration/_files/order_with_items.php
<?php
declare(strict_types=1);
use Magento\TestFramework\Helper\Bootstrap;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Order\Item;
use Magento\Catalog\Model\Product;
$objectManager = Bootstrap::getObjectManager();
// Create product
$product = $objectManager->create(Product::class);
$product->setTypeId('simple')
->setId(1)
->setAttributeSetId(4)
->setName('Test Product')
->setSku('test-product-sku')
->setPrice(100.00)
->setVisibility(4)
->setStatus(1)
->setWebsiteIds([1]);
$product->save();
// Create order
$order = $objectManager->create(Order::class);
$order->setIncrementId('100000002')
->setStoreId(1)
->setState(Order::STATE_NEW)
->setStatus('pending')
->setCustomerIsGuest(false)
->setCustomerEmail('customer@example.com')
->setCustomerFirstname('John')
->setCustomerLastname('Doe')
->setGrandTotal(200.00)
->setBaseGrandTotal(200.00)
->setSubtotal(200.00)
->setBaseSubtotal(200.00);
// Add order items
$orderItem1 = $objectManager->create(Item::class);
$orderItem1->setProductId($product->getId())
->setSku($product->getSku())
->setName($product->getName())
->setPrice(100.00)
->setQtyOrdered(1);
$order->addItem($orderItem1);
$orderItem2 = $objectManager->create(Item::class);
$orderItem2->setProductId($product->getId())
->setSku($product->getSku())
->setName($product->getName())
->setPrice(100.00)
->setQtyOrdered(1);
$order->addItem($orderItem2);
$order->save();
Rollback: Test/Integration/_files/order_with_items_rollback.php
<?php
declare(strict_types=1);
use Magento\TestFramework\Helper\Bootstrap;
use Magento\Sales\Model\Order;
use Magento\Framework\Registry;
$objectManager = Bootstrap::getObjectManager();
$registry = $objectManager->get(Registry::class);
// Prevent observer execution
$registry->unregister('isSecureArea');
$registry->register('isSecureArea', true);
// Delete order
$order = $objectManager->create(Order::class);
$order->loadByIncrementId('100000002');
if ($order->getId()) {
$order->delete();
}
// Delete product
$product = $objectManager->create(\Magento\Catalog\Model\Product::class);
$product->load(1);
if ($product->getId()) {
$product->delete();
}
$registry->unregister('isSecureArea');
Testing Plugins (Interceptors)
Plugin class:
<?php
declare(strict_types=1);
namespace Vendor\Module\Plugin;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Api\Data\OrderInterface;
class OrderRepositoryExtend
{
/**
* Add custom attribute after loading order
*
* @param OrderRepositoryInterface $subject
* @param OrderInterface $result
* @return OrderInterface
*/
public function afterGet(
OrderRepositoryInterface $subject,
OrderInterface $result
): OrderInterface {
$extensionAttributes = $result->getExtensionAttributes();
$extensionAttributes->setCustomAttribute('processed');
$result->setExtensionAttributes($extensionAttributes);
return $result;
}
}
Integration test:
/**
* @magentoDataFixture Magento/Sales/_files/order.php
*/
public function testPluginAddsCustomAttribute(): void
{
$order = $this->orderRepository->get(100000001);
$extensionAttributes = $order->getExtensionAttributes();
$this->assertEquals('processed', $extensionAttributes->getCustomAttribute());
}
Testing Observers
Observer:
<?php
declare(strict_types=1);
namespace Vendor\Module\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class OrderPlaceAfter implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger
) {}
public function execute(Observer $observer): void
{
$order = $observer->getEvent()->getOrder();
$this->logger->info('Order placed', ['order_id' => $order->getId()]);
}
}
Integration test:
/**
* @magentoDataFixture Magento/Sales/_files/order.php
*/
public function testObserverExecutesOnOrderPlace(): void
{
$objectManager = Bootstrap::getObjectManager();
$eventManager = $objectManager->get(\Magento\Framework\Event\ManagerInterface::class);
$order = $this->orderRepository->get(100000001);
// Dispatch event
$eventManager->dispatch('sales_order_place_after', ['order' => $order]);
// Verify log entry (requires custom log handler or DB logging)
// For demonstration, test observer is registered
$this->assertTrue(true);
}
API Functional Testing
REST API Tests
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\Api;
use Magento\TestFramework\TestCase\WebapiAbstract;
class OrderManagementTest extends WebapiAbstract
{
private const RESOURCE_PATH = '/V1/custom/orders';
/**
* @magentoApiDataFixture Magento/Sales/_files/order.php
*/
public function testGetOrderViaApi(): void
{
$orderId = 100000001;
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH . '/' . $orderId,
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET,
],
];
$response = $this->_webApiCall($serviceInfo);
$this->assertArrayHasKey('entity_id', $response);
$this->assertEquals($orderId, $response['entity_id']);
$this->assertArrayHasKey('status', $response);
}
/**
* @magentoApiDataFixture Magento/Customer/_files/customer.php
*/
public function testCreateOrderViaApi(): void
{
$orderData = [
'customer_email' => 'customer@example.com',
'items' => [
[
'sku' => 'simple-product',
'qty' => 1,
'price' => 100.00
]
],
'billing_address' => [
'firstname' => 'John',
'lastname' => 'Doe',
'street' => ['123 Main St'],
'city' => 'Los Angeles',
'region' => 'CA',
'postcode' => '90001',
'country_id' => 'US',
'telephone' => '555-1234'
]
];
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH,
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST,
],
];
$response = $this->_webApiCall($serviceInfo, $orderData);
$this->assertArrayHasKey('entity_id', $response);
$this->assertGreaterThan(0, $response['entity_id']);
}
public function testUnauthorizedAccessReturns401(): void
{
$this->expectException(\Exception::class);
$this->expectExceptionCode(401);
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH . '/999',
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET,
'token' => 'invalid_token'
],
];
$this->_webApiCall($serviceInfo);
}
}
GraphQL API Tests
<?php
declare(strict_types=1);
namespace Vendor\Module\Test\GraphQl;
use Magento\TestFramework\TestCase\GraphQlAbstract;
class OrderQueryTest extends GraphQlAbstract
{
/**
* @magentoApiDataFixture Magento/Sales/_files/order.php
*/
public function testGetOrderViaGraphQl(): void
{
$orderId = 100000001;
$query = <<<QUERY
{
customOrder(id: $orderId) {
id
increment_id
status
grand_total
items {
sku
name
qty_ordered
}
}
}
QUERY;
$response = $this->graphQlQuery($query);
$this->assertArrayHasKey('customOrder', $response);
$this->assertEquals($orderId, $response['customOrder']['id']);
$this->assertIsArray($response['customOrder']['items']);
}
/**
* @magentoApiDataFixture Magento/Customer/_files/customer.php
*/
public function testCreateOrderMutation(): void
{
$mutation = <<<MUTATION
mutation {
createCustomOrder(input: {
customer_email: "customer@example.com"
items: [
{
sku: "simple-product"
qty: 1
}
]
}) {
order {
id
increment_id
status
}
}
}
MUTATION;
$response = $this->graphQlMutation($mutation);
$this->assertArrayHasKey('createCustomOrder', $response);
$this->assertArrayHasKey('order', $response['createCustomOrder']);
$this->assertGreaterThan(0, $response['createCustomOrder']['order']['id']);
}
}
MFTF (Magento Functional Testing Framework)
When to Use MFTF
Use MFTF for: - Critical user journeys (checkout, customer registration) - Cross-module integration scenarios - Admin panel workflows - Smoke tests for deployment validation
Not for: Unit-level logic, API endpoints (use API functional tests)
MFTF Test Structure
File: Test/Mftf/Test/AdminProcessOrderTest.xml
<?xml version="1.0" encoding="UTF-8"?>
<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd">
<test name="AdminProcessOrderTest">
<annotations>
<features value="Order Management"/>
<stories value="Admin processes pending order"/>
<title value="Admin can process order and update status"/>
<description value="Verify admin can process order from pending to processing status"/>
<severity value="CRITICAL"/>
<testCaseId value="OM-123"/>
<group value="order"/>
<group value="admin"/>
</annotations>
<before>
<!-- Create order fixture -->
<createData entity="SimpleProduct" stepKey="createProduct"/>
<createData entity="Simple_US_Customer" stepKey="createCustomer"/>
<createData entity="CustomerCart" stepKey="createCustomerCart">
<requiredEntity createDataKey="createCustomer"/>
</createData>
<createData entity="CustomerCartItem" stepKey="addCartItem">
<requiredEntity createDataKey="createCustomerCart"/>
<requiredEntity createDataKey="createProduct"/>
</createData>
<createData entity="CustomerAddressInformation" stepKey="addCustomerOrderAddress">
<requiredEntity createDataKey="createCustomerCart"/>
</createData>
<updateData createDataKey="createCustomerCart" entity="CustomerOrderPaymentMethod" stepKey="sendCustomerPaymentInformation">
<requiredEntity createDataKey="createCustomerCart"/>
</updateData>
<!-- Login as admin -->
<actionGroup ref="AdminLoginActionGroup" stepKey="loginAsAdmin"/>
</before>
<after>
<!-- Cleanup -->
<deleteData createDataKey="createProduct" stepKey="deleteProduct"/>
<deleteData createDataKey="createCustomer" stepKey="deleteCustomer"/>
<actionGroup ref="AdminLogoutActionGroup" stepKey="logout"/>
</after>
<!-- Navigate to order -->
<actionGroup ref="AdminOrdersPageOpenActionGroup" stepKey="goToOrdersPage"/>
<actionGroup ref="AdminOrdersGridClearFiltersActionGroup" stepKey="clearFilters"/>
<actionGroup ref="FilterOrderGridByIdActionGroup" stepKey="filterOrderById">
<argument name="orderId" value="$createCustomerCart.return$"/>
</actionGroup>
<click selector="{{AdminOrdersGridSection.firstRow}}" stepKey="clickOrderRow"/>
<!-- Process order -->
<click selector="{{AdminOrderDetailsMainActionsSection.processOrder}}" stepKey="clickProcessOrder"/>
<waitForPageLoad stepKey="waitForProcessing"/>
<!-- Verify status updated -->
<see selector="{{AdminOrderDetailsInformationSection.orderStatus}}" userInput="Processing" stepKey="seeProcessingStatus"/>
<see selector="{{AdminMessagesSection.success}}" userInput="Order processed successfully" stepKey="seeSuccessMessage"/>
</test>
</tests>
Custom Action Groups
File: Test/Mftf/ActionGroup/AdminProcessOrderActionGroup.xml
<?xml version="1.0" encoding="UTF-8"?>
<actionGroups xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/actionGroupSchema.xsd">
<actionGroup name="AdminProcessOrderActionGroup">
<annotations>
<description>Process order from order view page</description>
</annotations>
<arguments>
<argument name="orderId" type="string"/>
</arguments>
<amOnPage url="{{AdminOrderPage.url(orderId)}}" stepKey="navigateToOrderPage"/>
<waitForPageLoad stepKey="waitForOrderPage"/>
<click selector="{{AdminOrderDetailsMainActionsSection.processOrder}}" stepKey="clickProcessButton"/>
<waitForPageLoad stepKey="waitForProcessing"/>
<see selector="{{AdminMessagesSection.success}}" userInput="Order processed successfully" stepKey="verifySuccess"/>
</actionGroup>
</actionGroups>
Running MFTF Tests
# Generate and run specific test
vendor/bin/mftf generate:tests
vendor/bin/mftf run:test AdminProcessOrderTest
# Run group
vendor/bin/mftf run:group order
# Run with specific browser
vendor/bin/mftf run:test AdminProcessOrderTest --browser chrome
# Parallel execution (requires Selenium Grid)
vendor/bin/mftf run:group order --parallel 4
Test Coverage Requirements
Coverage Targets
| Test Type | Minimum Coverage | Target Coverage |
|---|---|---|
| Unit | 70% | 80%+ |
| Integration | 50% | 65%+ |
| MFTF | Critical paths | 100% of user journeys |
Measuring Coverage
PHPUnit with coverage:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --coverage-html var/coverage/unit
Integration test coverage:
vendor/bin/phpunit -c dev/tests/integration/phpunit.xml.dist --coverage-html var/coverage/integration
View coverage report:
open var/coverage/unit/index.html
Coverage Configuration (phpunit.xml)
<phpunit>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">../../../app/code/Vendor/Module</directory>
<exclude>
<directory>../../../app/code/Vendor/Module/Test</directory>
<directory>../../../app/code/Vendor/Module/Setup</directory>
</exclude>
</whitelist>
</filter>
</phpunit>
CI/CD Integration
GitHub Actions Workflow
File: .github/workflows/tests.yml
name: Magento Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: bcmath, ctype, curl, dom, gd, intl, mbstring, pdo_mysql, simplexml, soap, xsl, zip
coverage: xdebug
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run unit tests
run: vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --coverage-clover coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage.xml
integration-tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: magento_integration_tests
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.0
env:
discovery.type: single-node
ports:
- 9200:9200
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: bcmath, ctype, curl, dom, gd, intl, mbstring, pdo_mysql, simplexml, soap, xsl, zip
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Setup Magento
run: |
php bin/magento setup:install \
--db-host=127.0.0.1 \
--db-name=magento_integration_tests \
--db-user=root \
--db-password=root \
--backend-frontname=admin \
--search-engine=elasticsearch7 \
--elasticsearch-host=127.0.0.1 \
--elasticsearch-port=9200
- name: Run integration tests
run: |
cd dev/tests/integration
../../../vendor/bin/phpunit -c phpunit.xml.dist
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse app/code/Vendor/Module --level 8
- name: Run PHPCS
run: vendor/bin/phpcs app/code/Vendor/Module --standard=Magento2
GitLab CI Pipeline
File: .gitlab-ci.yml
stages:
- test
- analyze
unit-tests:
stage: test
image: php:8.2-cli
services:
- mysql:8.0
variables:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: magento_tests
script:
- composer install
- vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist --coverage-text
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
integration-tests:
stage: test
image: php:8.2-cli
services:
- mysql:8.0
- elasticsearch:7.17.0
script:
- composer install
- bin/magento setup:install --db-host=mysql --db-name=magento_tests --db-user=root --db-password=root
- cd dev/tests/integration && ../../../vendor/bin/phpunit
phpstan:
stage: analyze
image: php:8.2-cli
script:
- composer install
- vendor/bin/phpstan analyse app/code/Vendor/Module --level 8
allow_failure: false
phpcs:
stage: analyze
image: php:8.2-cli
script:
- composer install
- vendor/bin/phpcs app/code/Vendor/Module --standard=Magento2
Advanced Testing Patterns
Testing Private Methods (via Reflection)
public function testPrivateMethodLogic(): void
{
$reflection = new \ReflectionClass(OrderProcessor::class);
$method = $reflection->getMethod('calculateDiscount');
$method->setAccessible(true);
$orderProcessor = new OrderProcessor(/* dependencies */);
$result = $method->invokeArgs($orderProcessor, [100.00, 0.15]);
$this->assertEquals(15.00, $result);
}
Note: Prefer refactoring private methods to separate classes with public interfaces for better testability.
Testing Event Dispatching
public function testEventDispatched(): void
{
$eventManagerMock = $this->createMock(\Magento\Framework\Event\ManagerInterface::class);
$eventManagerMock->expects($this->once())
->method('dispatch')
->with(
'vendor_module_order_processed',
$this->callback(function ($data) {
return isset($data['order']) && $data['order'] instanceof OrderInterface;
})
);
$orderProcessor = new OrderProcessor($eventManagerMock, /* other dependencies */);
$orderProcessor->process(123);
}
Mutation Testing with Infection
composer require --dev infection/infection
vendor/bin/infection --threads=4 --min-msi=80
Configuration: infection.json.dist
{
"source": {
"directories": [
"app/code/Vendor/Module"
],
"excludes": [
"Test"
]
},
"logs": {
"text": "var/infection.log"
},
"mutators": {
"@default": true
}
}
Best Practices
Test Naming Conventions
// Pattern: test[MethodName][Scenario][ExpectedResult]
public function testProcessOrderWithValidDataReturnsTrue(): void
public function testProcessOrderWithInvalidOrderIdThrowsException(): void
public function testGetOrderStatusForPendingOrderReturnsPending(): void
Arrange-Act-Assert Pattern
public function testExample(): void
{
// Arrange: Set up test data and mocks
$orderId = 123;
$this->orderRepositoryMock->method('get')->willReturn($orderMock);
// Act: Execute the method under test
$result = $this->orderProcessor->process($orderId);
// Assert: Verify expected outcome
$this->assertTrue($result);
}
Test Isolation
// BAD: Tests depend on execution order
public function testA(): void { self::$sharedState = 'value'; }
public function testB(): void { $this->assertEquals('value', self::$sharedState); }
// GOOD: Each test is independent
public function testA(): void { /* isolated */ }
public function testB(): void { /* isolated */ }
Avoid Test Logic
// BAD: Conditional logic in tests
if ($condition) {
$this->assertTrue($result);
} else {
$this->assertFalse($result);
}
// GOOD: Explicit assertions
$this->assertTrue($result);
Assumptions
- Magento version: 2.4.7+ (PHPUnit 9.x, MFTF 3.x)
- PHP version: 8.2+ (typed properties, readonly)
- Test frameworks: PHPUnit 9.5+, MFTF 3.7+
- CI/CD: GitHub Actions or GitLab CI
- Services: MySQL 8.0, Elasticsearch 7.17
Why This Approach
Test pyramid distribution (60/30/10): - Unit tests are fast and catch logic errors early - Integration tests verify framework interactions - MFTF tests validate critical user paths without over-investment in slow E2E tests
PHPUnit mocking over ObjectManager: - Unit tests run in <100ms vs 5-10s with ObjectManager - True isolation prevents cascading failures - Forces proper dependency injection design
API functional tests for endpoints: - Tests service contracts as external consumers use them - Validates serialization, authorization, error responses - Catches breaking changes in API contracts
MFTF for user journeys: - Cross-browser testing (Chrome, Firefox) - Validates frontend-backend integration - Smoke tests for deployment confidence
Security Impact
- Test data: Use anonymized/synthetic PII in fixtures; never production data
- API tests: Validate authentication, authorization, rate limiting
- MFTF: Test CSRF token handling, XSS prevention in admin forms
- Secrets: Use environment variables for CI credentials; never commit to VCS
- Test isolation: Database rollback prevents data leakage between tests
Performance Impact
- Unit tests: ~50ms per test; 1000 tests in <1 minute
- Integration tests: ~500ms per test; database overhead
- MFTF: ~30s per test; browser automation + page loads
- CI runtime: Unit (2min), Integration (10min), MFTF (30min)
- Coverage reports: Add 20-30% to test runtime (Xdebug overhead)
Backward Compatibility
- PHPUnit: 9.x across Magento 2.4.x; migration to PHPUnit 10 in 2.5
- MFTF: 3.x stable; selector changes may break tests on Magento core updates
- Test attributes: PHP 8.2+ attributes preferred over annotations (future-proof)
- Fixtures: Backward compatible across 2.4.x if using entity builders
- API tests: Version endpoints (
/V1/,/V2/) to maintain BC
Tests to Add
Unit tests: - All public methods in service classes - Validation logic - Exception handling paths - Data transformation logic
Integration tests: - Repository operations (CRUD) - Plugin execution order - Observer registration and execution - DI configuration correctness
API functional tests: - All REST/GraphQL endpoints - Authentication/authorization flows - Error responses (400, 401, 404, 500) - Input validation
MFTF tests: - Critical checkout flows - Admin order management - Customer account operations - Payment gateway integration
Documentation to Update
- README: Add test execution instructions (
composer test,composer test:unit) - CONTRIBUTING: Test requirements for PRs (70% unit coverage minimum)
- CI/CD docs: Pipeline configuration and troubleshooting
- Test fixtures: Document available fixtures and how to create custom ones
- CHANGELOG: Note breaking changes in test infrastructure
- Screenshots: CI badge, coverage report example
Additional Resources
- Magento DevDocs: Testing Guide
- PHPUnit Documentation
- MFTF Developer Guide
- Infection Mutation Testing
- GitHub Actions for PHP
Related Documentation
Related Guides
- Docker Development Environment Setup for Magento 2
- CI/CD Deployment Pipelines for Magento 2
- Plugin System Deep Dive: Mastering Magento 2 Interception