Testing SendGrid Integration: Sandbox Mode, Webhook Testing, and Delivery Assertions

Testing SendGrid Integration: Sandbox Mode, Webhook Testing, and Delivery Assertions

SendGrid powers email delivery for many production applications. Testing SendGrid integration involves mocking the API client for unit tests, using SendGrid's sandbox mode for integration tests, and testing Event Webhooks for delivery tracking. This guide covers all three layers with Node.js examples.

Key Takeaways

Enable sandbox mode to prevent real delivery in integration tests. Add mail_settings: { sandbox_mode: { enable: true } } to your sendMail call — SendGrid accepts and validates the email but doesn't deliver it.

Mock the SendGrid client for unit tests. @sendgrid/mail is an HTTP client — mock it entirely at the module boundary to test your email construction logic without API calls.

Test Event Webhooks with a real HTTP server. SendGrid's Event Webhook posts delivery events (delivered, bounced, opened, etc.) to your endpoint. Test the handler with a local server or by unit testing the route handler.

Assert on Dynamic Template data. SendGrid Dynamic Templates use Handlebars syntax. Test that the templateData object your code constructs has the right keys and values before it's sent.

Rate limit your tests. SendGrid has API rate limits. Use sandbox mode or mocks to avoid hitting them in CI.

SendGrid Integration Overview

A typical SendGrid integration has three parts:

  1. Email sending — calling sgMail.send() with message data
  2. Dynamic Templates — populating Handlebars templates with data
  3. Event Webhooks — receiving delivery/bounce/open events from SendGrid

Each part needs separate testing.

Unit Tests: Mocking the SendGrid Client

The @sendgrid/mail package exports a module-level client. Mock it entirely:

// src/__tests__/email-service.test.js
import { jest } from '@jest/globals';

// Mock before importing the module under test
jest.mock('@sendgrid/mail', () => ({
  setApiKey: jest.fn(),
  send: jest.fn(),
}));

import sgMail from '@sendgrid/mail';
import { EmailService } from '../email/email-service.js';

describe('EmailService', () => {
  let service;

  beforeEach(() => {
    jest.clearAllMocks();
    sgMail.send.mockResolvedValue([{ statusCode: 202 }, {}]);
    service = new EmailService();
  });

  describe('sendWelcomeEmail', () => {
    const user = { email: 'jane@example.com', name: 'Jane Smith' };

    it('calls sgMail.send with correct recipient', async () => {
      await service.sendWelcomeEmail(user);

      expect(sgMail.send).toHaveBeenCalledWith(
        expect.objectContaining({ to: 'jane@example.com' })
      );
    });

    it('uses welcome template ID', async () => {
      await service.sendWelcomeEmail(user);

      const [args] = sgMail.send.mock.calls;
      expect(args[0].templateId).toBe(process.env.SENDGRID_WELCOME_TEMPLATE_ID);
    });

    it('populates template with user name', async () => {
      await service.sendWelcomeEmail(user);

      const [args] = sgMail.send.mock.calls;
      expect(args[0].dynamicTemplateData.name).toBe('Jane Smith');
    });

    it('sends from branded address', async () => {
      await service.sendWelcomeEmail(user);

      const [args] = sgMail.send.mock.calls;
      expect(args[0].from).toEqual(
        expect.objectContaining({
          email: 'noreply@helpmetest.com',
          name: 'HelpMeTest',
        })
      );
    });

    it('includes reply-to', async () => {
      await service.sendWelcomeEmail(user);

      const [args] = sgMail.send.mock.calls;
      expect(args[0].replyTo).toBe('support@helpmetest.com');
    });
  });

  describe('sendPasswordReset', () => {
    it('includes reset URL in template data', async () => {
      await service.sendPasswordReset(
        { email: 'user@example.com', name: 'User' },
        'reset-token-abc123'
      );

      const [args] = sgMail.send.mock.calls;
      expect(args[0].dynamicTemplateData.resetUrl).toContain('reset-token-abc123');
    });

    it('sets correct email subject override', async () => {
      await service.sendPasswordReset(
        { email: 'user@example.com', name: 'User' },
        'token'
      );

      const [args] = sgMail.send.mock.calls;
      // Subject may be in template or overridden here
      if (args[0].subject) {
        expect(args[0].subject).toMatch(/password reset/i);
      }
    });
  });
});

Testing Dynamic Template Data

SendGrid's Dynamic Templates use Handlebars. The most common bug: a template expects {{user.firstName}} but your code sends dynamicTemplateData.name. Test the data shape explicitly:

// src/email/email-service.js
export class EmailService {
  async sendWelcomeEmail(user) {
    const [firstName] = user.name.split(' ');

    return sgMail.send({
      to: user.email,
      from: { email: 'noreply@helpmetest.com', name: 'HelpMeTest' },
      templateId: process.env.SENDGRID_WELCOME_TEMPLATE_ID,
      dynamicTemplateData: {
        name: user.name,
        firstName,
        appUrl: 'https://helpmetest.com',
        supportEmail: 'support@helpmetest.com',
        unsubscribeUrl: `https://helpmetest.com/unsubscribe/${user.unsubscribeToken}`,
      },
    });
  }
}
describe('Dynamic template data shape', () => {
  it('provides all required template variables', async () => {
    const user = {
      email: 'test@example.com',
      name: 'Alice Johnson',
      unsubscribeToken: 'unsub-token-123',
    };

    await service.sendWelcomeEmail(user);

    const templateData = sgMail.send.mock.calls[0][0].dynamicTemplateData;

    // These must match the Handlebars variables in your SendGrid template
    expect(templateData).toHaveProperty('name', 'Alice Johnson');
    expect(templateData).toHaveProperty('firstName', 'Alice');
    expect(templateData).toHaveProperty('appUrl');
    expect(templateData).toHaveProperty('supportEmail');
    expect(templateData).toHaveProperty('unsubscribeUrl');
    expect(templateData.unsubscribeUrl).toContain('unsub-token-123');
  });
});

Integration Tests: SendGrid Sandbox Mode

Sandbox mode sends the email to SendGrid's API for validation but doesn't deliver it:

// tests/integration/sendgrid.test.js
import sgMail from '@sendgrid/mail';

const SKIP = !process.env.SENDGRID_API_KEY;

describe.skipIf(SKIP)('SendGrid sandbox integration', () => {
  beforeAll(() => {
    sgMail.setApiKey(process.env.SENDGRID_API_KEY);
  });

  it('accepts welcome email in sandbox mode', async () => {
    const [response] = await sgMail.send({
      to: 'test@example.com',
      from: 'noreply@helpmetest.com',
      subject: 'Sandbox Test',
      html: '<p>Test email</p>',
      text: 'Test email',
      mailSettings: {
        sandboxMode: { enable: true },
      },
    });

    // 200 in sandbox mode (vs 202 for real delivery)
    expect([200, 202]).toContain(response.statusCode);
  });

  it('sandbox mode accepts emails with attachments', async () => {
    const [response] = await sgMail.send({
      to: 'test@example.com',
      from: 'noreply@helpmetest.com',
      subject: 'Attachment Test',
      html: '<p>See attached</p>',
      mailSettings: {
        sandboxMode: { enable: true },
      },
      attachments: [
        {
          content: Buffer.from('test content').toString('base64'),
          filename: 'test.txt',
          type: 'text/plain',
          disposition: 'attachment',
        },
      ],
    });

    expect([200, 202]).toContain(response.statusCode);
  });

  it('rejects emails with invalid from address', async () => {
    await expect(
      sgMail.send({
        to: 'test@example.com',
        from: 'not-a-valid-email',
        subject: 'Invalid',
        text: 'Test',
        mailSettings: { sandboxMode: { enable: true } },
      })
    ).rejects.toThrow();
  });
});

Testing Error Handling

describe('SendGrid error handling', () => {
  it('handles 429 rate limit errors', async () => {
    const rateLimitError = new Error('Too Many Requests');
    rateLimitError.code = 429;
    rateLimitError.response = {
      body: { errors: [{ message: 'Too Many Requests' }] },
    };
    sgMail.send.mockRejectedValue(rateLimitError);

    await expect(
      service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' })
    ).rejects.toThrow();
  });

  it('handles 401 invalid API key', async () => {
    const authError = new Error('Unauthorized');
    authError.code = 401;
    sgMail.send.mockRejectedValue(authError);

    await expect(
      service.sendWelcomeEmail({ email: 'x@x.com', name: 'X' })
    ).rejects.toThrow('Unauthorized');
  });
});

Testing Event Webhooks

SendGrid sends POST requests to your webhook URL for delivery events (delivered, bounced, opened, clicked, unsubscribed). Test the webhook handler:

// src/webhooks/sendgrid-handler.js
export async function handleSendGridEvent(events) {
  for (const event of events) {
    switch (event.event) {
      case 'delivered':
        await markEmailDelivered(event.email, event.sg_message_id);
        break;
      case 'bounce':
        await handleBounce(event.email, event.type);
        break;
      case 'unsubscribe':
        await unsubscribeUser(event.email);
        break;
      case 'spamreport':
        await handleSpamReport(event.email);
        break;
    }
  }
}
// src/__tests__/sendgrid-webhook.test.js
import { jest } from '@jest/globals';

jest.mock('../db/email-tracking.js');
jest.mock('../db/users.js');

import { markEmailDelivered, handleBounce, unsubscribeUser } from '../db/email-tracking.js';
import { handleSendGridEvent } from '../webhooks/sendgrid-handler.js';

describe('SendGrid Event Webhook handler', () => {
  beforeEach(() => jest.clearAllMocks());

  it('marks email as delivered on "delivered" event', async () => {
    await handleSendGridEvent([
      {
        event: 'delivered',
        email: 'user@example.com',
        sg_message_id: 'msg-abc123',
        timestamp: 1716000000,
      },
    ]);

    expect(markEmailDelivered).toHaveBeenCalledWith('user@example.com', 'msg-abc123');
  });

  it('handles bounce event by type', async () => {
    await handleSendGridEvent([
      {
        event: 'bounce',
        email: 'bad@example.com',
        type: 'bounce',
        status: '5.1.1',
      },
    ]);

    expect(handleBounce).toHaveBeenCalledWith('bad@example.com', 'bounce');
  });

  it('unsubscribes user on unsubscribe event', async () => {
    await handleSendGridEvent([
      { event: 'unsubscribe', email: 'unsub@example.com' },
    ]);

    expect(unsubscribeUser).toHaveBeenCalledWith('unsub@example.com');
  });

  it('processes multiple events in a single payload', async () => {
    await handleSendGridEvent([
      { event: 'delivered', email: 'a@example.com', sg_message_id: 'id1' },
      { event: 'delivered', email: 'b@example.com', sg_message_id: 'id2' },
    ]);

    expect(markEmailDelivered).toHaveBeenCalledTimes(2);
  });

  it('ignores unknown event types without throwing', async () => {
    await expect(
      handleSendGridEvent([{ event: 'click', email: 'x@x.com', url: 'https://example.com' }])
    ).resolves.not.toThrow();
  });
});

Testing the Webhook HTTP Endpoint

Use Supertest to test the Express route that receives webhook events:

// src/routes/webhooks.js
import express from 'express';
import { handleSendGridEvent } from '../webhooks/sendgrid-handler.js';

const router = express.Router();

router.post('/sendgrid/events', async (req, res) => {
  try {
    await handleSendGridEvent(req.body);
    res.status(200).send('OK');
  } catch (err) {
    console.error('Webhook error:', err);
    res.status(500).send('Error');
  }
});

export default router;
import request from 'supertest';
import app from '../src/app.js';

describe('POST /webhooks/sendgrid/events', () => {
  it('returns 200 for valid event payload', async () => {
    const payload = [
      { event: 'delivered', email: 'test@example.com', sg_message_id: 'id1' },
    ];

    const response = await request(app)
      .post('/webhooks/sendgrid/events')
      .send(payload)
      .expect(200);

    expect(response.text).toBe('OK');
  });

  it('accepts empty event array', async () => {
    await request(app)
      .post('/webhooks/sendgrid/events')
      .send([])
      .expect(200);
  });
});

CI Configuration

name: SendGrid Tests
on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm test -- --testPathPattern='email|webhook'
      # Unit tests use mocks — no SENDGRID_API_KEY needed

  sandbox-tests:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm run test:integration
        env:
          SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
          SENDGRID_WELCOME_TEMPLATE_ID: ${{ secrets.SENDGRID_WELCOME_TEMPLATE_ID }}

Summary

SendGrid testing in three layers:

Layer Tool Validates
Unit tests Jest + @sendgrid/mail mock Message construction, template data, from/to, subject
Sandbox integration SendGrid Sandbox Mode API authentication, payload validation, template IDs
Webhook tests Jest + Supertest Event handling, database updates, route responses

For full email flow E2E tests — verifying that clicking a link in a real email opens the correct page — HelpMeTest provides browser-level test automation that covers the path SendGrid unit tests don't reach.

Read more