Skip to main content
🍞
Dev Corner
End-to-End (E2E) Testing Checkout Flow

End-to-End (E2E) Testing Checkout Flow

Running tests against the checkout flow can help you discover issues at an early stage and ensure that clients can always complete their orders. Happy customers, happy business!

End-to-End (E2E) Testing Checkout Flow

The checkout flow may be the last part of the customer journey before completing an order, but it is the most important one. The journey starts by adding an item to the basket, filling in customer details, accepting some form of payment, making an order, and receiving a confirmation. Adding an item to the basket is not necessarily part of the checkout, but it is a requirement so that we can get on it.

Overall, the checkout flow touches different parts of the e-commerce process, and thus it is very complicated and error-prone. To ensure it always works, a thorough end-to-end test will ensure these different parts are glued together and work as intended.

Every checkout implementation will be different, which is why there is no single universal rule that is always going to work. You need to adjust your e2e test to match the implementation. This article will look at best practices for writing e2e tests and the steps to follow when creating checkout flow tests.

Here is a video of a checkout flow e2e test I will use for this article. The test is part of the latest Crystallize Remix boilerplate; you can find the code here. The test framework we use is Playwright, as we love it!

E2E Tests Best Practices

Below is a list of a few best practices that will help you write robust e2e tests focusing on a single flow.

  • Test like a real user - write your tests in a way that resembles a real user navigating through the app.  
  • Avoid mocking API calls and responses - or keep them to minimum.
  • Use `data-testid` to select elements in the DOM and avoid selecting them by text. 
  • Keep the focus on one flow only. Only test one flow at a different time, like login and checkout, since both are complicated and may trigger many different outcomes. 
  • Always clean up at the end of the test. The e2e tests work with actual databases; you want to keep them from populating them with test data.

Now that best practices are in check, let’s put them to use in a real-case test for a checkout flow.

Checkout Requirements

Before writing any e2e test, we have to prepare a list of tasks we want to include in the test. For this checkout flow test, we need to cover the following:

  • Add an item to the basket,
  • Add user details,
  • Handle payment,
  • Confirm the order is successful in the UI,
  • Confirm order exists in the API,
  • Cleanup order from the API.

By ensuring that a user can go through the steps above, we can guarantee that the user can make an order. Let's now see how these steps can be implemented.

Add an Item to the Basket

Before going to the checkout flow, we need to ensure we can add an item to the basket. Here we should be very generic in picking the item since the content in our ecommerce may change, which could affect the test. Also, do not use hardcoded links to an item since the product may no longer exist, and thus the URL may be gone.

So let's select an item from the current page and navigate to it, where we can then add it to the basket. In our case, all product items with a link have a data-testid=”product-link,” which we can use to navigate to the item.

 // Navigate to the first product on the home page
 await page.getByTestId('product-link').first().click();


// Add the product to the cart and navigate to the cart
await page.getByTestId('add-to-cart-button');
await page.getByTestId('go-to-cart-button');

As you can see here, we select the first product from the list of products since we don’t know how many of them are on the current page. Once on the item page, we can use the “Add to cart” button to add the item to the basket and navigate to it.

Add User Details

In this step, we need to gather all the necessary information about the user. If looking at a test with an existing user, we need to check that user is logged in. If our test case is with a new user, we must fill in all the required fields. For this demo, I decided to go with the case where we must provide all the user information.

 // Fill in the form
await page.type('input[name=firstname]', customer.firstname);
await page.type('input[name=lastname]', customer.lastname);
await page.type('input[name=email]', customer.email);
...

Handle Payment

This is the most complicated step because it often involves third parties handling the payment. If that is your case, check with the payment provider you are working with on how to handle test payments. For example, Stripe has excellent documentation on testing.

Here you may also need to fallback to listening for a specific payment request and mocking the result to continue your test. At the end of the day, it is the responsibility of the third party to ensure their API works when you send a specific payload. 

For our test case, we decided to ensure we could place an order against the Crystallize API, for which we chose to use the Crystal coin payment method. Clicking on the Next button and selecting the Crystal coin payment option is enough for this step.

// Navigate to next step - payment
await page.getByTestId('checkout-next-step-button').click();


// Select the Crystal coin payment method
await page.getByTestId('crystal-coin-payment-button').click();

Confirm Order Is Successful in the UI

Once the order is successfully handled on the API side, we receive a confirmation in the UI. In this step, we often need to confirm that by giving the user some visual clue that everything went as expected. These clues can be used to ensure a successful operation.

// Confirm the order is placed
await page.getByTestId('order-placed').waitFor({ state: 'visible' });


// Get the order id
const orderId = await page.getByTestId('guest-order-id').textContent();

Confirm Order Exists in the API

In this step, we want to confirm that the order does exist in the API. The previous step gives us a good level of confidence about it, but it is also likely that the backend processed the order while the order never made it to the database.

Since there is no indication of when the order will be available in the API, we need to make a few requests until it is available. For that, a loop can be used where we wait a certain number of milliseconds before making a new request.

// Wait until the order appears in the API
while (retryCount < MAX_RETRY_COUNT) {
  retryCount += 1;
  // Wait 300 ms before making a new attempt to get the order
  await new Promise((r) => setTimeout(r, 300));


  // Fetch the order from the API
  const orderRes = await apiContext.post('/graphql', { data });
  expect(orderRes.ok()).toBeTruthy();
...

Once the request to get the order succeeds, we can extract the payment information and compare it with our local data to ensure the order is handled correctly.

Cleanup Order From the API

This is one of the most important steps that often gets neglected. Here we want to remove all the data we created during the test, so we do not pollute the database. For that, we can use the `afterAll` method of the `test` property, which should run after all tests - even if they fail.

test.afterAll(async ({}) => {
   // Clean up order
   !!orderId && (await apiContext.post('/graphql', { data) }));
   // Dispose all responses
   await apiContext.dispose();
});

Final Words

The checkout flow is one of the most critical elements for every eCommerce store, so it is your responsibility to ensure it always works. Adding even a single end-2-end test will give you the confidence to work with it and improve it without any fear that it may break.

The test case implementation will always be different. Still, as long as we guarantee that one can add an item to the basket, fill in user details, complete payment, and get an order confirmation, we know that our checkout flow works.