Magento 2 Testing: Integration Tests, MFTF, and Functional Testing

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:project

Running 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 CheckoutSuite

Unit 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 CheckoutAsGuestTest

CI 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 runnersparatest runs PHPUnit tests in parallel:

composer require --dev brianium/paratest
./vendor/bin/paratest -c dev/tests/unit/phpunit.xml -p 4

Next 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

Read more