Magento 2 7/7/2024

Improved Fixtures for Magento 2 Integration Tests

If you write integration tests for Magento 2, you probably have used fixtures. If you did not, have a look at our previous blog post for a quick overview: Integration Tests with Magento 2

If you write core code, you will obviously reuse the existing fixtures of the core test suite as much as possible. To some extend this might also work for extensions, but you will often need more specific test data or the core fixtures do not even work. For example, if you added required customer attributes, the customer fixtures do not work because save() triggers a validation exception.

Long story short, you will write your own fixtures and so you are not bound to the format of the core. That format of procedural files can easily lead to duplicated spaghetti code or undesired dependencies between modules.

Fixtures can as well be organized in nice OOP code. A common pattern for test data is the "test data builder" pattern (Read more here: https://davedevelopment.co.uk/2015/01/28/test-data-builders.html) In a B2B project with lots of custom customer attributes, I started writing builders that can be used like this:

public static function loadFixture()
{
    CustomerBuilder::aCustomer()
        ->withEmail('test@example.com')
        ->withCustomAttribute('foo', 'bar')
        ->build();
}

The aCustomer() constructor initializes the builder with default data. So if I just need any customer, this is already enough. In the fixture, I only specify data that is relevant to the current test, which makes it much clearer.

Often you will need the ID of entities created in the fixture to reference it in the test. The core fixtures explicitly use setId() to create entities with specific IDs. But this is only possible with some tricks and can easily lead to conflicts.

I find it much more pleasant to work with the normal methods to create entities and then check which IDs were assigned. Since the loadFixture method is executed before the test case is instantiated and must be static, you need to store the ID in a static property:

public static function loadFixture()
{
    self::$customerId = CustomerBuilder::aCustomer()
        ->build()->getId();
}

If you need more than just one ID, it might also be useful to encapsulate the data in a fixture object. For example, I have a CustomerFixture class that gives easy access to the ID, the email, the shipping address ID and the billing address ID of a customer, created with the CustomerBuilder. The fixture loader then can look like this:

public static function loadFixture()
{
    self::$customer = new CustomerFixture(
        CustomerBuilder::aCustomer()->build()
    );
}

If you need the same fixture for all methods in the test case, you can even get rid of the "magentoDataFixture" annotation and the fixture loader. Instead, use the regular setUp and tearDown methods of PHPUnit:

protected function setUp()
{
    $this->customer = new CustomerFixture(
        CustomerBuilder::aCustomer()->build()
    );
}

TddWizard Fixture Library

I open sourced these fixture classes at https://github.com/tddwizard/magento2-fixtures

The library aims to be:

  • extensible
  • expressive
  • easy to use

It contains some more common test setup code, like checkout simulation to create a real order. This is not as easy as it should be. For example, you have to call certain methods in the right order and reload the quote before submitting. Alternatively you could try to hand craft all the Ajax requests during checkout, which is complicated as well and also slower.

I figured it out once and added it to the fixture library, so you don't have to.

Installation

Install it into your Magento 2 project with composer:
composer require --dev tddwizard/magento2-fixtures

Usage examples:

Customer

If you need a customer without specific data, this is all:
protected function setUp()
{
    $this->customerFixture = new CustomerFixture(
        CustomerBuilder::aCustomer()->build()
    );
}
protected function tearDown()
{
    CustomerFixtureRollback::create()->execute($this->customerFixture);
}

It uses default sample data and a random email address. If you need the ID or email address in the tests, the CustomerFixture gives you access:

$this->customerFixture->getId();
$this->customerFixture->getEmail();

You can configure the builder with attributes:

CustomerBuilder::aCustomer()
    ->withEmail('test@example.com')
    ->withCustomAttributes(
        [
            'my_custom_attribute' => 42
        ]
    )
    ->build()

You can add addresses to the customer by passing address builders:

CustomerBuilder::aCustomer()
    ->withAddresses(
        AddressBuilder::anAddress()->asDefaultBilling(),
        AddressBuilder::anAddress()->asDefaultShipping(),
        AddressBuilder::anAddress()
    )
    ->build()

Or just one:

CustomerBuilder::aCustomer()
    ->withAddresses(
        AddressBuilder::anAddress()->asDefaultBilling()->asDefaultShipping()
    )
    ->build()

The CustomerFixture also has a shortcut to create a customer session:

$this->customerFixture->login();

Addresses

Similar to the customer builder you can also configure the address builder with custom attributes:
AddressBuilder::anAddress()
    ->withCountryId('DE')
    ->withCity('Aachen')
    ->withPostcode('52078')
    ->withCustomAttributes(
        [
            'my_custom_attribute' => 42
        ]
    )
    ->asDefaultShipping()

Product

Product fixtures work similarily to customer fixtures:
protected function setUp()
{
    $this->productFixture = new ProductFixture(
        ProductBuilder::aSimpleProduct()
            ->withPrice(10)
            ->withCustomAttributes(
                [
                    'my_custom_attribute' => 42
                ]
            )
            ->build()
    );
}
protected function tearDown()
{
    ProductFixtureRollback::create()->execute($this->productFixture);
}

The SKU is randomly generated and can be accessed through ProductFixture, just like the ID:

$this->productFixture->getSku();
$this->productFixture->getId();

Cart/Checkout

To create a quote, use the CartBuilder together with product fixtures:
$cart = CartBuilder::forCurrentSession()
    ->withSimpleProduct(
        $productFixture1->getSku()
    )
    ->withSimpleProduct(
        $productFixture2->getSku(), 10 // optional qty parameter
    )
    ->build()
$quote = $cart->getQuote();

Checkout is supported for logged in customers. To create an order, you can simulate the checkout as follows, given a customer fixture with default shipping and billing addresses and a product fixture:

$this->customerFixture->login();
$checkout = CustomerCheckout::fromCart(
    CartBuilder::forCurrentSession()
        ->withSimpleProduct(
            $productFixture->getSku()
        )
        ->build()
);
$order = $checkout->placeOrder();

It will try to select the default addresses and the first available shipping and payment methods.

You can also select them explicitly:

$order = $checkout
  ->withShippingMethodCode('freeshipping_freeshipping')
  ->withPaymentMethodCode('checkmo')
  ->withCustomerBillingAddressId($this->customerFixture->getOtherAddressId())
  ->withCustomerShippingAddressId($this->customerFixture->getOtherAddressId())
  ->placeOrder();

Where is this going?

Currently, the library only covers what I needed in the project where it orginates; new features are added slowly over time. It is still in alpha stability, so that I still can improve the API without worrying too much about backwards compatibility. But after it grows more complete, the goal is to have a stable library that is useful for everybody. Of course, contributions are welcome to get there faster!

Check it out at GitHub