Testing Shopify Apps: App Bridge, Embedded Apps, and Checkout Extensions

Testing Shopify Apps: App Bridge, Embedded Apps, and Checkout Extensions

Testing Shopify apps requires dealing with embedded iframes, App Bridge authentication, and the Shopify CLI development environment. This guide covers mocking App Bridge for unit tests, integration testing with the development store, and testing checkout extensions.


Shopify App Testing Challenges

Shopify apps run in a unique environment that makes testing non-trivial:

  • Embedded iframes — Shopify Admin loads apps in iframes, affecting how window events and navigation work
  • App Bridge — the SDK that bridges your app and the Shopify Admin shell requires mocking in tests
  • Session tokens — authentication via Shopify session tokens (JWTs) that rotate frequently
  • Checkout extensions — UI Extensions run in a sandboxed React environment with a proprietary API
  • Webhooks — HMAC-verified callbacks that need signature testing

Setting Up the Shopify CLI Development Environment

# Install Shopify CLI
npm install -g @shopify/cli @shopify/theme

<span class="hljs-comment"># Create a new app
shopify app init my-app --template node

<span class="hljs-comment"># Start development server (tunnels to a development store)
shopify app dev

The shopify app dev command:

  1. Creates an ngrok-like tunnel to localhost
  2. Configures your development store to use the tunnel URL
  3. Handles session token injection
  4. Watches for changes and hot-reloads

Mocking App Bridge for Unit Tests

App Bridge uses window.shopify and iframe messaging, which doesn't work in Jest/Node. You need a mock.

// __mocks__/@shopify/app-bridge-react.js
const MockProvider = ({ children }) => children;

const mockNavigate = jest.fn();
const mockToast = jest.fn();
const mockModal = {
  open: jest.fn(),
  close: jest.fn(),
};

module.exports = {
  Provider: MockProvider,
  useNavigate: () => mockNavigate,
  useToast: () => ({ show: mockToast }),
  useModal: () => mockModal,
  // Export mocks for test assertions
  __mocks__: { navigate: mockNavigate, toast: mockToast, modal: mockModal },
};
// __mocks__/@shopify/app-bridge.js
const createApp = jest.fn(() => ({
  dispatch: jest.fn(),
  subscribe: jest.fn(),
  error: jest.fn(),
  featuresAvailable: jest.fn().mockResolvedValue({ Redirect: true }),
}));

module.exports = { createApp };

Testing App Bridge Navigation

// ProductPage.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import { __mocks__ } from '@shopify/app-bridge-react';
import ProductPage from '../ProductPage';

beforeEach(() => {
  jest.clearAllMocks();
});

test('navigates to product edit page on button click', () => {
  render(<ProductPage productId="gid://shopify/Product/123" />);

  fireEvent.click(screen.getByText('Edit Product'));

  expect(__mocks__.navigate).toHaveBeenCalledWith('/products/123/edit');
});

test('shows success toast after save', async () => {
  render(<ProductPage productId="gid://shopify/Product/123" />);

  fireEvent.click(screen.getByText('Save'));

  await waitFor(() => {
    expect(__mocks__.toast).toHaveBeenCalledWith('Product saved successfully', {
      duration: 3000,
    });
  });
});

Testing Session Token Authentication

Shopify apps use rotating session tokens for authentication. Test your token validation logic thoroughly.

// session-token.test.js
import jwt from 'jsonwebtoken';
import { validateSessionToken, extractShop } from '../auth/session-token';

const SHOPIFY_API_SECRET = 'test-api-secret';

function createTestToken(payload = {}, secret = SHOPIFY_API_SECRET) {
  return jwt.sign(
    {
      iss: 'https://test-store.myshopify.com/admin',
      dest: 'https://test-store.myshopify.com',
      aud: 'test-api-key',
      sub: '1234',
      exp: Math.floor(Date.now() / 1000) + 3600,
      nbf: Math.floor(Date.now() / 1000) - 60,
      iat: Math.floor(Date.now() / 1000) - 60,
      jti: 'test-jti',
      sid: 'test-session-id',
      ...payload,
    },
    secret
  );
}

describe('validateSessionToken', () => {
  it('accepts a valid token', async () => {
    const token = createTestToken();
    const result = await validateSessionToken(token, SHOPIFY_API_SECRET);

    expect(result.valid).toBe(true);
    expect(result.shop).toBe('test-store.myshopify.com');
  });

  it('rejects an expired token', async () => {
    const token = createTestToken({
      exp: Math.floor(Date.now() / 1000) - 60,
    });

    const result = await validateSessionToken(token, SHOPIFY_API_SECRET);
    expect(result.valid).toBe(false);
    expect(result.error).toContain('expired');
  });

  it('rejects a token with wrong secret', async () => {
    const token = createTestToken({}, 'wrong-secret');
    const result = await validateSessionToken(token, SHOPIFY_API_SECRET);

    expect(result.valid).toBe(false);
    expect(result.error).toContain('signature');
  });

  it('rejects a token with wrong audience', async () => {
    const token = createTestToken({ aud: 'wrong-api-key' });
    const result = await validateSessionToken(token, SHOPIFY_API_SECRET);

    expect(result.valid).toBe(false);
  });
});

Testing Shopify Admin API Calls

// shopify-api.test.js
import { createAdminApiClient } from '@shopify/admin-api-client';
import { getProducts, updateProductPrice } from '../shopify-api';

// Mock the Admin API client
jest.mock('@shopify/admin-api-client');

const mockRequest = jest.fn();
createAdminApiClient.mockReturnValue({ request: mockRequest });

describe('getProducts', () => {
  it('fetches products with correct GraphQL query', async () => {
    mockRequest.mockResolvedValue({
      data: {
        products: {
          edges: [
            { node: { id: 'gid://shopify/Product/1', title: 'Test Product', variants: { edges: [] } } },
          ],
        },
      },
    });

    const products = await getProducts({ first: 10 });

    expect(products).toHaveLength(1);
    expect(products[0].title).toBe('Test Product');
    expect(mockRequest).toHaveBeenCalledWith(
      expect.stringContaining('products'),
      expect.objectContaining({ variables: { first: 10 } })
    );
  });

  it('handles rate limiting gracefully', async () => {
    mockRequest
      .mockRejectedValueOnce({ extensions: { code: 'THROTTLED' } })
      .mockResolvedValueOnce({ data: { products: { edges: [] } } });

    const products = await getProducts({ first: 10 });

    expect(products).toEqual([]);
    expect(mockRequest).toHaveBeenCalledTimes(2); // Retried once
  });
});

describe('updateProductPrice', () => {
  it('updates variant price via mutation', async () => {
    mockRequest.mockResolvedValue({
      data: {
        productVariantUpdate: {
          productVariant: { id: 'gid://shopify/ProductVariant/1', price: '29.99' },
          userErrors: [],
        },
      },
    });

    const result = await updateProductPrice('gid://shopify/ProductVariant/1', '29.99');

    expect(result.price).toBe('29.99');
  });

  it('handles user errors from Shopify', async () => {
    mockRequest.mockResolvedValue({
      data: {
        productVariantUpdate: {
          productVariant: null,
          userErrors: [{ field: 'price', message: 'Price must be greater than 0' }],
        },
      },
    });

    await expect(
      updateProductPrice('gid://shopify/ProductVariant/1', '-5')
    ).rejects.toThrow('Price must be greater than 0');
  });
});

Testing Webhook Handling

Shopify webhooks use HMAC-SHA256 signatures. Always verify them in tests.

// webhooks.test.js
import crypto from 'crypto';
import request from 'supertest';
import app from '../app';

const SHOPIFY_WEBHOOK_SECRET = 'test-webhook-secret';

function signWebhookPayload(payload) {
  const hmac = crypto.createHmac('sha256', SHOPIFY_WEBHOOK_SECRET);
  hmac.update(payload, 'utf8');
  return hmac.digest('base64');
}

describe('Shopify webhooks', () => {
  it('rejects webhooks with invalid HMAC', async () => {
    const payload = JSON.stringify({ id: 123 });

    const res = await request(app)
      .post('/webhooks/shopify/products/update')
      .set('X-Shopify-Topic', 'products/update')
      .set('X-Shopify-Hmac-Sha256', 'invalid-signature')
      .set('Content-Type', 'application/json')
      .send(payload);

    expect(res.status).toBe(401);
  });

  it('processes products/update webhook', async () => {
    const payload = JSON.stringify({
      id: 632910392,
      title: 'Updated Product',
      variants: [{ id: 1, price: '39.99' }],
    });

    const signature = signWebhookPayload(payload);

    const res = await request(app)
      .post('/webhooks/shopify/products/update')
      .set('X-Shopify-Topic', 'products/update')
      .set('X-Shopify-Hmac-Sha256', signature)
      .set('X-Shopify-Shop-Domain', 'test-store.myshopify.com')
      .set('Content-Type', 'application/json')
      .send(payload);

    expect(res.status).toBe(200);
  });

  it('responds within 5 seconds to avoid Shopify retry', async () => {
    const payload = JSON.stringify({ id: 1 });
    const signature = signWebhookPayload(payload);

    const start = Date.now();
    await request(app)
      .post('/webhooks/shopify/orders/create')
      .set('X-Shopify-Hmac-Sha256', signature)
      .send(payload);

    expect(Date.now() - start).toBeLessThan(5000);
  });
});

Testing Checkout Extensions

Shopify Checkout Extensions run in a sandboxed environment using UI Extensions. The Shopify CLI provides testing utilities.

Unit Testing Extension Logic

// discount-extension.test.js
import { applyDiscount, validateDiscountCode } from '../extensions/checkout-discount/logic';

describe('discount code validation', () => {
  it('accepts valid discount codes', () => {
    expect(validateDiscountCode('SAVE20')).toBe(true);
    expect(validateDiscountCode('SUMMER2025')).toBe(true);
  });

  it('rejects malformed codes', () => {
    expect(validateDiscountCode('')).toBe(false);
    expect(validateDiscountCode('a'.repeat(300))).toBe(false);
    expect(validateDiscountCode('<script>')).toBe(false);
  });

  it('applies percentage discount to cart total', () => {
    const cart = { totalPrice: { amount: '100.00', currencyCode: 'USD' } };
    const result = applyDiscount(cart, { type: 'percentage', value: 20 });

    expect(result.discountAmount).toBe('20.00');
    expect(result.finalPrice).toBe('80.00');
  });
});

Testing with the Extension API Mock

Shopify provides @shopify/ui-extensions-test-helpers for testing checkout UI extensions:

// checkout-ui-extension.test.jsx
import { mount } from '@shopify/ui-extensions-test-helpers/checkout';
import DiscountBanner from '../extensions/checkout-ui/DiscountBanner';

test('renders discount banner when code is applied', async () => {
  const { getByText } = await mount(DiscountBanner, {
    extensionPoint: 'Checkout::Dynamic::Render',
    initialState: {
      discountCodes: [{ code: 'SAVE20', applicable: true }],
    },
  });

  expect(getByText('SAVE20 applied — 20% off your order')).toBeTruthy();
});

test('shows error state for invalid code', async () => {
  const { getByText, act } = await mount(DiscountBanner, {
    extensionPoint: 'Checkout::Dynamic::Render',
    initialState: {
      discountCodes: [],
    },
  });

  await act(async () => {
    // Simulate entering an invalid code
    await triggerInput('BADCODE');
    await clickApply();
  });

  expect(getByText('Discount code not valid')).toBeTruthy();
});

E2E Testing Checkout Extensions

For end-to-end testing, use the Shopify CLI to preview extensions:

# Start the extension dev server
shopify app dev --extension-only

<span class="hljs-comment"># In a separate terminal, run Playwright tests
npx playwright <span class="hljs-built_in">test --config=playwright.extension.config.js
// checkout-extension.e2e.js (Playwright)
test('discount extension applies discount at checkout', async ({ page }) => {
  // Go to a product in the development store
  await page.goto(`${DEV_STORE_URL}/products/test-product`);
  await page.click('[data-testid="add-to-cart"]');
  await page.goto(`${DEV_STORE_URL}/cart`);
  await page.click('[name="checkout"]');

  // Wait for checkout to load with extension
  await page.waitForSelector('[data-checkout-extension]', { timeout: 10000 });

  // Test your extension's discount code input
  await page.fill('[data-testid="extension-discount-input"]', 'SAVE20');
  await page.click('[data-testid="extension-apply-discount"]');

  // Verify discount was applied
  await expect(page.locator('[data-testid="discount-line"]')).toContainText('-20%');
});

Integration Testing with the Development Store

For integration tests that require a real Shopify store connection:

// dev-store-integration.test.js
import { shopifyApi } from '@shopify/shopify-api';

const shopify = shopifyApi({
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecretKey: process.env.SHOPIFY_API_SECRET,
  scopes: ['read_products', 'write_products'],
  hostName: 'localhost',
});

// These run only against the dev store, not in CI (unless CI secrets are set)
describe.skipIf(!process.env.DEV_STORE_SESSION)('Dev store integration', () => {
  let client;

  beforeAll(async () => {
    const session = JSON.parse(process.env.DEV_STORE_SESSION);
    client = new shopify.clients.Graphql({ session });
  });

  it('can fetch and update a product', async () => {
    // Fetch a product
    const { body } = await client.query({
      data: `{ products(first: 1) { edges { node { id title } } } }`,
    });

    const productId = body.data.products.edges[0].node.id;
    expect(productId).toBeTruthy();

    // Update it with a test tag
    const mutation = await client.query({
      data: {
        query: `mutation updateProduct($id: ID!, $input: ProductInput!) {
          productUpdate(id: $id, input: $input) {
            product { id tags }
            userErrors { field message }
          }
        }`,
        variables: {
          id: productId,
          input: { tags: ['test-tag-integration'] },
        },
      },
    });

    expect(mutation.body.data.productUpdate.userErrors).toHaveLength(0);
  });
});

Summary

Shopify app testing requires mocks for App Bridge (iframe bridging that doesn't work in Node), JWT validation tests for session tokens, and HMAC verification tests for webhooks. For checkout extensions, unit test the business logic separately, then use Shopify's UI Extensions test helpers for component tests and Playwright for end-to-end flows against a development store.

The most common testing gap is skipping the HMAC verification test — without it, your webhook endpoint may silently accept forged events in production.

Read more