Saleor Testing Guide: Testing Your GraphQL Headless Commerce Backend

Saleor Testing Guide: Testing Your GraphQL Headless Commerce Backend

Saleor is an open-source headless commerce platform built on Django and GraphQL. It powers storefronts for mid-size to enterprise retailers who need full control over their commerce stack. Because Saleor exposes everything through a GraphQL API, testing requires understanding both Django's test tooling and GraphQL-specific patterns.

This guide covers testing Saleor: unit testing custom Django logic, integration testing GraphQL mutations and queries, and E2E testing checkout flows.

Understanding Saleor's Architecture

Saleor is structured as a Django project:

  • Django apps: product, order, checkout, account, payment — each with models, views, and business logic
  • GraphQL API: Built with Graphene-Django; mutations and queries are the primary interface
  • Plugins: Extend Saleor via plugin hooks (payment gateways, tax providers, fulfillment)
  • Celery tasks: Async jobs for order confirmations, webhooks, exports
  • Channels: Multi-channel selling (different currencies, availability per channel)

The key principle: Saleor's test suite itself uses pytest with Django fixtures. Follow the same patterns your customizations will live alongside.

Setting Up the Test Environment

# Install dev dependencies
pip install pytest pytest-django pytest-asyncio factory-boy freezegun

# Configure pytest

pytest.ini:

[pytest]
DJANGO_SETTINGS_MODULE = saleor.settings
python_files = tests/test_*.py
python_classes = Test*
python_functions = test_*

conftest.py:

import pytest
from django.test import RequestFactory
from saleor.account.models import User
from saleor.channel.models import Channel

@pytest.fixture
def rf():
    return RequestFactory()

@pytest.fixture
def channel_USD(db):
    return Channel.objects.create(
        name="Channel USD",
        slug="channel-usd",
        currency_code="USD",
        default_country="US",
        is_active=True,
    )

@pytest.fixture
def customer_user(db):
    return User.objects.create_user(
        email="customer@example.com",
        password="password",
        is_active=True,
    )

Unit Testing Django Logic

Testing Custom Model Methods

# tests/test_product.py
import pytest
from decimal import Decimal
from saleor.product.models import Product, ProductVariant
from saleor.warehouse.models import Stock

@pytest.mark.django_db
def test_variant_is_in_stock(product_variant, warehouse):
    """Variant with positive stock reports as in stock."""
    Stock.objects.create(
        product_variant=product_variant,
        warehouse=warehouse,
        quantity=10,
        quantity_allocated=2,
    )
    assert product_variant.is_available_in_channel(channel_slug="channel-usd")

@pytest.mark.django_db
def test_variant_out_of_stock(product_variant, warehouse):
    """Variant with zero available stock reports as out of stock."""
    Stock.objects.create(
        product_variant=product_variant,
        warehouse=warehouse,
        quantity=5,
        quantity_allocated=5,
    )
    assert not product_variant.is_available_in_channel(channel_slug="channel-usd")

Testing Custom Plugin Hooks

# tests/test_custom_tax_plugin.py
import pytest
from unittest.mock import MagicMock, patch
from mystore.plugins.tax import CustomTaxPlugin

def test_calculate_checkout_line_tax():
    """Custom tax plugin applies correct rate for digital goods."""
    plugin = CustomTaxPlugin(config={"digital_rate": "0.05", "physical_rate": "0.10"})

    checkout_line = MagicMock()
    checkout_line.variant.product.product_type.is_digital = True
    checkout_line.unit_price.gross.amount = Decimal("100.00")

    previous_value = MagicMock()
    result = plugin.calculate_checkout_line_tax(
        checkout=MagicMock(),
        checkout_line_info=MagicMock(line=checkout_line),
        address=MagicMock(),
        discounts=[],
        previous_value=previous_value,
    )

    assert result.tax_rate == Decimal("0.05")

def test_skip_non_digital_goods():
    """Plugin delegates non-digital tax to previous value."""
    plugin = CustomTaxPlugin(config={"digital_rate": "0.05", "physical_rate": "0.10"})
    checkout_line = MagicMock()
    checkout_line.variant.product.product_type.is_digital = False
    previous_value = MagicMock()

    result = plugin.calculate_checkout_line_tax(
        checkout=MagicMock(),
        checkout_line_info=MagicMock(line=checkout_line),
        address=MagicMock(),
        discounts=[],
        previous_value=previous_value,
    )

    assert result == previous_value

Integration Testing the GraphQL API

Saleor exposes all operations through GraphQL. Use Django's test client to send queries.

Setting Up the GraphQL Test Client

# tests/conftest.py
import pytest
from django.test import Client
import json

@pytest.fixture
def graphql_client():
    client = Client()

    def execute(query, variables=None, user=None):
        if user:
            client.force_login(user)
        response = client.post(
            "/graphql/",
            data=json.dumps({"query": query, "variables": variables or {}}),
            content_type="application/json",
        )
        return response.json()

    return execute

Testing Product Queries

@pytest.mark.django_db
def test_product_query(graphql_client, product, channel_USD):
    """Product query returns correct data for published product."""
    query = """
    query GetProduct($id: ID!, $channel: String!) {
        product(id: $id, channel: $channel) {
            id
            name
            isAvailableForPurchase
            variants {
                id
                name
                pricing {
                    price {
                        gross {
                            amount
                            currency
                        }
                    }
                }
            }
        }
    }
    """
    import graphene
    product_id = graphene.Node.to_global_id("Product", product.pk)

    result = graphql_client(
        query,
        variables={"id": product_id, "channel": channel_USD.slug}
    )

    assert "errors" not in result
    data = result["data"]["product"]
    assert data["name"] == product.name
    assert data["isAvailableForPurchase"] is True
    assert len(data["variants"]) > 0

Testing Checkout Mutations

@pytest.mark.django_db
def test_checkout_create(graphql_client, product_variant, channel_USD):
    """CreateCheckout mutation creates a checkout with line items."""
    mutation = """
    mutation CreateCheckout($input: CheckoutCreateInput!) {
        checkoutCreate(input: $input) {
            checkout {
                id
                token
                totalPrice {
                    gross {
                        amount
                        currency
                    }
                }
                lines {
                    id
                    quantity
                    variant {
                        id
                    }
                }
            }
            errors {
                field
                message
                code
            }
        }
    }
    """
    import graphene
    variant_id = graphene.Node.to_global_id("ProductVariant", product_variant.pk)

    result = graphql_client(mutation, variables={
        "input": {
            "channel": channel_USD.slug,
            "lines": [{"variantId": variant_id, "quantity": 2}],
            "email": "buyer@example.com",
        }
    })

    assert "errors" not in result
    checkout_data = result["data"]["checkoutCreate"]
    assert checkout_data["errors"] == []
    assert checkout_data["checkout"]["token"] is not None
    assert len(checkout_data["checkout"]["lines"]) == 1
    assert checkout_data["checkout"]["lines"][0]["quantity"] == 2

@pytest.mark.django_db
def test_checkout_shipping_address_update(graphql_client, checkout):
    """ShippingAddressUpdate sets address and returns available shipping methods."""
    mutation = """
    mutation UpdateShippingAddress($id: ID!, $address: AddressInput!) {
        checkoutShippingAddressUpdate(id: $id, shippingAddress: $address) {
            checkout {
                shippingAddress {
                    firstName
                    city
                    country {
                        code
                    }
                }
                availableShippingMethods {
                    id
                    name
                    price {
                        amount
                    }
                }
            }
            errors {
                field
                message
            }
        }
    }
    """
    import graphene
    checkout_id = graphene.Node.to_global_id("Checkout", checkout.pk)

    result = graphql_client(mutation, variables={
        "id": checkout_id,
        "address": {
            "firstName": "Jane",
            "lastName": "Doe",
            "streetAddress1": "123 Main St",
            "city": "New York",
            "countryArea": "NY",
            "postalCode": "10001",
            "country": "US",
        }
    })

    assert result["data"]["checkoutShippingAddressUpdate"]["errors"] == []
    address = result["data"]["checkoutShippingAddressUpdate"]["checkout"]["shippingAddress"]
    assert address["firstName"] == "Jane"
    assert address["country"]["code"] == "US"

Testing Order Mutations

@pytest.mark.django_db
def test_checkout_complete_creates_order(
    graphql_client, checkout_with_payment, customer_user
):
    """CheckoutComplete creates an order and clears the checkout."""
    mutation = """
    mutation CompleteCheckout($id: ID!) {
        checkoutComplete(id: $id) {
            order {
                id
                status
                total {
                    gross {
                        amount
                        currency
                    }
                }
            }
            errors {
                field
                message
                code
            }
        }
    }
    """
    import graphene
    checkout_id = graphene.Node.to_global_id("Checkout", checkout_with_payment.pk)

    result = graphql_client(mutation, variables={"id": checkout_id}, user=customer_user)

    assert "errors" not in result
    data = result["data"]["checkoutComplete"]
    assert data["errors"] == []
    assert data["order"]["status"] == "UNFULFILLED"
    assert data["order"]["total"]["gross"]["amount"] > 0

Testing Webhooks

Saleor fires webhooks for order events. Test that your webhook handler processes payloads correctly.

# tests/test_webhooks.py
import pytest
import json
import hmac
import hashlib
from django.test import Client

WEBHOOK_SECRET = "test-webhook-secret"

def sign_payload(payload: str, secret: str) -> str:
    return hmac.new(
        secret.encode(),
        payload.encode(),
        hashlib.sha256
    ).hexdigest()

@pytest.mark.django_db
def test_order_created_webhook(client):
    """Webhook handler processes order.created event and sends confirmation email."""
    payload = json.dumps({
        "order": {
            "id": "T3JkZXI6MTIz",
            "number": "123",
            "status": "UNFULFILLED",
            "userEmail": "buyer@example.com",
            "total": {"gross": {"amount": 99.99, "currency": "USD"}},
        }
    })
    signature = sign_payload(payload, WEBHOOK_SECRET)

    response = client.post(
        "/webhooks/saleor/",
        data=payload,
        content_type="application/json",
        HTTP_SALEOR_SIGNATURE=signature,
        HTTP_SALEOR_EVENT="order_created",
    )

    assert response.status_code == 200
    # Verify email was queued
    from django.core import mail
    assert len(mail.outbox) == 1
    assert mail.outbox[0].to == ["buyer@example.com"]

Testing Celery Tasks

# tests/test_tasks.py
import pytest
from unittest.mock import patch
from mystore.tasks import sync_inventory_to_erp

@pytest.mark.django_db
def test_sync_inventory_task(product_variant, warehouse):
    """Sync task sends variant stock data to ERP."""
    with patch("mystore.tasks.erp_client.update_stock") as mock_update:
        mock_update.return_value = {"status": "ok"}
        sync_inventory_to_erp(variant_id=product_variant.pk)
        mock_update.assert_called_once_with(
            sku=product_variant.sku,
            quantity=product_variant.quantity,
        )

@pytest.mark.django_db
def test_sync_inventory_task_retries_on_failure(product_variant):
    """Sync task retries when ERP is unavailable."""
    with patch("mystore.tasks.erp_client.update_stock") as mock_update:
        mock_update.side_effect = ConnectionError("ERP unavailable")
        with pytest.raises(ConnectionError):
            sync_inventory_to_erp.apply(args=[product_variant.pk])
        assert mock_update.call_count == 1  # Will retry via Celery

E2E Testing Checkout Flows

For full E2E testing of a Saleor storefront, use Playwright:

# e2e/test_checkout_e2e.py
import pytest
from playwright.sync_api import Page, expect

BASE_URL = "http://localhost:3000"

def test_full_checkout_flow(page: Page):
    """User can browse, add to cart, and complete checkout."""
    # Browse catalog
    page.goto(f"{BASE_URL}/category/clothing/")
    expect(page.locator("[data-testid='product-card']").first).to_be_visible()

    # Add product to cart
    page.locator("[data-testid='product-card']").first.click()
    page.wait_for_url("**/product/**")
    page.locator("[data-testid='add-to-cart-btn']").click()

    # Verify cart count
    expect(page.locator("[data-testid='cart-count']")).to_have_text("1")

    # Proceed to checkout
    page.locator("[data-testid='cart-icon']").click()
    page.locator("[data-testid='checkout-btn']").click()
    page.wait_for_url("**/checkout/**")

    # Enter shipping info
    page.fill("[name='email']", "e2e@example.com")
    page.fill("[name='firstName']", "Test")
    page.fill("[name='lastName']", "User")
    page.fill("[name='streetAddress1']", "123 Main St")
    page.fill("[name='city']", "New York")
    page.select_option("[name='country']", "US")
    page.fill("[name='postalCode']", "10001")
    page.locator("[data-testid='next-step-btn']").click()

    # Select shipping method
    page.locator("[data-testid='shipping-method']").first.click()
    page.locator("[data-testid='next-step-btn']").click()

    # Enter payment
    page.frame_locator("[data-testid='card-element'] iframe").locator(
        "[name='cardnumber']"
    ).fill("4242 4242 4242 4242")
    page.frame_locator("[data-testid='card-element'] iframe").locator(
        "[name='exp-date']"
    ).fill("12/30")
    page.frame_locator("[data-testid='card-element'] iframe").locator(
        "[name='cvc']"
    ).fill("123")

    # Place order
    page.locator("[data-testid='place-order-btn']").click()
    page.wait_for_url("**/order-confirmation/**")

    expect(page.locator("[data-testid='order-number']")).to_be_visible()
    expect(page.locator("text=Thank you")).to_be_visible()

Testing Saleor with HelpMeTest

While pytest covers backend logic, customer-facing checkout flows need continuous monitoring. HelpMeTest runs E2E checkout tests on a schedule so you catch regressions before customers do.

Write the test once in plain English:

Go to the storefront homepage
Click the first product
Click "Add to Cart"
Verify the cart count shows 1
Click the cart icon
Click "Proceed to Checkout"
Fill in the shipping form with test data
Select the standard shipping method
Fill in test card number 4242 4242 4242 4242
Click "Place Order"
Verify the order confirmation page shows an order number

HelpMeTest runs this every 5 minutes, 24/7, on your production storefront. If the checkout breaks after a Saleor upgrade, a plugin change, or a payment gateway issue, you're alerted in minutes — not when a customer emails support.

Summary

Testing a Saleor store means covering three layers:

  1. Unit tests — Django model methods, plugin hooks, Celery tasks with mocks
  2. Integration tests — GraphQL mutations via Django's test client with real DB
  3. E2E tests — Full checkout flows via Playwright or HelpMeTest monitoring

Saleor's own test suite is your best reference — read saleor/checkout/tests/ and saleor/order/tests/ to understand patterns used by the core team before adding your own tests alongside them.

Start now free