Magento 2 9/1/2020

An approach for integration testing the Magento 2 checkout

The checkout is usually one of the most heavily adjusted parts of an online store. Besides the visual appearance and the UI, adjustments typically cover some backend parts too. For example: you adjust the e-mails which are being sent at checkout success, or you send some custom data to a message queue or put it into the database.
As this is a highly critical part of every online store, it makes sense to cover the checkout with automated tests. For the frontend, you may use MFTF (Magento Functional Testing Framework) or cypress.io. This blog posts is about the backend part of the checkout, the PHP part which handles all the data comingh through. We use Integration Tests for testing this backend part of the checkout.

Some background information about Integration Tests in Magento 2

There are several layers of automated tests in Magento 2. Let's list the most important ones here:

  1. Unit Tests - they test the behaviour of a single component (i.e. a class) without external dependencies.
  2. Integration Tests - they test how different components, including framework and resources like databases, work together.
  3. MFTF (Frontend Tests) - they test how the frontend behaves.

Magento 2 comes with a full integration testsuite which covers all functionalities. It is based on a testing framework, which is again based on PhpUnit. This is also a good foundation for writing your own tests. Please read my Introduction to Integration Testing including a setup guide and some small example tests if you want to know more about Magento 2 integration tests.

The approach to testing the checkout

There are two parts we want to test for:

  1. Assert that the checkout works and isn't blocked on the server side
  2. Assert that the resulting order contains the data we expect

The Magento 2 checkout is built with Javascript on the frontend, communicating with the Magento 2 server via REST APIs. As we don't do Javascript frontend tests, we directly emulate the API requests and call the underlying PHP methods.

In this blog post, we cannot cover all possible checkout options in Magento 2. To simplify the examples, we are using the Guest Checkout. Depending on your requirements, you should add additional tests covering the checkout for logged in customers and possibly different products, shipping methods, payment methods and other checkout options.

1. Preparing the API classes

At the beginning of the test class, we initialize the needed objects:
<?php

declare(strict_types=1);

namespace IntegerNet\CheckoutTest\Test\Integration;

use Magento\Checkout\Api\Data\ShippingInformationInterface; use Magento\Checkout\Api\GuestShippingInformationManagementInterface; use Magento\Framework\ObjectManagerInterface; use Magento\Quote\Api\Data\AddressInterface as QuoteAddressInterface; use Magento\Quote\Api\Data\CartItemInterface; use Magento\Quote\Api\Data\PaymentInterface; use Magento\Quote\Api\GuestCartItemRepositoryInterface; use Magento\Quote\Api\GuestCartManagementInterface; use Magento\Quote\Api\PaymentMethodManagementInterface; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase;

class CheckoutTest extends TestCase { /** * @var ObjectManagerInterface / private $objectManager; /* * @var GuestCartManagementInterface / private $guestCartManagement; /* * @var GuestCartItemRepositoryInterface / private $guestCartItemRepository; /* * @var GuestShippingInformationManagementInterface / private $guestShippingInformationManagement; /* * @var PaymentMethodManagementInterface */ private $paymentMethodManagement;

protected function setUp()
{
    parent::setUp();

    $this-&gt;objectManager = Bootstrap::getObjectManager();

    $this-&gt;guestCartManagement = $this-&gt;objectManager-&gt;get(GuestCartManagementInterface::class);

    $this-&gt;guestCartItemRepository = $this-&gt;objectManager-&gt;get(GuestCartItemRepositoryInterface::class);

    $this-&gt;guestShippingInformationManagement = $this-&gt;objectManager-&gt;get(GuestShippingInformationManagementInterface::class);

    $this-&gt;paymentMethodManagement = $this-&gt;objectManager-&gt;get(PaymentMethodManagementInterface::class);
}

}

2. Creating fixtures

The integration tests need some data in the database. In our case, it's only one product which can be put into the shopping cart. In other cases, the fixtures can also contain customers or more products of different types.

For more simplicity, we use my colleague Fabian's Fixture Framework and add the following code to the setUp method:

<?php
[...]
use TddWizard\Fixtures\Catalog\ProductBuilder;
use TddWizard\Fixtures\Catalog\ProductFixture;

class CheckoutTest extends TestCase { /** * @var ProductFixture */ private $productFixture;

[...]

protected function setUp()
{
    parent::setUp();

    $this-&gt;productFixture = new ProductFixture(
        ProductBuilder::aSimpleProduct()
            -&gt;withPrice(10)
            -&gt;build()
    );
    [...]

3. Create a new empty cart

We now start to emulate the different checkout steps. The first step is to create an empty cart (a.k.a. quote).

The method we use will return a cart ID, which is a random string. This cart ID will be used throughout the other API methods in order to identify the cart.

The public method "testCreateOrder" we create is the method which will be executed by the test framework in the end. It will be extended in the upcoming steps.

<?php
public function testCreateOrder()
{
    $cartId = $this-&gt;getNewCartId();
}

private function getNewCartId(): string
{
    return $this-&gt;guestCartManagement-&gt;createEmptyCart();
}

4. Add a product to the cart

We look up the SKU of the product which we have created as a fixture and add a quantity of two of this product to the cart.

<?php
public function testCreateOrder() 
{ 
    $cartId = $this-&gt;getNewCartId(); 
    $this-&gt;addProductToCart($cartId, $this-&gt;productFixture-&gt;getSku(), 2); 
} 

private function addProductToCart(string $cartId, string $productSku, int $qty) 
{ 
    /** @var CartItemInterface $cartItem */ 
    $cartItem = $this-&gt;objectManager-&gt;create(CartItemInterface::class); 
    $cartItem-&gt;setSku($productSku); 
    $cartItem-&gt;setQty($qty); 
    $cartItem-&gt;setQuoteId($cartId); 
    $this-&gt;guestCartItemRepository-&gt;save($cartItem); 
} 

5. Set addresses and shipping method

We need to add a billing address, a shipping address, and a shipping method. We choose "freeshipping" here as it is the simplest option.

<?php
public function testCreateOrder()
{
    [...]
    $this-&gt;assignShippingInformation($cartId);
}

private function assignShippingInformation(string $cartId)
{
    /** @var ShippingInformationInterface $shippingInformation */
    $shippingInformation = $this-&gt;objectManager-&gt;create(ShippingInformationInterface::class);
    $shippingInformation-&gt;setShippingMethodCode('freeshipping');
    $shippingInformation-&gt;setShippingCarrierCode('freeshipping');
    $shippingInformation-&gt;setShippingAddress($this-&gt;getShippingAddress());
    $shippingInformation-&gt;setBillingAddress($this-&gt;getBillingAddress());

    $this-&gt;guestShippingInformationManagement-&gt;saveAddressInformation($cartId, $shippingInformation);
}

private function getShippingAddress(): QuoteAddressInterface
{
    /** @var QuoteAddressInterface $shippingAddress */
    $shippingAddress = $this-&gt;objectManager-&gt;create(QuoteAddressInterface::class);
    $shippingAddress-&gt;setEmail('shipping@invalid.com');
    $shippingAddress-&gt;setTelephone('+12 345 6789 0');
    $shippingAddress-&gt;setFirstname('Firstname Shipping');
    $shippingAddress-&gt;setLastname('Lastname Shipping');
    $shippingAddress-&gt;setCompany('Company Shipping');
    $shippingAddress-&gt;setStreet('Street Shipping');
    $shippingAddress-&gt;setPostcode('12345');
    $shippingAddress-&gt;setCity('City Shipping');
    $shippingAddress-&gt;setCountryId('DE');

    return $shippingAddress;
}

private function getBillingAddress(): QuoteAddressInterface
{
    /** @var QuoteAddressInterface $billingAddress */
    $billingAddress = $this-&gt;objectManager-&gt;create(QuoteAddressInterface::class);
    $billingAddress-&gt;setEmail('billing@invalid.com');
    $billingAddress-&gt;setTelephone('+12 345 6789 0');
    $billingAddress-&gt;setFirstname('Firstname Billing');
    $billingAddress-&gt;setLastname('Lastname Billing');
    $billingAddress-&gt;setCompany('Company Billing');
    $billingAddress-&gt;setStreet('Street Billing');
    $billingAddress-&gt;setPostcode('12345');
    $billingAddress-&gt;setCity('City Billing');
    $billingAddress-&gt;setCountryId('DE');

    return $billingAddress;
}

6. Set payment information

We choose the "checkmo" payment method because of simplicity. It doesn't require any more information than the method code.

<?php
public function testCreateOrder()
{
    $cartId = $this-&gt;getNewCartId();

    $this-&gt;addProductToCart($cartId, $this-&gt;productFixture-&gt;getSku(), 2);

    $this-&gt;assignShippingInformation($cartId);

    $payment = $this-&gt;getPayment('checkmo');
}

private function getPayment(string $paymentMethodCode): PaymentInterface
{
    /** @var PaymentInterface $payment */
    $payment = $this-&gt;objectManager-&gt;create(PaymentInterface::class);
    $payment-&gt;setMethod($paymentMethodCode);

    return $payment;
}

7. Place order

We need the cart ID and the payment object from the previous step here. After executing the "placeOrder" method, we will have an order in our database. We do a simple check if the order creation has succeeding by asserting that the order ID is a number greater than or equal to 1.

<?php
public function testCreateOrder()
{
    [...]

    $orderId = (int)$this-&gt;guestCartManagement-&gt;placeOrder($cartId, $payment);

    $this-&gt;assertGreaterThanOrEqual(1, $orderId);
}

Checking the order data

We don't only want to assert that the order has been created, but also that its data is correct. Here is a simple example of how to check that the grand total has been collected correctly:

<?php
public function testExportOrder()
{
    [...]
    $this-&gt;assertEquals(20, $this-&gt;getOrder($orderId)-&gt;getGrandTotal());
}

private function getOrder(int $orderId): OrderInterface
{
    /** @var OrderRepositoryInterface $orderRepository */
    $orderRepository = $this-&gt;objectManager-&gt;get(OrderRepositoryInterface::class);
    return $orderRepository-&gt;get($orderId);
}

Full example

After we have explained the individual steps in detail above, you can find below the complete example:

declare(strict_types=1);

namespace IntegerNet\CheckoutTest\Test\Integration;

use Magento\Checkout\Api\Data\ShippingInformationInterface; use Magento\Checkout\Api\GuestShippingInformationManagementInterface; use Magento\Framework\ObjectManagerInterface; use Magento\Quote\Api\Data\AddressInterface as QuoteAddressInterface; use Magento\Quote\Api\Data\CartItemInterface; use Magento\Quote\Api\Data\PaymentInterface; use Magento\Quote\Api\GuestCartItemRepositoryInterface; use Magento\Quote\Api\GuestCartManagementInterface; use Magento\Quote\Api\PaymentMethodManagementInterface; use Magento\Sales\Api\Data\OrderInterface; use Magento\Sales\Api\OrderRepositoryInterface; use Magento\TestFramework\Helper\Bootstrap; use PHPUnit\Framework\TestCase; use TddWizard\Fixtures\Catalog\ProductBuilder; use TddWizard\Fixtures\Catalog\ProductFixture;

class CheckoutTest extends TestCase { /** * @var ProductFixture / private $productFixture; /* * @var ObjectManagerInterface / private $objectManager; /* * @var GuestCartManagementInterface / private $guestCartManagement; /* * @var GuestCartItemRepositoryInterface / private $guestCartItemRepository; /* * @var GuestShippingInformationManagementInterface / private $guestShippingInformationManagement; /* * @var PaymentMethodManagementInterface */ private $paymentMethodManagement;

protected function setUp()
{
    parent::setUp();

    $this->productFixture = new ProductFixture(
        ProductBuilder::aSimpleProduct()
            ->withPrice(10)
            ->build()
    );

    $this->objectManager = Bootstrap::getObjectManager();

    $this->guestCartManagement = $this->objectManager->get(GuestCartManagementInterface::class);

    $this->guestCartItemRepository = $this->objectManager->get(GuestCartItemRepositoryInterface::class);

    $this->guestShippingInformationManagement = $this->objectManager->get(GuestShippingInformationManagementInterface::class);

    $this->paymentMethodManagement = $this->objectManager->get(PaymentMethodManagementInterface::class);
}

public function testCreateOrder()
{
    $cartId = $this->getNewCartId();

    $this->addProductToCart($cartId, $this->productFixture->getSku(), 2);

    $this->assignShippingInformation($cartId);

    $payment = $this->getPayment('checkmo');

    $orderId = (int)$this->guestCartManagement->placeOrder($cartId, $payment);

    $this->assertGreaterThanOrEqual(1, $orderId);
    $this->assertEquals(20, $this->getOrder($orderId)->getGrandTotal());
}

private function getNewCartId(): string
{
    return $this->guestCartManagement->createEmptyCart();
}

private function addProductToCart(string $cartId, string $productSku, int $qty)
{
    /** @var CartItemInterface $cartItem */
    $cartItem = $this->objectManager->create(CartItemInterface::class);
    $cartItem->setSku($productSku);
    $cartItem->setQty($qty);
    $cartItem->setQuoteId($cartId);
    $this->guestCartItemRepository->save($cartItem);
}

private function assignShippingInformation(string $cartId)
{
    /** @var ShippingInformationInterface $shippingInformation */
    $shippingInformation = $this->objectManager->create(ShippingInformationInterface::class);
    $shippingInformation->setShippingMethodCode('freeshipping');
    $shippingInformation->setShippingCarrierCode('freeshipping');
    $shippingInformation->setShippingAddress($this->getShippingAddress());
    $shippingInformation->setBillingAddress($this->getBillingAddress());

    $this->guestShippingInformationManagement->saveAddressInformation($cartId, $shippingInformation);
}

private function getShippingAddress(): QuoteAddressInterface
{
    /** @var QuoteAddressInterface $shippingAddress */
    $shippingAddress = $this->objectManager->create(QuoteAddressInterface::class);
    $shippingAddress->setEmail('shipping@invalid.com');
    $shippingAddress->setTelephone('+12 345 6789 0');
    $shippingAddress->setFirstname('Firstname Shipping');
    $shippingAddress->setLastname('Lastname Shipping');
    $shippingAddress->setCompany('Company Shipping');
    $shippingAddress->setStreet('Street Shipping');
    $shippingAddress->setPostcode('12345');
    $shippingAddress->setCity('City Shipping');
    $shippingAddress->setCountryId('DE');

    return $shippingAddress;
}

private function getBillingAddress(): QuoteAddressInterface
{
    /** @var QuoteAddressInterface $billingAddress */
    $billingAddress = $this->objectManager->create(QuoteAddressInterface::class);
    $billingAddress->setEmail('billing@invalid.com');
    $billingAddress->setTelephone('+12 345 6789 0');
    $billingAddress->setFirstname('Firstname Billing');
    $billingAddress->setLastname('Lastname Billing');
    $billingAddress->setCompany('Company Billing');
    $billingAddress->setStreet('Street Billing');
    $billingAddress->setPostcode('12345');
    $billingAddress->setCity('City Billing');
    $billingAddress->setCountryId('DE');

    return $billingAddress;
}

private function getPayment(string $paymentMethodCode): PaymentInterface
{
    /** @var PaymentInterface $payment */
    $payment = $this->objectManager->create(PaymentInterface::class);
    $payment->setMethod($paymentMethodCode);

    return $payment;
}

private function getOrder(int $orderId): OrderInterface
{
    /** @var OrderRepositoryInterface $orderRepository */
    $orderRepository = $this->objectManager->get(OrderRepositoryInterface::class);
    return $orderRepository->get($orderId);
}

}

Advanced: Check if a method has been called and its parameters

In our use case, we were exporting order data to a message queue. As we don't want to access a RabbitMQ server directly in our integration tests, we are just checking if the method to write the order data to the queue is being called, and that the order data is correct. Thus, we added this part to the "testCreateOrder" method before the "placeOrder" call.

Please note that you need to tell the object manager to use the mock instead of the original class before instantiation, i.e. during setup. That's what the "addSharedInstance" call is for.

protected function setUp()
{
    [...]
    $this->publisher = $this->createMock(Publisher::class);
    $this->objectManager->addSharedInstance($this->publisher, Publisher::class);
}

[...]

$this->publisher->expects($this->exactly(1))
    ->method('execute')
    ->with($this->callback(
        function (OrderInterface $order) { // Our custom order data interface
            $this->assertEquals('Company Shipping', $order->getShippingAddressCompany());
            $this->assertEquals('Street Shipping', $order->getShippingAddressStreet());
            $this->assertEquals('DE', $order->getShippingAddressCountryC());
            $this->assertEquals('Company Billing', $order->getAddressCompanyInvRecC());
            $this->assertEquals('Street Billing', $order->getAddressStreetInvRecC());
            $this->assertEquals('DE', $order->getAddressCountryInvRecC());
            $this->assertEquals(400, $order->getBillingTypeC());
            $this->assertGreaterThan(8, strlen($order->getOrderNumber()));
            $this->assertEquals(1898, $order->getSumPrice());
            $this->assertEquals(360.62, $order->getSumTaxesC());
            $this->assertEquals(0, $order->getSumDiscountsC());
            $this->assertEquals(date('Y-m-d'), $order->getOrderDateC());

            return true;
        }
    ));
    
$this->guestCartManagement->placeOrder($cartId, $payment);

Alternative: create the full order as a fixture

This example implements all the different steps of checkout. In many cases, it may be enough to just generate an order as a fixture and test how the created order behaves after checkout.

The latest version TddWizard_Fixtures supports just that: create an order as a fixture.

And now I hope you can put this information on integration tests to good use in your Magento project.

About integer_net

integer_net is one of the leading service providers for developing Magento shops, based in Aachen, Germany. Our team holds the world-wide record with 7 "Magento Master" awards sind 2017. We are an official Magento solution partner with a strong focus on quality and reliability. Since 2012, we successfully create online stores and support them in the long term.

You can find more information about integer_net on our team page.