Testing with SDKs
Build reliable messaging features with comprehensive testing strategies for Sent SDKs. This guide covers unit testing, integration testing, mocking, and CI/CD best practices.
Testing Pyramid
For messaging integrations, follow this testing strategy:
- Unit Tests (70%) - Mock the SDK, test business logic
- Integration Tests (20%) - Use test API keys to validate API calls
- 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:integrationTest 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.