Testing Sentry Integration: Verify Errors Are Captured and Alerts Fire
Sentry integration is easy to break silently—a wrong DSN, a muted beforeSend hook, or a missing SDK init call can cause errors to vanish without anyone noticing. This guide covers how to verify Sentry actually captures what it should: SDK mocks in unit tests, integration tests for captureException, testing alert rules via the API, error grouping logic, and beforeSend hooks.
Key Takeaways
Silent Sentry failures are the worst kind. If your SDK is misconfigured, errors disappear. Users suffer. Nobody gets paged. Testing the integration itself is not optional.
Mock the SDK in unit tests, use real SDK in integration tests. Unit tests verify your code calls Sentry correctly. Integration tests verify Sentry receives and processes the event.
Test beforeSend hooks explicitly. They are the most common place where events accidentally get dropped, scrubbed of context, or sent with wrong data.
The Silent Failure Problem
Here is a scenario that plays out more often than anyone admits: a developer refactors the application entry point, accidentally moves the Sentry.init() call to after the first route handler is registered, and deploys. For three weeks, a class of errors in that handler silently vanishes. No alerts, no issues in the Sentry dashboard—just missing data.
Or: someone adds a beforeSend hook to strip PII and introduces a bug that returns null for every event. Sentry stops receiving anything. The team thinks production is unusually stable. It is not.
Testing Sentry integration closes this gap. Not exhaustively—you do not need to test Sentry's internals—but enough to verify your contract with the SDK.
Mocking the Sentry SDK in Unit Tests
Unit tests should run without network access. That means mocking the Sentry SDK so captureException, captureMessage, and setUser do not attempt real HTTP calls.
JavaScript / TypeScript
// __mocks__/@sentry/node.js (or @sentry/browser)
const mockSentry = {
init: jest.fn(),
captureException: jest.fn(),
captureMessage: jest.fn(),
captureEvent: jest.fn(),
setUser: jest.fn(),
setTag: jest.fn(),
setContext: jest.fn(),
addBreadcrumb: jest.fn(),
withScope: jest.fn((callback) => callback({
setTag: jest.fn(),
setContext: jest.fn(),
setExtra: jest.fn(),
setLevel: jest.fn(),
})),
Severity: { Error: 'error', Warning: 'warning', Info: 'info' },
_events: [],
};
module.exports = mockSentry;With Jest's automatic mocking via __mocks__/, any import of @sentry/node picks up this mock. Now assert that your error handler calls it correctly:
const Sentry = require('@sentry/node');
const { handlePaymentError } = require('../src/payment');
beforeEach(() => {
jest.clearAllMocks();
});
test('handlePaymentError captures exception with payment context', () => {
const error = new Error('Card declined');
const context = { userId: 'user_123', amount: 4900 };
handlePaymentError(error, context);
expect(Sentry.captureException).toHaveBeenCalledOnce();
const [capturedError, capturedOptions] = Sentry.captureException.mock.calls[0];
expect(capturedError).toBe(error);
expect(capturedOptions.tags['payment.result']).toBe('declined');
expect(capturedOptions.user.id).toBe('user_123');
});
test('handlePaymentError does not capture if error is user cancellation', () => {
const error = new Error('User cancelled');
error.code = 'USER_CANCEL';
handlePaymentError(error, { userId: 'user_123' });
expect(Sentry.captureException).not.toHaveBeenCalled();
});Python
# tests/conftest.py
import pytest
from unittest.mock import MagicMock, patch
@pytest.fixture(autouse=True)
def mock_sentry():
with patch('sentry_sdk.capture_exception') as mock_capture, \
patch('sentry_sdk.capture_message') as mock_message, \
patch('sentry_sdk.set_user') as mock_set_user, \
patch('sentry_sdk.push_scope') as mock_scope:
yield {
'capture_exception': mock_capture,
'capture_message': mock_message,
'set_user': mock_set_user,
}
# tests/test_error_handler.py
def test_payment_error_is_captured_with_context(mock_sentry):
from myapp.payments import handle_payment_error
error = ValueError("Insufficient funds")
handle_payment_error(error, user_id="u_999", amount=5000)
mock_sentry['capture_exception'].assert_called_once_with(error)Integration Testing That captureException Actually Fires
Unit tests verify your code calls Sentry. Integration tests verify Sentry receives the event. The distinction matters: a broken Sentry.init() config silently prevents all events from being sent, even if your code calls captureException correctly.
The best approach is to use Sentry's test DSN endpoint or run a local Sentry relay stub.
Here is a lightweight HTTP server that captures Sentry envelopes (the format used by Sentry SDK v7+):
# tests/fixtures/sentry_stub.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading, json
captured_envelopes = []
class SentryStubHandler(BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(length).decode('utf-8')
# Sentry envelopes: header\n{}\nitem-header\n{item}
lines = body.strip().split('\n')
captured_envelopes.append(lines)
self.send_response(200)
self.end_headers()
self.wfile.write(b'{}')
def log_message(self, *args):
pass
def start_sentry_stub(port=9999):
server = HTTPServer(('127.0.0.1', port), SentryStubHandler)
thread = threading.Thread(target=server.serve_forever)
thread.daemon = True
thread.start()
return serverPoint your application's SENTRY_DSN at the stub:
SENTRY_DSN=http://public@127.0.0.1:9999/1Now test the full path—from an actual exception being raised through to Sentry receiving the event:
def test_unhandled_exception_reaches_sentry(sentry_stub, test_client):
captured_envelopes.clear()
# Trigger a real exception in the app
response = test_client.get('/api/trigger-error')
assert response.status_code == 500
# Give the SDK time to flush (it sends asynchronously)
import time; time.sleep(0.5)
assert len(captured_envelopes) > 0, "No events reached Sentry stub"
# Parse the first event from the envelope
envelope_lines = captured_envelopes[0]
event = json.loads(envelope_lines[2]) # item is third line
assert event['level'] == 'error'
assert 'ZeroDivisionError' in event['exception']['values'][0]['type']Testing Alert Rules via the Sentry API
Sentry alert rules can be defined as code using the Sentry API. Test that your alert rules are configured correctly and will fire for the right event types.
# tests/test_sentry_alerts.py
import os
import requests
import pytest
SENTRY_AUTH_TOKEN = os.environ['SENTRY_AUTH_TOKEN']
SENTRY_ORG = os.environ['SENTRY_ORG']
SENTRY_PROJECT = os.environ['SENTRY_PROJECT']
BASE_URL = f"https://sentry.io/api/0/projects/{SENTRY_ORG}/{SENTRY_PROJECT}"
def get_alert_rules():
resp = requests.get(
f"{BASE_URL}/alert-rules/",
headers={"Authorization": f"Bearer {SENTRY_AUTH_TOKEN}"}
)
resp.raise_for_status()
return resp.json()
def test_critical_error_rate_alert_exists():
rules = get_alert_rules()
names = [r['name'] for r in rules]
assert 'Critical Error Rate > 1%' in names, \
f"Expected critical error rate alert, found: {names}"
def test_critical_alert_has_correct_threshold():
rules = get_alert_rules()
rule = next(r for r in rules if r['name'] == 'Critical Error Rate > 1%')
assert rule['triggers'][0]['alertThreshold'] == 1.0
assert rule['triggers'][0]['thresholdType'] == 0 # above threshold
assert rule['timeWindow'] == 5 # 5 minute window
def test_critical_alert_notifies_correct_channel():
rules = get_alert_rules()
rule = next(r for r in rules if r['name'] == 'Critical Error Rate > 1%')
actions = rule['triggers'][0]['actions']
slack_action = next(
(a for a in actions if a['type'] == 'slack'),
None
)
assert slack_action is not None, "Critical alert has no Slack notification"
assert slack_action['targetIdentifier'] == '#incidents'These tests run in CI against your actual Sentry project. They catch drift between your documented alerting strategy and the actual configured rules.
Testing Error Grouping Logic
Sentry groups errors by fingerprint. If your fingerprinting logic is wrong, unrelated errors get merged (obscuring distinct issues) or identical errors create thousands of separate issues (alert fatigue).
Test grouping via the fingerprint field in captureException:
// src/errors/payment-errors.js
function capturePaymentError(error, { provider, errorCode }) {
Sentry.captureException(error, {
fingerprint: ['payment-error', provider, errorCode],
tags: { 'payment.provider': provider, 'payment.code': errorCode },
});
}
// tests/payment-errors.test.js
test('same provider and error code produce identical fingerprint', () => {
capturePaymentError(new Error('Card declined'), {
provider: 'stripe',
errorCode: 'card_declined',
});
capturePaymentError(new Error('Insufficient funds'), {
provider: 'stripe',
errorCode: 'card_declined',
});
const calls = Sentry.captureException.mock.calls;
expect(calls[0][1].fingerprint).toEqual(calls[1][1].fingerprint);
});
test('different error codes produce different fingerprints', () => {
capturePaymentError(new Error('Card declined'), {
provider: 'stripe',
errorCode: 'card_declined',
});
capturePaymentError(new Error('Card expired'), {
provider: 'stripe',
errorCode: 'expired_card',
});
const fp1 = Sentry.captureException.mock.calls[0][1].fingerprint;
const fp2 = Sentry.captureException.mock.calls[1][1].fingerprint;
expect(fp1).not.toEqual(fp2);
});Testing Performance Monitoring Assertions
Sentry Performance creates transactions for each request. Test that your spans are created correctly and that slow operations are instrumented:
// tests/tracing.test.js
const Sentry = require('@sentry/node');
test('database query is wrapped in a Sentry span', async () => {
const mockSpan = {
finish: jest.fn(),
setData: jest.fn(),
setStatus: jest.fn(),
};
const mockTransaction = {
startChild: jest.fn().mockReturnValue(mockSpan),
finish: jest.fn(),
};
Sentry.getCurrentHub = jest.fn().mockReturnValue({
getScope: () => ({ getTransaction: () => mockTransaction }),
});
await fetchUserFromDatabase('user_123');
expect(mockTransaction.startChild).toHaveBeenCalledWith({
op: 'db.query',
description: expect.stringContaining('SELECT'),
});
expect(mockSpan.finish).toHaveBeenCalled();
});Testing beforeSend Hooks
The beforeSend hook is where events get filtered, enriched, or accidentally dropped. It must be tested in isolation:
// src/sentry-config.js
function beforeSend(event, hint) {
// Strip PII
if (event.user) {
delete event.user.email;
delete event.user.ip_address;
}
// Drop health check errors
if (event.request?.url?.includes('/health')) {
return null;
}
// Add deployment context
event.tags = event.tags || {};
event.tags['deploy.version'] = process.env.DEPLOY_VERSION;
return event;
}
// tests/sentry-config.test.js
const { beforeSend } = require('../src/sentry-config');
test('beforeSend removes email and IP from user context', () => {
const event = {
user: { id: 'u_123', email: 'user@example.com', ip_address: '1.2.3.4' },
exception: {},
};
const result = beforeSend(event, {});
expect(result.user.id).toBe('u_123');
expect(result.user.email).toBeUndefined();
expect(result.user.ip_address).toBeUndefined();
});
test('beforeSend drops health check errors', () => {
const event = {
request: { url: 'https://api.example.com/health' },
exception: {},
};
const result = beforeSend(event, {});
expect(result).toBeNull();
});
test('beforeSend adds deploy version tag', () => {
process.env.DEPLOY_VERSION = 'v1.2.3';
const event = { exception: {}, tags: {} };
const result = beforeSend(event, {});
expect(result.tags['deploy.version']).toBe('v1.2.3');
});
test('beforeSend returns event when no PII present', () => {
const event = { exception: {}, user: { id: 'u_456' } };
const result = beforeSend(event, {});
expect(result).not.toBeNull();
expect(result.user.id).toBe('u_456');
});Testing beforeSend thoroughly prevents the silent failures that come from accidentally returning null for events you actually need.
CI Integration Checklist
Bring these tests into CI at the right level:
- Unit tests (mock SDK): run on every commit, no network required
- Integration tests (stub server): run on every PR against a test build with
SENTRY_DSNpointing to the stub - Alert rule tests (Sentry API): run nightly or pre-deploy, require
SENTRY_AUTH_TOKEN
The goal is to make Sentry misconfiguration visible before it silences your error tracking in production.
HelpMeTest can monitor your observability stack automatically — sign up free