How to Test Exceptions and Try-Catch Blocks in Salesforce Apex
Master testing exception handling in Salesforce Apex with this comprehensive guide. Learn how to test try-catch blocks, DML exceptions, and hard-to-reach code paths with practical examples.
TL;DR
Testing exception handling in Apex is crucial for production stability and code coverage. To test try-catch blocks, deliberately trigger exceptions with invalid data, use the Assert class to verify exception messages, and leverage @TestVisible flags for hard-to-reach error paths. Always test both happy paths (code works) and sad paths (code handles errors).
Why Testing Exceptions Matters
Imagine you're driving a car. The gas pedal makes you go (the happy path), but the brakes keep you safe when things go wrong (the sad path). In Salesforce development, exception handling is your brake system. Testing it ensures your code handles problems gracefully instead of crashing in production.
What You'll Learn
In this guide, you'll discover how to:
- Write test methods that verify exception handling works correctly
- Test different types of exceptions (DML, Query, Governor Limits)
- Reach difficult-to-test code paths in catch blocks
- Use advanced techniques for complex exception scenarios
Understanding Exceptions in Apex
An exception is Salesforce's way of saying "something went wrong." Think of it like a fire alarm in a building. When smoke is detected (an error occurs), the alarm sounds (exception is thrown) so people can respond appropriately (your catch block handles it).
Common Exception Types
// DML Exception - Database operation fails
try {
Account acc = new Account(); // Missing required Name field
insert acc;
} catch (DmlException e) {
System.debug('Database error: ' + e.getMessage());
}
// Query Exception - No records found
try {
Account acc = [SELECT Id FROM Account WHERE Name = 'NonExistent'];
} catch (QueryException e) {
System.debug('No records found: ' + e.getMessage());
}
// Custom Exception - Your own error type
try {
if (accountBalance < 0) {
throw new InsufficientFundsException('Balance cannot be negative');
}
} catch (InsufficientFundsException e) {
System.debug('Custom error: ' + e.getMessage());
}The Basic Pattern for Testing Exceptions
When testing exception handling, you follow this pattern:
- Arrange: Set up data that will cause an exception
- Act: Call the method and wrap it in try-catch
- Assert: Verify the exception was thrown with the right message
Example: Testing a DML Exception
Here's a service class that creates accounts:
public class AccountService {
public static void createAccount(String accountName) {
try {
Account acc = new Account(Name = accountName);
insert acc;
} catch (DmlException e) {
// Log the error for debugging
System.debug('Failed to create account: ' + e.getMessage());
// Re-throw so caller knows it failed
throw new AccountCreationException('Could not create account: ' + accountName);
}
}
}
// Custom exception class
public class AccountCreationException extends Exception {}Now let's test both the happy path (it works) and sad path (it fails):
@isTest
public class AccountServiceTest {
@isTest
static void testCreateAccount_Success() {
// Happy path - account is created successfully
Test.startTest();
AccountService.createAccount('Test Account');
Test.stopTest();
// Verify account was created
Account acc = [SELECT Name FROM Account WHERE Name = 'Test Account'];
Assert.areEqual('Test Account', acc.Name, 'Account should be created');
}
@isTest
static void testCreateAccount_ExceptionHandling() {
// Sad path - trigger an exception by exceeding DML limits
Test.startTest();
Boolean exceptionThrown = false;
try {
// Pass null name to cause DML exception
AccountService.createAccount(null);
// If we reach here, no exception was thrown (bad!)
Assert.fail('Expected AccountCreationException to be thrown');
} catch (AccountCreationException e) {
exceptionThrown = true;
// Verify the exception message contains expected text
Assert.isTrue(e.getMessage().contains('Could not create account'),
'Exception message should explain the failure');
}
Test.stopTest();
// Final verification
Assert.isTrue(exceptionThrown, 'Exception should have been thrown and caught');
}
}Key Testing Pattern Explained
Notice the pattern in the sad path test:
- We deliberately cause an error (pass
nullas account name) - We wrap the call in try-catch to capture the exception
- We verify the exception was thrown using
Assert.isTrue() - We check the exception message to ensure it's helpful
Exception Testing: Bad, Alright, and Good Approaches
Let's look at three different ways to test a validation rule exception, ranked from worst to best. This progression shows how to properly structure exception tests.
BAD Approach - Checking Exception Inside Catch Block
@isTest
static void BAD_check_for_validation_rule() {
try {
insert myRecord;
} catch (Exception e) {
// Problem: If no exception is thrown, this test passes silently!
Assert.isTrue(e.getMessage().contains('FIELD_CUSTOM_VALIDATION_EXCEPTION'),
'Validation rule did not fire');
}
}Why this is bad: If myRecord inserts successfully (no exception), the test still passes because the catch block never runs. You won't know your validation rule isn't working!
ALRIGHT Approach - Using a Boolean Flag
@isTest
static void ALRIGHT_check_for_validation_rule() {
Boolean errorDetected = false;
try {
insert myRecord;
} catch (Exception e) {
errorDetected = true;
}
Assert.isTrue(errorDetected, 'Validation rule did not fire');
}Why this is better: The test will fail if no exception is thrown. However, you're not verifying which exception occurred. This could catch any exception (DML, Query, Null Pointer), not just your validation rule.
GOOD Approach - Capture and Verify Exception Message
@isTest
static void GOOD_check_for_validation_rule() {
String errorMessage = '';
try {
insert myRecord;
} catch (Exception e) {
errorMessage = e.getMessage();
}
Assert.isTrue(errorMessage.contains('FIELD_CUSTOM_VALIDATION_EXCEPTION'),
'Validation rule did not fire');
}Why this is best:
- The test fails if no exception occurs (errorMessage stays empty)
- You verify the specific exception message
- You confirm the right validation rule fired
- The assertion happens outside the catch block, so it always runs
Real-World Example: Testing Account Validation
Here's a complete example using the GOOD pattern:
// Validation rule: Account Annual Revenue must be positive
@isTest
static void testAccountValidation_NegativeRevenue() {
String errorMessage = '';
Account acc = new Account(
Name = 'Test Account',
AnnualRevenue = -5000 // Should trigger validation
);
Test.startTest();
try {
insert acc;
} catch (DmlException e) {
errorMessage = e.getMessage();
}
Test.stopTest();
// Verify the specific validation rule fired
Assert.isTrue(errorMessage.contains('Annual Revenue must be positive'),
'Expected validation rule to prevent negative revenue');
}Key Takeaway
Always assert outside the catch block! This ensures your test fails if no exception is thrown, and you can verify the exact exception that occurred.
Testing Different Exception Types
1. Testing DML Exceptions
DML exceptions happen when database operations fail. Test them by creating invalid data:
public class ContactService {
public static void createContactWithAccount(String firstName, String lastName, Id accountId) {
try {
Contact con = new Contact(
FirstName = firstName,
LastName = lastName,
AccountId = accountId // Required field for our business logic
);
insert con;
} catch (DmlException e) {
System.debug('DML Error: ' + e.getDmlMessage(0));
throw new ContactCreationException('Failed to create contact: ' + e.getMessage());
}
}
}
public class ContactCreationException extends Exception {}Test method:
@isTest
static void testCreateContact_InvalidAccountId() {
Test.startTest();
Boolean caughtException = false;
try {
// Use a fake Account Id that doesn't exist
Id fakeAccountId = '001000000000000AAA';
ContactService.createContactWithAccount('John', 'Doe', fakeAccountId);
Assert.fail('Should have thrown ContactCreationException');
} catch (ContactCreationException e) {
caughtException = true;
// DML exceptions include specific error messages
Assert.isTrue(e.getMessage().contains('Failed to create contact'),
'Exception should contain error details');
}
Test.stopTest();
Assert.isTrue(caughtException, 'DML exception should have been caught and re-thrown');
}2. Testing Query Exceptions
Query exceptions occur when SOQL queries fail. Test them by querying non-existent records:
public class AccountLookupService {
public static Account getAccountByName(String accountName) {
try {
// This will throw QueryException if no record found
return [SELECT Id, Name FROM Account WHERE Name = :accountName LIMIT 1];
} catch (QueryException e) {
System.debug('Query failed: ' + e.getMessage());
return null; // Return null instead of throwing
}
}
}Test method:
@isTest
static void testGetAccountByName_NotFound() {
Test.startTest();
// Query for account that doesn't exist
Account result = AccountLookupService.getAccountByName('NonExistentAccount');
Test.stopTest();
// Verify the catch block returned null instead of crashing
Assert.isNull(result, 'Should return null when account not found');
}3. Testing Custom Exceptions
Custom exceptions let you create meaningful error types for your business logic:
public class OpportunityService {
public static void closeOpportunity(Id oppId, Decimal amount) {
if (amount <= 0) {
throw new InvalidAmountException('Opportunity amount must be greater than zero');
}
try {
Opportunity opp = [SELECT Id, Amount FROM Opportunity WHERE Id = :oppId];
opp.Amount = amount;
opp.StageName = 'Closed Won';
update opp;
} catch (QueryException e) {
throw new OpportunityNotFoundException('Opportunity not found: ' + oppId);
} catch (DmlException e) {
throw new OpportunityUpdateException('Failed to update opportunity: ' + e.getMessage());
}
}
}
// Custom exception classes
public class InvalidAmountException extends Exception {}
public class OpportunityNotFoundException extends Exception {}
public class OpportunityUpdateException extends Exception {}Test method:
@isTest
static void testCloseOpportunity_InvalidAmount() {
// Create test opportunity
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
insert opp;
Test.startTest();
Boolean caughtException = false;
try {
// Pass negative amount to trigger custom exception
OpportunityService.closeOpportunity(opp.Id, -100);
Assert.fail('Should throw InvalidAmountException');
} catch (InvalidAmountException e) {
caughtException = true;
Assert.isTrue(e.getMessage().contains('must be greater than zero'),
'Should explain the validation rule');
}
Test.stopTest();
Assert.isTrue(caughtException, 'Custom exception should have been thrown');
}Advanced Testing Techniques
Technique 1: Using @TestVisible for Hard-to-Test Exceptions
Sometimes exceptions are hard to trigger naturally. Use @TestVisible flags to force exceptions in tests:
public class PaymentProcessor {
@TestVisible
private static Boolean throwExceptionForTest = false;
public static void processPayment(Decimal amount, String paymentMethod) {
// Test hook to simulate payment gateway failure
if (Test.isRunningTest() && throwExceptionForTest) {
throw new PaymentException('Payment gateway unavailable');
}
try {
// Call external payment gateway (simplified)
Http http = new Http();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://payment-gateway.example.com/charge');
req.setMethod('POST');
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
throw new PaymentException('Payment failed: ' + res.getBody());
}
} catch (CalloutException e) {
throw new PaymentException('Could not connect to payment gateway: ' + e.getMessage());
}
}
}
public class PaymentException extends Exception {}Test method using the flag:
@isTest
static void testProcessPayment_GatewayFailure() {
Test.startTest();
// Set the test flag to force exception
PaymentProcessor.throwExceptionForTest = true;
Boolean caughtException = false;
try {
PaymentProcessor.processPayment(100.00, 'Credit Card');
Assert.fail('Should throw PaymentException');
} catch (PaymentException e) {
caughtException = true;
Assert.isTrue(e.getMessage().contains('Payment gateway unavailable'),
'Should indicate gateway issue');
}
Test.stopTest();
Assert.isTrue(caughtException, 'Payment exception should be thrown when flag is set');
}Technique 2: Testing Governor Limit Exceptions
Governor limits protect Salesforce's multi-tenant architecture. Here's how to test code that handles limit exceptions:
public class BulkDataProcessor {
public static void processLargeDataSet(List<Account> accounts) {
try {
// Check if we're approaching SOQL query limit
if (Limits.getQueries() > 90) {
throw new LimitException('Approaching SOQL query limit');
}
// Process accounts
List<Contact> contactsToUpdate = new List<Contact>();
for (Account acc : accounts) {
List<Contact> contacts = [SELECT Id, AccountId FROM Contact WHERE AccountId = :acc.Id];
contactsToUpdate.addAll(contacts);
}
update contactsToUpdate;
} catch (LimitException e) {
System.debug('Governor limit hit: ' + e.getMessage());
// Handle gracefully - maybe batch the work
throw new ProcessingException('Too much data to process at once: ' + e.getMessage());
}
}
}
public class LimitException extends Exception {}
public class ProcessingException extends Exception {}Test the limit handling:
@isTest
static void testProcessLargeDataSet_LimitException() {
// Create enough accounts to potentially hit limits
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 100; i++) {
accounts.add(new Account(Name = 'Test Account ' + i));
}
insert accounts;
Test.startTest();
Boolean caughtException = false;
try {
// This will hit SOQL limits due to query in loop
BulkDataProcessor.processLargeDataSet(accounts);
} catch (ProcessingException e) {
caughtException = true;
Assert.isTrue(e.getMessage().contains('Too much data'),
'Should explain the limit issue');
}
Test.stopTest();
// Note: May or may not hit limits depending on org, but tests the pattern
}Technique 3: Testing Exceptions in Triggers
Triggers need special testing because they fire automatically. Here's a trigger with exception handling:
trigger AccountTrigger on Account (before insert, before update) {
try {
AccountTriggerHandler.handleBeforeInsertOrUpdate(Trigger.new);
} catch (Exception e) {
// Add error to all records to prevent DML
for (Account acc : Trigger.new) {
acc.addError('An error occurred: ' + e.getMessage());
}
}
}
public class AccountTriggerHandler {
public static void handleBeforeInsertOrUpdate(List<Account> accounts) {
for (Account acc : accounts) {
// Business rule: Name must not contain 'TEST' in production
if (!Test.isRunningTest() && acc.Name.containsIgnoreCase('TEST')) {
throw new ValidationException('Account name cannot contain TEST');
}
// Another validation: Annual Revenue must be positive
if (acc.AnnualRevenue != null && acc.AnnualRevenue < 0) {
throw new ValidationException('Annual Revenue cannot be negative');
}
}
}
}
public class ValidationException extends Exception {}Test the trigger's exception handling:
@isTest
static void testAccountTrigger_ValidationException() {
Test.startTest();
Boolean caughtDmlException = false;
try {
// Create account with negative revenue to trigger exception
Account acc = new Account(
Name = 'Valid Name',
AnnualRevenue = -1000
);
insert acc;
Assert.fail('Should throw DmlException with validation error');
} catch (DmlException e) {
caughtDmlException = true;
// Trigger added error to record, which causes DmlException
Assert.isTrue(e.getMessage().contains('Annual Revenue cannot be negative'),
'Should contain validation message');
}
Test.stopTest();
Assert.isTrue(caughtDmlException, 'Trigger should prevent insert and throw DmlException');
}Real-World Example: Service Class with Multiple Exception Types
Here's a complete example showing a service class that handles different exceptions:
public class OrderFulfillmentService {
public static void fulfillOrder(Id orderId) {
Order orderRecord;
// Step 1: Get the order
try {
orderRecord = [
SELECT Id, Status, TotalAmount, AccountId
FROM Order
WHERE Id = :orderId
];
} catch (QueryException e) {
throw new OrderNotFoundException('Order not found: ' + orderId);
}
// Step 2: Validate order status
if (orderRecord.Status == 'Fulfilled') {
throw new OrderAlreadyFulfilledException('Order already fulfilled: ' + orderId);
}
// Step 3: Check inventory (simplified)
try {
checkInventory(orderId);
} catch (InsufficientInventoryException e) {
throw new FulfillmentException('Cannot fulfill - out of stock: ' + e.getMessage());
}
// Step 4: Update order status
try {
orderRecord.Status = 'Fulfilled';
update orderRecord;
} catch (DmlException e) {
throw new FulfillmentException('Failed to update order: ' + e.getMessage());
}
}
private static void checkInventory(Id orderId) {
// Simplified inventory check
Integer stockLevel = 0; // Assume we queried this from Inventory object
if (stockLevel <= 0) {
throw new InsufficientInventoryException('No stock available');
}
}
}
// Custom exceptions
public class OrderNotFoundException extends Exception {}
public class OrderAlreadyFulfilledException extends Exception {}
public class InsufficientInventoryException extends Exception {}
public class FulfillmentException extends Exception {}Complete test class covering all exception paths:
@isTest
public class OrderFulfillmentServiceTest {
@TestSetup
static void setup() {
// Create test account
Account acc = new Account(Name = 'Test Account');
insert acc;
// Create test order
Order ord = new Order(
AccountId = acc.Id,
Status = 'Draft',
EffectiveDate = Date.today(),
TotalAmount = 1000
);
insert ord;
}
@isTest
static void testFulfillOrder_OrderNotFound() {
Test.startTest();
Boolean caughtException = false;
try {
// Use fake Order Id
Id fakeOrderId = '801000000000000AAA';
OrderFulfillmentService.fulfillOrder(fakeOrderId);
Assert.fail('Should throw OrderNotFoundException');
} catch (OrderNotFoundException e) {
caughtException = true;
Assert.isTrue(e.getMessage().contains('Order not found'),
'Should indicate order was not found');
}
Test.stopTest();
Assert.isTrue(caughtException, 'OrderNotFoundException should be thrown');
}
@isTest
static void testFulfillOrder_AlreadyFulfilled() {
Order ord = [SELECT Id FROM Order LIMIT 1];
ord.Status = 'Fulfilled';
update ord;
Test.startTest();
Boolean caughtException = false;
try {
OrderFulfillmentService.fulfillOrder(ord.Id);
Assert.fail('Should throw OrderAlreadyFulfilledException');
} catch (OrderAlreadyFulfilledException e) {
caughtException = true;
Assert.isTrue(e.getMessage().contains('already fulfilled'),
'Should indicate order is already fulfilled');
}
Test.stopTest();
Assert.isTrue(caughtException, 'Should not allow fulfilling already fulfilled orders');
}
@isTest
static void testFulfillOrder_Success() {
Order ord = [SELECT Id, Status FROM Order LIMIT 1];
Test.startTest();
OrderFulfillmentService.fulfillOrder(ord.Id);
Test.stopTest();
// Verify order was fulfilled
ord = [SELECT Status FROM Order WHERE Id = :ord.Id];
Assert.areEqual('Fulfilled', ord.Status, 'Order should be marked as fulfilled');
}
}Best Practices for Testing Exceptions
1. Always Test Both Happy and Sad Paths
For every method, write tests for:
- Happy path: The code works as expected
- Sad path: The code handles errors gracefully
2. Use Descriptive Assertion Messages
// Bad - unclear what failed
Assert.isTrue(caughtException);
// Good - explains what should have happened
Assert.isTrue(caughtException, 'DmlException should be thrown when Account name is null');3. Verify Exception Messages
Don't just check if an exception was thrown—verify it contains useful information:
catch (CustomException e) {
Assert.isTrue(e.getMessage().contains('expected text'),
'Exception message should be helpful for debugging');
}4. Use Test.startTest() and Test.stopTest()
These methods reset governor limits, giving you a fresh context:
Test.startTest();
// Your test code here - gets fresh governor limits
Test.stopTest();5. Don't Overuse Try-Catch in Tests
Only use try-catch when you're specifically testing exception handling. For normal tests, let exceptions propagate naturally so Salesforce reports them clearly.
Common Mistakes to Avoid
Mistake 1: Asserting Inside the Catch Block
// Bad - test passes silently if no exception is thrown!
try {
someMethodThatThrows();
} catch (Exception e) {
Assert.isTrue(e.getMessage().contains('expected'),
'Exception should contain expected message');
}
// Good - capture message and assert outside catch block
String errorMessage = '';
try {
someMethodThatThrows();
} catch (Exception e) {
errorMessage = e.getMessage();
}
Assert.isTrue(errorMessage.contains('expected'),
'Exception should have been thrown with expected message');Why this matters: When you assert inside the catch block, the test passes if no exception occurs! Always capture the exception details and assert outside the catch block.
Mistake 2: Using Assert.fail() Without a Message
// Bad - unclear why the assertion failed
try {
methodThatShouldThrow();
Assert.fail(); // What was expected?
} catch (Exception e) {
// ...
}
// Good - clear expectation
try {
methodThatShouldThrow();
Assert.fail('Expected CustomException to be thrown');
} catch (CustomException e) {
// Expected path
}Mistake 3: Catching Too General Exceptions
// Bad - catches everything, masks actual exception type
catch (Exception e) {
// Could be any exception
}
// Good - catch specific exception types
catch (DmlException e) {
// Handle DML errors specifically
} catch (QueryException e) {
// Handle query errors specifically
}Mistake 4: Not Testing the Catch Block Logic
If your catch block has logic (logging, re-throwing, transforming), test it:
// Production code
catch (DmlException e) {
System.debug('Error occurred: ' + e.getMessage());
ErrorLogger.log(e); // Make sure this is tested!
throw new CustomException('Failed to save: ' + e.getMessage());
}Mistake 5: Forgetting to Test Trigger Exception Handling
Triggers need special attention because they run automatically. Always test what happens when triggers throw exceptions.
Conclusion
Testing exception handling is like practicing fire drills—you hope you never need it, but you'll be grateful you prepared when something goes wrong. By testing both happy and sad paths, you ensure your code handles errors gracefully and provides helpful feedback when things don't go as planned.
Key Takeaways
- Always test exception handling - It's crucial for production stability
- Use try-catch in tests when specifically testing error paths
- Verify exception messages to ensure they're helpful
- Test all exception types your code might encounter
- Use @TestVisible flags for hard-to-reach error paths
- Cover both paths - success scenarios and failure scenarios
Next Steps
Now that you understand exception testing, practice by:
- Reviewing your existing code for untested catch blocks
- Adding sad path tests to achieve higher code coverage
- Creating custom exceptions for your business logic
- Testing trigger exception handling in your org
Keep building your Salesforce development skills, and remember: a well-tested codebase is a reliable codebase!
About Warren Walters
Salesforce MVP and transformative mentor with 8+ years in the Salesforce realm. Founder of Lightning Challenge, dedicated to nurturing the next generation of Salesforce talent through hands-on practice and real-world coding challenges.
Visit Profile →