Medusa.js Testing Guide: Testing Your Headless Commerce Backend

Medusa.js Testing Guide: Testing Your Headless Commerce Backend

Medusa.js is an open-source headless commerce platform built on Node.js. Unlike Shopify or WooCommerce, Medusa is code-first: you own the backend, extend it with custom services and workflows, and connect any frontend. This power comes with responsibility — you need to test your customizations to ensure the checkout, order, and fulfillment flows work correctly.

This guide covers testing Medusa.js applications: unit testing custom services, integration testing the Medusa API, and E2E testing checkout flows.

Understanding Medusa's Architecture

Medusa v2 is built around:

  • Services: Business logic classes registered in the dependency injection container
  • Workflows: Composable, step-based business processes (using @medusajs/workflows-sdk)
  • API Routes: Express-based routes under src/api/
  • Subscribers: Event listeners for order.placed, payment.captured, etc.

Testing strategy maps to these layers:

Layer Test type Speed
Services Unit (mocked deps) Fast
Workflows Unit (step mocking) Fast
API Routes Integration (real HTTP) Medium
Subscribers Integration (event dispatch) Medium
Checkout E2E E2E (browser) Slow

Unit Testing Custom Services

Medusa services are classes. Test them by mocking their dependencies.

Example: Custom Discount Service

// src/modules/promotions/services/loyalty-discount.ts
import { MedusaService } from '@medusajs/utils';

type LoyaltyDiscountInput = {
  customerId: string;
  orderTotal: number;
};

export class LoyaltyDiscountService extends MedusaService({}) {
  constructor(container: any) {
    super(container);
  }

  async calculateDiscount({ customerId, orderTotal }: LoyaltyDiscountInput) {
    const orderCount = await this.container.orderService.count({
      customer_id: customerId,
      status: 'completed',
    });

    if (orderCount >= 10) {
      return Math.round(orderTotal * 0.1); // 10% for loyal customers
    }
    if (orderCount >= 5) {
      return Math.round(orderTotal * 0.05); // 5% for returning customers
    }
    return 0;
  }
}

Testing this service:

// src/modules/promotions/services/loyalty-discount.test.ts
import { LoyaltyDiscountService } from './loyalty-discount';

function createMockContainer(orderCount: number) {
  return {
    orderService: {
      count: jest.fn().mockResolvedValue(orderCount),
    },
  };
}

describe('LoyaltyDiscountService', () => {
  describe('calculateDiscount', () => {
    it('returns 0 for first-time customers', async () => {
      const service = new LoyaltyDiscountService(createMockContainer(0));
      const discount = await service.calculateDiscount({
        customerId: 'cust-1',
        orderTotal: 10000, // $100.00 in cents
      });
      expect(discount).toBe(0);
    });

    it('returns 5% for customers with 5-9 orders', async () => {
      const service = new LoyaltyDiscountService(createMockContainer(7));
      const discount = await service.calculateDiscount({
        customerId: 'cust-1',
        orderTotal: 10000,
      });
      expect(discount).toBe(500); // $5.00
    });

    it('returns 10% for customers with 10+ orders', async () => {
      const service = new LoyaltyDiscountService(createMockContainer(15));
      const discount = await service.calculateDiscount({
        customerId: 'cust-1',
        orderTotal: 10000,
      });
      expect(discount).toBe(1000); // $10.00
    });

    it('rounds discount to whole cents', async () => {
      const service = new LoyaltyDiscountService(createMockContainer(5));
      const discount = await service.calculateDiscount({
        customerId: 'cust-1',
        orderTotal: 333, // $3.33 * 5% = $0.1665 → rounds to 17 cents
      });
      expect(discount).toBe(17);
    });
  });
});

Unit Testing Medusa Workflows

Medusa v2 workflows are step-based. Each step is testable in isolation:

// src/workflows/order-confirmation.ts
import { createStep, createWorkflow, StepResponse } from '@medusajs/workflows-sdk';

export const sendConfirmationEmailStep = createStep(
  'send-confirmation-email',
  async (input: { orderId: string; customerEmail: string }, { container }) => {
    const emailService = container.resolve('emailService');
    await emailService.sendOrderConfirmation({
      to: input.customerEmail,
      orderId: input.orderId,
    });
    return new StepResponse({ sent: true });
  },
);

export const updateOrderMetadataStep = createStep(
  'update-order-metadata',
  async (input: { orderId: string }, { container }) => {
    const orderService = container.resolve('orderService');
    await orderService.update(input.orderId, {
      metadata: { confirmation_sent_at: new Date().toISOString() },
    });
    return new StepResponse({ updated: true });
  },
);

export const orderConfirmationWorkflow = createWorkflow(
  'order-confirmation',
  (input: { orderId: string; customerEmail: string }) => {
    sendConfirmationEmailStep(input);
    updateOrderMetadataStep({ orderId: input.orderId });
  },
);

Test individual steps by running them with a mock container:

// src/workflows/order-confirmation.test.ts
import { StepExecutionContext } from '@medusajs/workflows-sdk';

const mockEmailService = { sendOrderConfirmation: jest.fn() };
const mockOrderService = { update: jest.fn() };

function mockContext() {
  return {
    container: {
      resolve: jest.fn((name: string) => {
        if (name === 'emailService') return mockEmailService;
        if (name === 'orderService') return mockOrderService;
        throw new Error(`Unknown service: ${name}`);
      }),
    },
  } as unknown as StepExecutionContext;
}

describe('sendConfirmationEmailStep', () => {
  it('calls emailService with correct arguments', async () => {
    const { sendConfirmationEmailStep } = await import('./order-confirmation');
    const ctx = mockContext();

    // Run the step handler directly
    // (Medusa exposes .run() for testing individual steps)
    await sendConfirmationEmailStep.run(
      { orderId: 'order-1', customerEmail: 'buyer@example.com' },
      ctx,
    );

    expect(mockEmailService.sendOrderConfirmation).toHaveBeenCalledWith({
      to: 'buyer@example.com',
      orderId: 'order-1',
    });
  });
});

Integration Testing the Medusa API

Medusa provides a test helper that boots the full application with an in-memory SQLite database:

// tests/integration/setup.ts
import { MedusaApp } from '@medusajs/medusa';
import request from 'supertest';

let app: Express.Application;
let adminToken: string;

export async function startMedusaApp() {
  const medusa = await MedusaApp.create({
    database: { type: 'sqlite', url: ':memory:' },
    // Disable external services in tests
    redis: { url: undefined },
    stripe: undefined,
  });

  await medusa.run();
  app = medusa.express;

  // Get admin token
  const res = await request(app)
    .post('/auth/token/emailpass')
    .send({ email: 'admin@test.com', password: 'supersecret' });
  adminToken = res.body.token;

  return { app, adminToken };
}

export async function stopMedusaApp() {
  // cleanup
}

API Integration Tests

// tests/integration/products.test.ts
import request from 'supertest';
import { startMedusaApp, stopMedusaApp } from './setup';

let app: Express.Application;
let adminToken: string;
let createdProductId: string;

beforeAll(async () => {
  ({ app, adminToken } = await startMedusaApp());
});

afterAll(async () => {
  await stopMedusaApp();
});

describe('Product API', () => {
  it('creates a product', async () => {
    const res = await request(app)
      .post('/admin/products')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({
        title: 'Test T-Shirt',
        description: 'A test product',
        variants: [
          {
            title: 'Small',
            prices: [{ amount: 2999, currency_code: 'usd' }],
          },
        ],
      });

    expect(res.status).toBe(200);
    expect(res.body.product.title).toBe('Test T-Shirt');
    createdProductId = res.body.product.id;
  });

  it('retrieves the created product on the storefront', async () => {
    const res = await request(app)
      .get(`/store/products/${createdProductId}`)
      .query({ region_id: 'test-region' });

    expect(res.status).toBe(200);
    expect(res.body.product.title).toBe('Test T-Shirt');
    expect(res.body.product.variants[0].prices).toHaveLength(1);
  });

  it('deletes the product', async () => {
    const res = await request(app)
      .delete(`/admin/products/${createdProductId}`)
      .set('Authorization', `Bearer ${adminToken}`);

    expect(res.status).toBe(200);
    expect(res.body.deleted).toBe(true);
  });
});

Testing the Cart and Checkout Flow

// tests/integration/checkout.test.ts
import request from 'supertest';

describe('Cart and checkout flow', () => {
  let cartId: string;
  let lineItemId: string;

  it('creates a cart', async () => {
    const res = await request(app)
      .post('/store/carts')
      .send({ region_id: 'test-region-id' });

    expect(res.status).toBe(200);
    expect(res.body.cart.id).toBeDefined();
    cartId = res.body.cart.id;
  });

  it('adds a line item to the cart', async () => {
    const res = await request(app)
      .post(`/store/carts/${cartId}/line-items`)
      .send({
        variant_id: 'test-variant-id',
        quantity: 2,
      });

    expect(res.status).toBe(200);
    expect(res.body.cart.items).toHaveLength(1);
    expect(res.body.cart.items[0].quantity).toBe(2);
    lineItemId = res.body.cart.items[0].id;
  });

  it('applies a discount code', async () => {
    const res = await request(app)
      .post(`/store/carts/${cartId}/discounts`)
      .send({ code: 'TESTCODE10' });

    expect(res.status).toBe(200);
    expect(res.body.cart.discounts).toHaveLength(1);
    expect(res.body.cart.total).toBeLessThan(res.body.cart.subtotal);
  });

  it('completes the cart (simulated payment)', async () => {
    // First, create a payment session
    await request(app)
      .post(`/store/carts/${cartId}/payment-sessions`)
      .send({ provider_id: 'test-provider' });

    // Select the payment session
    await request(app)
      .post(`/store/carts/${cartId}/payment-session`)
      .send({ provider_id: 'test-provider' });

    // Complete the cart
    const res = await request(app)
      .post(`/store/carts/${cartId}/complete`);

    expect(res.status).toBe(200);
    expect(res.body.type).toBe('order');
    expect(res.body.data.status).toBe('pending');
  });
});

Testing Subscribers

Medusa subscribers listen to events like order.placed. Test them by dispatching events directly:

// src/subscribers/order-placed.test.ts
import { OrderPlacedSubscriber } from './order-placed';

const mockEmailService = { sendOrderConfirmation: jest.fn() };
const mockContainer = {
  resolve: jest.fn().mockReturnValue(mockEmailService),
  logger: { info: jest.fn(), error: jest.fn() },
};

describe('OrderPlacedSubscriber', () => {
  let subscriber: OrderPlacedSubscriber;

  beforeEach(() => {
    subscriber = new OrderPlacedSubscriber(mockContainer as any);
    jest.clearAllMocks();
  });

  it('sends confirmation email on order.placed', async () => {
    await subscriber.handle({
      data: {
        id: 'order-123',
        customer: { email: 'buyer@example.com', first_name: 'Alice' },
        total: 5999,
        currency_code: 'usd',
      },
    });

    expect(mockEmailService.sendOrderConfirmation).toHaveBeenCalledWith(
      expect.objectContaining({
        to: 'buyer@example.com',
        orderId: 'order-123',
      }),
    );
  });

  it('handles missing customer gracefully', async () => {
    await expect(
      subscriber.handle({ data: { id: 'order-no-customer' } }),
    ).resolves.not.toThrow();
  });
});

CI Configuration

# .github/workflows/test.yml
name: Test Medusa

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    env:
      DATABASE_URL: sqlite::memory:
      NODE_ENV: test

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test -- --testPathPattern="unit"
      - run: npm test -- --testPathPattern="integration" --runInBand

Key Testing Principles

Don't test Medusa's core. If orderService.create() works correctly is Medusa's responsibility, not yours. Test your custom services, workflows, and subscribers.

Use the test provider for payments. Medusa ships with a test-provider payment plugin that simulates payment without calling Stripe or any real payment processor. Always use this in integration tests.

Seed reference data before integration tests. Medusa requires regions, currencies, and tax configuration to exist before you can create carts. Set these up in your beforeAll.

Run integration tests in band. Integration tests share the in-memory database. --runInBand prevents race conditions from parallel test execution.

For production Medusa stores, HelpMeTest monitors your checkout flow 24/7 — running automated add-to-cart and checkout tests on a schedule so you catch broken payment flows before customers do.

Read more