Testing with SDKs

Build reliable messaging features with comprehensive testing strategies for Sent LogoSent SDKs. This guide covers unit testing, integration testing, mocking, and CI/CD best practices.

Testing Pyramid

For messaging integrations, follow this testing strategy:

  1. Unit Tests (70%) - Mock the SDK, test business logic
  2. Integration Tests (20%) - Use test API keys to validate API calls
  3. E2E Tests (10%) - Full flow with webhook handling

Unit Testing

Mock the SDK

Don't make real API calls in unit tests:

// __mocks__/@sentdm/sentdm.ts
import { jest } from '@jest/globals';

export const mockSend = jest.fn();

export default jest.fn().mockImplementation(() => ({
  messages: {
    send: mockSend
  }
}));

// notification.service.test.ts
import { mockSend } from './__mocks__/@sentdm/sentdm';
import { NotificationService } from './notification.service';

describe('NotificationService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should send welcome message on user signup', async () => {
    // Arrange
    mockSend.mockResolvedValue({
      data: {
        messages: [{ id: 'msg_123', status: 'pending', price: 0.0125 }]
      }
    });

    const service = new NotificationService();
    const user = { phone: '+1234567890', name: 'John' };

    // Act
    await service.sendWelcomeMessage(user);

    // Assert
    expect(mockSend).toHaveBeenCalledWith({
      to: ['+1234567890'],
      template: {
        id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
        name: 'welcome',
        parameters: { name: 'John' }
      }
    });
  });

  it('should handle send failures gracefully', async () => {
    // Arrange
    const error = new Error('Template not found');
    error.status = 400;
    mockSend.mockRejectedValue(error);

    const service = new NotificationService();
    const user = { phone: '+1234567890', name: 'John' };

    // Act & Assert
    await expect(service.sendWelcomeMessage(user))
      .rejects.toThrow('Failed to send message');
  });
});
# conftest.py
import pytest
from unittest.mock import Mock

@pytest.fixture
def mock_sent():
    mock = Mock()
    mock.messages.send.return_value = Mock(
        data=Mock(
            messages=[Mock(id='msg_123', status='pending', price=0.0125)]
        )
    )
    return mock

# test_notification_service.py
import pytest
from unittest.mock import patch

def test_send_welcome_message(mock_sent):
    # Arrange
    with patch('myapp.services.SentDm', return_value=mock_sent):
        from myapp.services import NotificationService
        service = NotificationService()
        user = {'phone': '+1234567890', 'name': 'John'}

        # Act
        service.send_welcome_message(user)

        # Assert
        mock_sent.messages.send.assert_called_once_with(
            to=['+1234567890'],
            template={
                'id': '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
                'name': 'welcome',
                'parameters': {'name': 'John'}
            }
        )

def test_handle_send_failure(mock_sent):
    # Arrange
    from sent_dm import BadRequestError
    mock_sent.messages.send.side_effect = BadRequestError(
        message='Template not found',
        response=Mock(status_code=400)
    )

    with patch('myapp.services.SentDm', return_value=mock_sent):
        from myapp.services import NotificationService
        service = NotificationService()
        user = {'phone': '+1234567890', 'name': 'John'}

        # Act & Assert
        with pytest.raises(Exception) as exc_info:
            service.send_welcome_message(user)

        assert 'Template not found' in str(exc_info.value)
// mocks/sent_client.go
package mocks

import (
    "context"
    "github.com/sentdm/sent-dm-go"
)

type MockMessagesService struct {
    SendFunc func(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error)
}

func (m *MockMessagesService) Send(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) {
    return m.SendFunc(ctx, params)
}

// notification_service_test.go
func TestSendWelcomeMessage(t *testing.T) {
    // Arrange
    mockMessages := &mocks.MockMessagesService{
        SendFunc: func(ctx context.Context, params sentdm.MessageSendParams) (*sentdm.MessageSendResponse, error) {
            // Verify parameters
            if len(params.To) != 1 || params.To[0] != "+1234567890" {
                t.Errorf("Expected phone +1234567890, got %v", params.To)
            }
            return &sentdm.MessageSendResponse{
                Data: struct {
                    Messages []sentdm.Message `json:"messages"`
                }{
                    Messages: []sentdm.Message{
                        {ID: "msg_123", Status: "pending"},
                    },
                },
            }, nil
        },
    }

    service := NewNotificationService(mockMessages)
    user := User{Phone: "+1234567890", Name: "John"}

    // Act
    err := service.SendWelcomeMessage(context.Background(), user)

    // Assert
    assert.NoError(t, err)
}

Testing Error Scenarios

Test all error paths:

const errorScenarios = [
  {
    name: 'AuthenticationError',
    status: 401,
    message: 'Should throw on invalid API key',
    shouldRetry: false
  },
  {
    name: 'RateLimitError',
    status: 429,
    message: 'Should retry on rate limit',
    shouldRetry: true
  },
  {
    name: 'BadRequestError',
    status: 400,
    message: 'Should not retry on 4xx',
    shouldRetry: false
  }
];

errorScenarios.forEach(({ name, status, message, shouldRetry }) => {
  it(message, async () => {
    const error = new Error('Test error');
    (error as any).status = status;
    mockSend.mockRejectedValue(error);

    const result = await service.sendMessage({...});

    expect(result.retryAttempted).toBe(shouldRetry);
  });
});

Integration Testing

Use Test API Keys

Use separate test API keys for integration tests:

// integration/message.test.ts
import SentDm from '@sentdm/sentdm';

import SentDm from '@sentdm/sentdm';

describe('Message Integration Tests', () => {
  let client: SentDm;

  beforeAll(() => {
    // Use test API key
    client = new SentDm(process.env.SENT_DM_API_KEY_TEST!);
  });

  it('should validate message request structure with test_mode', async () => {
    // Use test_mode to validate without sending real messages
    const response = await client.messages.send({
      to: ['+1234567890'],
      template: {
        id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
        name: 'welcome'
      },
      testMode: true  // Validates but doesn't send
    });

    // Response will have test data
    expect(response.data.messages[0].id).toBeDefined();
    expect(response.data.messages[0].status).toBeDefined();
  });

  it('should handle invalid template', async () => {
    try {
      await client.messages.send({
        to: ['+1234567890'],
        template: {
          id: 'non-existent-template',
          name: 'invalid'
        }
      });

      fail('Should have thrown');
    } catch (error) {
      expect(error).toBeInstanceOf(SentDm.BadRequestError);
    }
  });
});
# integration/test_messages.py
import os
import pytest
from sent_dm import SentDm, BadRequestError

@pytest.fixture
def test_client():
    return SentDm(api_key=os.environ['SENT_DM_API_KEY_TEST'])

def test_validate_message_structure_with_test_mode(test_client):
    """Test that valid message structure is accepted using test_mode"""
    # Use test_mode to validate without sending real messages
    response = test_client.messages.send(
        to=["+1234567890"],
        template={
            "id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
            "name": "welcome"
        },
        test_mode=True  # Validates but doesn't send
    )

    # Response will have test data
    assert response.data.messages[0].id is not None
    assert response.data.messages[0].status is not None

def test_invalid_template_error(test_client):
    """Test that invalid template throws error"""
    with pytest.raises(BadRequestError) as exc_info:
        test_client.messages.send(
            to=["+1234567890"],
            template={
                "id": "non-existent-template",
                "name": "invalid"
            }
        )

    assert exc_info.value is not None
// integration/message_test.go
func TestSendMessageIntegrationWithTestMode(t *testing.T) {
    client := sentdm.NewClient(
        option.WithAPIKey(os.Getenv("SENT_DM_API_KEY_TEST")),
    )

    ctx := context.Background()

    // Use TestMode to validate without sending real messages
    response, err := client.Messages.Send(ctx, sentdm.MessageSendParams{
        To: []string{"+1234567890"},
        Template: sentdm.MessageSendParamsTemplate{
            ID:   sentdm.String("7ba7b820-9dad-11d1-80b4-00c04fd430c8"),
            Name: sentdm.String("welcome"),
        },
        TestMode: sentdm.Bool(true), // Validates but doesn't send
    })

    // Should succeed with test data
    require.NoError(t, err)
    assert.NotEmpty(t, response.Data.Messages[0].ID)
    assert.NotEmpty(t, response.Data.Messages[0].Status)
}

func TestInvalidTemplateIntegration(t *testing.T) {
    client := sentdm.NewClient(
        option.WithAPIKey(os.Getenv("SENT_DM_API_KEY_TEST")),
    )

    ctx := context.Background()

    _, err := client.Messages.Send(ctx, sentdm.MessageSendParams{
        To: []string{"+1234567890"},
        Template: sentdm.MessageSendParamsTemplate{
            ID:   sentdm.String("non-existent-template"),
            Name: sentdm.String("invalid"),
        },
    })

    require.Error(t, err)

    var apiErr *sentdm.Error
    require.True(t, errors.As(err, &apiErr))
    assert.Equal(t, 400, apiErr.StatusCode)
}

Test Webhooks Locally

Use tools like ngrok or localtunnel for webhook testing:

// webhook.test.ts
describe('Webhook Integration', () => {
  let server: Server;
  let webhookUrl: string;

  beforeAll(async () => {
    // Start local server
    server = app.listen(3001);

    // Create tunnel (using ngrok or similar)
    webhookUrl = await createTunnel(3001);
  });

  afterAll(async () => {
    server.close();
    await closeTunnel();
  });

  it('should verify webhook signature', async () => {
    const payload = JSON.stringify({
      type: 'message.delivered',
      data: { id: 'msg_123' }
    });

    const signature = generateTestSignature(payload);

    const response = await request(app)
      .post('/webhooks/sent')
      .set('X-Webhook-Signature', signature)
      .set('Content-Type', 'application/json')
      .send(payload);

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

CI/CD Testing

Environment Setup

Configure separate environments for CI/CD:

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run unit tests
        run: npm test

      - name: Run integration tests
        env:
          SENT_DM_API_KEY_TEST: ${{ secrets.SENT_DM_API_KEY_TEST }}
        run: npm run test:integration

Test Data Management

Use consistent test data:

// test/fixtures.ts
export const testFixtures = {
  validPhoneNumber: '+15555551234',  // Twilio test number
  invalidPhoneNumber: 'invalid',
  validTemplateId: 'test-welcome-template',
  invalidTemplateId: 'non-existent-template',

  mockMessage: {
    id: 'msg_test_123',
    status: 'pending',
    price: 0.0125
  }
};

// Use in tests
import { testFixtures } from './fixtures';

it('should send to valid number', async () => {
  mockSend.mockResolvedValue({
    data: {
      messages: [testFixtures.mockMessage]
    }
  });

  const result = await service.sendMessage({
    phone: testFixtures.validPhoneNumber
  });

  expect(mockSend).toHaveBeenCalledWith({
    to: [testFixtures.validPhoneNumber],
    template: {
      id: expect.any(String),
      name: expect.any(String)
    }
  });
});

Parallel Test Execution

Ensure tests can run in parallel:

// Use unique identifiers per test
it('should send message', async () => {
  const testId = `test-${Date.now()}-${Math.random()}`;
  const phoneNumber = `+1555${testId.slice(-7)}`;

  mockSend.mockResolvedValue({
    data: {
      messages: [{ id: `msg_${testId}`, status: 'pending' }]
    }
  });

  const result = await service.sendMessage({ phone: phoneNumber });

  expect(result.data.messages[0].id).toBe(`msg_${testId}`);
});

Load Testing

Test SDK behavior under load:

// load-test.ts
import SentDm from '@sentdm/sentdm';

async function loadTest() {
  const client = new SentDm(process.env.SENT_DM_API_KEY_TEST!);
  const concurrency = 10;
  const totalRequests = 100;

  console.log(`Starting load test: ${totalRequests} requests at ${concurrency} concurrency`);

  const startTime = Date.now();
  let successCount = 0;
  let errorCount = 0;

  for (let batch = 0; batch < totalRequests / concurrency; batch++) {
    const promises = Array(concurrency).fill(null).map((_, i) =>
      client.messages.send({
        to: [`+1555555${String(batch * concurrency + i).padStart(4, '0')}`],
        template: {
          id: '7ba7b820-9dad-11d1-80b4-00c04fd430c8',
          name: 'welcome'
        }
      }).then(() => {
        successCount++;
      }).catch(err => {
        errorCount++;
        console.log('Error:', err.message);
      })
    );

    await Promise.all(promises);
  }

  const duration = Date.now() - startTime;

  console.log(`\nResults:`);
  console.log(`Duration: ${duration}ms`);
  console.log(`Success: ${successCount}`);
  console.log(`Errors: ${errorCount}`);
  console.log(`RPS: ${(totalRequests / (duration / 1000)).toFixed(2)}`);
}

loadTest();

Test Coverage Checklist

Use this checklist to ensure comprehensive test coverage for your messaging integration.

Core Functionality

  • Send message with template
  • Send message with variables
  • Create contact
  • Handle successful response
  • Handle error response

Error Scenarios

  • Invalid API key
  • Rate limiting
  • Invalid template ID
  • Template not approved
  • Insufficient credits
  • Invalid phone number
  • Contact opted out

Webhooks

  • Verify webhook signature
  • Handle message.status.updated
  • Handle message.delivered
  • Handle message.failed
  • Handle duplicate events (idempotency)
  • Handle invalid signatures

Retry Logic

  • Retry on rate limit
  • Don't retry on 4xx errors
  • Exponential backoff
  • Max retry attempts

Edge Cases

  • Empty variables object
  • Special characters in message
  • Very long messages
  • Unicode/emojis
  • Concurrent requests

Comprehensive testing ensures your messaging integration works reliably in production. Aim for 80%+ code coverage with a good mix of unit and integration tests.

On this page