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" --runInBandKey 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.