Magento 2 Testing: Integration Tests, MFTF, and Functional Testing
Magento 2 has a built-in test framework covering unit tests, integration tests, and functional tests via MFTF (Magento Functional Testing Framework). This guide covers each layer with practical examples — from isolated PHP unit tests to full browser checkout flows.
Magento 2 Test Architecture
Magento 2 ships with four test types:
| Type | Location | Runs against |
|---|---|---|
| Unit | Test/Unit/ |
Isolated code, no Magento bootstrap |
| Integration | Test/Integration/ |
Real Magento with test database |
| API Functional | dev/tests/api-functional/ |
REST/GraphQL API |
| MFTF (Functional) | dev/tests/acceptance/ |
Real browser via WebDriver |
Most custom development needs unit + integration tests. MFTF is for validating complete user workflows.
Setup
Prerequisites
# Magento 2 test dependencies
composer install
<span class="hljs-comment"># Integration test database
bin/magento setup:install \
--db-name=magento_test \
--db-user=root \
--db-password=root
<span class="hljs-comment"># MFTF
composer require magento/magento2-functional-testing-framework --dev
bin/mftf build:projectRunning Tests
# Unit tests
./vendor/bin/phpunit -c dev/tests/unit/phpunit.xml
<span class="hljs-comment"># Integration tests (needs DB)
./vendor/bin/phpunit -c dev/tests/integration/phpunit.xml
<span class="hljs-comment"># MFTF
bin/mftf run:<span class="hljs-built_in">test CheckoutAsGuestTest
<span class="hljs-comment"># All MFTF tests in a suite
bin/mftf run:suite CheckoutSuiteUnit Tests
Magento unit tests use PHPUnit with Magento's TestFramework utilities for mocking the object manager.
Testing a Custom Shipping Calculator
<?php
// Vendor/Module/Model/CustomShipping.php
namespace Vendor\Module\Model;
use Magento\Framework\App\Config\ScopeConfigInterface;
class CustomShipping {
private ScopeConfigInterface $scopeConfig;
public function __construct(ScopeConfigInterface $scopeConfig) {
$this->scopeConfig = $scopeConfig;
}
public function calculateRate(float $orderWeight): float {
$baseRate = (float) $this->scopeConfig->getValue(
'carriers/custom/base_rate',
\Magento\Store\Model\ScopeInterface::SCOPE_STORE
);
$perKgRate = (float) $this->scopeConfig->getValue(
'carriers/custom/per_kg_rate',
\Magento\Store\Model\ScopeInterface::SCOPE_STORE
);
return $baseRate + ($orderWeight * $perKgRate);
}
}<?php
// Test/Unit/Model/CustomShippingTest.php
namespace Vendor\Module\Test\Unit\Model;
use PHPUnit\Framework\TestCase;
use Vendor\Module\Model\CustomShipping;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Store\Model\ScopeInterface;
class CustomShippingTest extends TestCase {
private ScopeConfigInterface $scopeConfig;
private CustomShipping $model;
protected function setUp(): void {
$this->scopeConfig = $this->createMock(ScopeConfigInterface::class);
$this->model = new CustomShipping($this->scopeConfig);
}
public function testCalculatesRateCorrectly(): void {
$this->scopeConfig
->method('getValue')
->willReturnCallback(function(string $path): float {
return match($path) {
'carriers/custom/base_rate' => 5.00,
'carriers/custom/per_kg_rate' => 2.50,
default => 0.0
};
});
// 5.00 base + (3kg * 2.50) = 12.50
$this->assertEquals(12.50, $this->model->calculateRate(3.0));
}
public function testReturnsBaseRateForZeroWeight(): void {
$this->scopeConfig
->method('getValue')
->willReturnCallback(function(string $path): float {
return match($path) {
'carriers/custom/base_rate' => 5.00,
default => 2.50
};
});
$this->assertEquals(5.00, $this->model->calculateRate(0.0));
}
}Integration Tests
Integration tests run against a real Magento installation with a test database. They're slower but test real behavior.
Testing an Observer
<?php
// Test/Integration/Observer/OrderPlacedObserverTest.php
namespace Vendor\Module\Test\Integration\Observer;
use Magento\TestFramework\Helper\Bootstrap;
class OrderPlacedObserverTest extends \PHPUnit\Framework\TestCase {
protected \Magento\Framework\ObjectManagerInterface $objectManager;
protected function setUp(): void {
$this->objectManager = Bootstrap::getObjectManager();
}
/**
* @magentoDataFixture Magento/Sales/_files/order.php
*/
public function testObserverLogsOrderPlaced(): void {
// Get the order created by the fixture
$orderRepository = $this->objectManager->get(
\Magento\Sales\Api\OrderRepositoryInterface::class
);
// Load fixture order (ID 1)
$order = $orderRepository->get(1);
// Dispatch the event
$eventManager = $this->objectManager->get(
\Magento\Framework\Event\ManagerInterface::class
);
$eventManager->dispatch('sales_order_place_after', ['order' => $order]);
// Verify your observer's effect
$logRepository = $this->objectManager->get(
\Vendor\Module\Api\OrderLogRepositoryInterface::class
);
$logs = $logRepository->getByOrderId($order->getId());
$this->assertCount(1, $logs);
$this->assertEquals('placed', $logs[0]->getStatus());
}
}Data Fixtures
Use @magentoDataFixture annotations to set up test data:
<?php
// Test/Integration/_files/custom_product.php
/** @var \Magento\TestFramework\Helper\Bootstrap $objectManager */
$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
/** @var \Magento\Catalog\Model\Product $product */
$product = $objectManager->create(\Magento\Catalog\Model\Product::class);
$product->setTypeId('simple')
->setAttributeSetId(4)
->setName('Custom Test Product')
->setSku('custom-test-sku-001')
->setPrice(29.99)
->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH)
->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED)
->setStockData(['use_config_manage_stock' => 1, 'qty' => 100, 'is_qty_decimal' => 0, 'is_in_stock' => 1])
->save();// Use in test:
/**
* @magentoDataFixture Vendor/Module/Test/Integration/_files/custom_product.php
*/
public function testCustomProductAppearsInSearch(): void {
$productRepository = $this->objectManager->get(
\Magento\Catalog\Api\ProductRepositoryInterface::class
);
$product = $productRepository->get('custom-test-sku-001');
$this->assertEquals(29.99, $product->getPrice());
$this->assertEquals('Custom Test Product', $product->getName());
}API Functional Tests
Test your Magento REST and GraphQL APIs:
<?php
// dev/tests/api-functional/testsuite/Vendor/Module/CartApiTest.php
namespace Vendor\Module;
use Magento\TestFramework\TestCase\WebapiAbstract;
class CartApiTest extends WebapiAbstract {
public function testCreateGuestCart(): void {
$serviceInfo = [
'rest' => [
'resourcePath' => '/V1/guest-carts',
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST,
],
];
$cartId = $this->_webApiCall($serviceInfo);
$this->assertNotEmpty($cartId);
$this->assertIsString($cartId);
}
public function testAddItemToCart(): void {
// Create guest cart
$cartInfo = ['rest' => ['resourcePath' => '/V1/guest-carts', 'httpMethod' => 'POST']];
$cartId = $this->_webApiCall($cartInfo);
// Add item
$serviceInfo = [
'rest' => [
'resourcePath' => "/V1/guest-carts/{$cartId}/items",
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST,
],
];
$requestData = [
'cartItem' => [
'sku' => 'simple-product',
'qty' => 2,
'quote_id' => $cartId,
]
];
$item = $this->_webApiCall($serviceInfo, $requestData);
$this->assertEquals('simple-product', $item['sku']);
$this->assertEquals(2, $item['qty']);
}
}MFTF: Functional Browser Tests
MFTF uses XML-based test definitions to drive browser automation:
<!-- dev/tests/acceptance/tests/xml/suite/Checkout/CheckoutAsGuestTest.xml -->
<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd">
<test name="CheckoutAsGuestTest">
<annotations>
<features value="Checkout"/>
<title value="Guest can complete checkout"/>
<severity value="CRITICAL"/>
<group value="checkout"/>
</annotations>
<before>
<!-- Create a product via API -->
<createData entity="SimpleProduct" stepKey="createProduct"/>
</before>
<after>
<!-- Cleanup -->
<deleteData createDataKey="createProduct" stepKey="deleteProduct"/>
</after>
<!-- Navigate to product page -->
<amOnPage url="$$createProduct.custom_attributes[url_key]$$.html" stepKey="goToProductPage"/>
<!-- Add to cart -->
<actionGroup ref="AddProductToCartActionGroup" stepKey="addToCart">
<argument name="product" value="$$createProduct$$"/>
<argument name="productCount" value="1"/>
</actionGroup>
<!-- Proceed to checkout -->
<actionGroup ref="GoToCheckoutFromMinicartActionGroup" stepKey="goToCheckout"/>
<!-- Fill guest email -->
<fillField selector="{{GuestCheckoutShippingSection.email}}"
userInput="guest@test.com" stepKey="fillEmail"/>
<!-- Fill shipping address -->
<actionGroup ref="GuestCheckoutFillingShippingSectionActionGroup" stepKey="fillShipping">
<argument name="customerVar" value="Simple_US_Customer"/>
<argument name="customerAddressVar" value="US_Address_TX"/>
</actionGroup>
<!-- Select shipping method -->
<actionGroup ref="CheckoutSelectFlatRateShippingMethodActionGroup" stepKey="selectShipping"/>
<!-- Go to payment -->
<actionGroup ref="StorefrontCheckoutClickNextButtonActionGroup" stepKey="goToPayment"/>
<!-- Place order -->
<actionGroup ref="ClickPlaceOrderActionGroup" stepKey="placeOrder"/>
<!-- Verify confirmation -->
<see selector="{{CheckoutSuccessMainSection.successTitle}}"
userInput="Thank you for your purchase!" stepKey="seeSuccessTitle"/>
<seeElement selector="{{CheckoutSuccessMainSection.orderLink}}" stepKey="seeOrderLink"/>
</test>
</tests>Run it:
bin/mftf run:test CheckoutAsGuestTestCI Integration
name: Magento 2 Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
extensions: mbstring, intl, pdo_mysql
- run: composer install --no-interaction
- name: Run unit tests
run: |
./vendor/bin/phpunit \
-c dev/tests/unit/phpunit.xml \
--testsuite "Vendor_Module"
integration-tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: magento_test
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
extensions: mbstring, intl, pdo_mysql
- run: composer install --no-interaction
- name: Setup Magento test env
run: |
cp dev/tests/integration/etc/install-config-mysql.php.dist \
dev/tests/integration/etc/install-config-mysql.php
- name: Run integration tests
run: |
./vendor/bin/phpunit \
-c dev/tests/integration/phpunit.xml \
--testsuite "Vendor_Module_Integration"Test Performance Tips
Integration tests are slow. Speed them up:
1. Use @magentoDbIsolation enabled — wraps each test in a transaction, rolled back after:
/**
* @magentoDbIsolation enabled
*/
public function testSomething(): void { ... }2. Use @magentoAppIsolation enabled — resets the app between tests (slower but safer):
/**
* @magentoAppIsolation enabled
*/
class MyTest extends TestCase { ... }3. Run only your module's tests — don't run all of Magento's test suite:
./vendor/bin/phpunit -c dev/tests/integration/phpunit.xml \
--filter "Vendor\\Module\\Test\\Integration"4. Use parallel runners — paratest runs PHPUnit tests in parallel:
composer require --dev brianium/paratest
./vendor/bin/paratest -c dev/tests/unit/phpunit.xml -p 4Next Steps
- Start with unit tests — they're fast and don't need a running Magento instance
- Add integration tests for observers and plugins — these are the most error-prone areas
- Run MFTF in staging — not in development (they're slow and require a full install)
- Check e-commerce regression testing for a pre-release checklist
- Monitor with HelpMeTest — schedule checkout flow tests to run every 5 minutes and alert when they fail