Apex Testing in Salesforce: Complete Guide (2026)
If you’ve ever pushed broken code to a Salesforce production org, you already know the sinking feeling. Suddenly your governor limit exception is somebody’s support ticket, and you’re tracing logic through 3,000 lines of untested Apex at 11pm. Sound familiar?
Good testing habits change this entirely. Apex testing in Salesforce isn’t just a platform requirement — it’s the difference between code you trust and code you fear. This guide walks you through everything: the fundamentals, test data management, mocking, coverage strategies, and the advanced techniques that separate good developers from great ones.
Table of Contents
- Why Testing Matters
- Fundamentals of Apex Testing
- Test Data Management
- Testing Different Apex Types
- Mock Callouts
- Code Coverage Strategies
- Advanced Testing Techniques
- Test Class Organization
- Debugging Failed Tests
- Testing Checklist
- FAQ
Why Testing Matters
Let’s start with the honest answer: Salesforce requires 75% code coverage before you can deploy to production. But if that’s the only reason you’re writing tests, you’re leaving enormous value on the table.
Testing in Apex serves three distinct purposes, and coverage is actually the least important of them.

Read the official Salesforce Documentation around Apex testing here.
Code Coverage Requirements
Salesforce mandates that at least 75% of your Apex code is executed by your test classes before deployment. Every trigger you write must have at least 1% coverage individually. These aren’t arbitrary numbers — they reflect a platform-level commitment to org stability across millions of customer deployments.
In 2025, Salesforce’s deployment pipeline enforces these requirements through both the Metadata API and Salesforce CLI. If you fall below the threshold, your deployment fails. Full stop.
That said, hitting 75% by writing tests that just exercise happy paths doesn’t protect you from much. A test suite that achieves 76% coverage by testing the easy branches leaves your error-handling, bulk logic, and edge cases completely exposed.
Quality Over Quantity
The tests worth writing are the ones that would actually catch a regression. Ask yourself: what would break if someone changed this method?
A well-written test for an account assignment trigger doesn’t just run the trigger — it inserts 200 records (the bulk test), verifies the field values are correct, and then asserts that nothing unexpected changed on related records. That kind of test catches real bugs.
I’ve seen orgs with 95% coverage and no meaningful assertions — just a sprawling test class that inserts records and calls System.assert(true). That’s coverage theater, not quality assurance. Your future self (and your teammates) deserve better.
Regression Testing Importance
Every bug you fix should come with a test. This sounds obvious, but it’s the discipline that prevents the same bug from recurring six months later when someone refactors the method.
Think of your test suite as a safety net that grows more valuable over time. Each new test case is a constraint on the system: this behavior must remain true. When a deployment causes a test failure, that failure is doing its job — it’s telling you something changed that shouldn’t have.
Fundamentals of Apex Testing
Salesforce Apex has a built-in testing framework that runs in an isolated context. No external tools required. Here’s how it works.
@isTest Annotation
The @isTest annotation is how you tell Salesforce that a class or method is part of your test suite. Classes marked with @isTest don’t count against your org’s code size limit, and they can’t be called from production code — only from other test contexts.
Apex Code
@isTest
private class AccountServiceTest {
// Your test methods live here
}
You can also annotate individual methods within a non-test class, though this pattern is less common. The cleaner approach is keeping test logic entirely in dedicated test classes.
One important detail: @isTest classes run in their own transaction context by default. They don’t see your org’s existing data unless you explicitly query for it using @isTest(seeAllData=true) — but more on why you should almost never do that in the data management section.
Test Classes and Methods
Each test method must be annotated with @isTest (or use the older testMethod keyword, which you should avoid in new code). Methods must be static, and they can’t accept parameters.
Apex Code
@isTest
private class OpportunityTriggerTest {
@isTest
static void testOpportunityInsert_setsDefaultStage() {
// Arrange
Opportunity opp = new Opportunity(
Name = 'Test Opp',
CloseDate = Date.today().addDays(30),
StageName = 'Prospecting'
);
// Act
Test.startTest();
insert opp;
Test.stopTest();
// Assert
Opportunity result = [SELECT StageName FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals('Needs Analysis', result.StageName,
'Stage should have been updated by trigger');
}
}
Notice the Test.startTest() and Test.stopTest() calls. These reset governor limits for the code inside the block, which means your test gets a fresh set of SOQL queries, DML statements, and CPU time. Always wrap the code under test in this block.
Assertions
Assertions are where your tests actually verify behavior. Without them, you’re just running code and hoping nothing throws an exception.
Salesforce provides System.assertEquals(), System.assertNotEquals(), and System.assert(). In API version 57.0 and later, the new Assert class is the preferred approach:
Apex Code
// Old style (still works)
System.assertEquals(expected, actual, 'Helpful message here');
// New Assert class (API v57.0+, preferred in 2025)
Assert.areEqual(expected, actual, 'Helpful message here');
Assert.isNotNull(result.Id, 'Record should have been inserted');
Assert.isTrue(result.IsActive, 'Account should be active after processing');
Always include the message parameter. When a test fails at 2am during a deployment, that message is the difference between a 30-second fix and a 30-minute investigation.
Test Data Management
This is where most developers go wrong — and where you can get a real competitive advantage in code quality.
@testSetup Methods
The @testSetup annotation lets you create test data once for an entire test class. The data is rolled back after each test method, but it’s only inserted once — which significantly speeds up your test execution.
Apex Code
@isTest
private class ContactServiceTest {
@testSetup
static void setupTestData() {
Account acc = new Account(Name = 'Test Corp');
insert acc;
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < 10; i++) {
contacts.add(new Contact(
LastName = 'Test ' + i,
AccountId = acc.Id
));
}
insert contacts;
}
@isTest
static void testContactUpdate() {
// Account and Contacts are already available — query them
Account acc = [SELECT Id FROM Account LIMIT 1];
// ... rest of test
}
}
One caveat: @testSetup data can’t be modified between tests. If your test needs to start with a different state, create the data inside the individual test method instead.
Test Data Factories
For any org with more than a handful of test classes, you need a test data factory — a utility class that centralizes record creation logic. This pattern eliminates duplication and makes your tests resilient to required field changes.
Apex Code
@isTest
public class TestDataFactory {
public static Account createAccount(String name, Boolean doInsert) {
Account acc = new Account(
Name = name,
Industry = 'Technology',
BillingCity = 'San Francisco'
);
if (doInsert) insert acc;
return acc;
}
public static List<Opportunity> createOpportunities(
Id accountId, Integer count, Boolean doInsert
) {
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < count; i++) {
opps.add(new Opportunity(
Name = 'Test Opp ' + i,
AccountId = accountId,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30 + i)
));
}
if (doInsert) insert opps;
return opps;
}
}
When a required field gets added to Account, you update createAccount() once — not across 47 test classes.
Test.loadData()
For complex test scenarios, Salesforce lets you load data from CSV files stored as Static Resources. Test.loadData() reads the file and inserts the records for you.
Apex Code
@isTest
static void testBulkProcessing() {
List<sObject> records = Test.loadData(Account.sObjectType, 'AccountTestData');
// records now contains whatever was in your CSV
Assert.areEqual(50, records.size(), 'Should have loaded 50 test accounts');
}
This approach works well when you need realistic data with complex field combinations that are tedious to build programmatically. The downside is that CSV files are harder to maintain and can drift out of sync with your schema over time. Use @testSetup with a factory for most cases, and reserve Test.loadData() for genuinely complex scenarios.
Testing Different Apex Types
Different Apex contexts require slightly different testing approaches.
Testing Triggers
Trigger tests are straightforward — you just perform the DML operation that fires the trigger and then assert the expected outcome. The key is testing bulk scenarios, not just single-record cases.
Apex Code
@isTest
static void testLeadTrigger_bulkInsert() {
List<Lead> leads = new List<Lead>();
for (Integer i = 0; i < 200; i++) {
leads.add(new Lead(
LastName = 'Test ' + i,
Company = 'Bulk Corp',
Status = 'Open - Not Contacted'
));
}
Test.startTest();
insert leads;
Test.stopTest();
List<Lead> inserted = [SELECT Id, LeadSource FROM Lead WHERE Company = 'Bulk Corp'];
Assert.areEqual(200, inserted.size(), 'All leads should have been inserted');
for (Lead l : inserted) {
Assert.areEqual('Web', l.LeadSource, 'LeadSource should be set by trigger');
}
}
200 records is the trigger batch size for bulk DML — always test at that boundary.
Testing Classes
For service classes and utility methods, test each public method independently. Focus on the contracts your methods expose: given this input, produce this output.
Test both the happy path and the expected error paths. If your method throws a custom exception for invalid input, write a test that verifies the exception is thrown with the correct message.
Apex Code
@isTest
static void testAccountService_throwsOnNullId() {
try {
AccountService.getAccountDetails(null);
Assert.fail('Should have thrown InvalidParameterException');
} catch (AccountService.InvalidParameterException e) {
Assert.isTrue(e.getMessage().contains('Account ID'),
'Exception message should mention Account ID');
}
}
Testing Batch Jobs
Batch Apex requires you to use Test.startTest() and Test.stopTest() — Salesforce processes the batch synchronously within that block during tests.
Apex Code
@isTest
static void testAccountCleanupBatch() {
// Create some stale accounts
List<Account> staleAccounts = new List<Account>();
for (Integer i = 0; i < 5; i++) {
staleAccounts.add(new Account(Name = 'Stale ' + i));
}
insert staleAccounts;
Test.startTest();
Database.executeBatch(new AccountCleanupBatch(), 200);
Test.stopTest();
// After stopTest(), batch has completed synchronously
Integer remaining = [SELECT COUNT() FROM Account WHERE Name LIKE 'Stale%'];
Assert.areEqual(0, remaining, 'Stale accounts should have been deleted');
}
Note that in test context, your batch runs as a single chunk regardless of the batch size you specify.
Testing Queueable
Queueable jobs also execute synchronously inside Test.startTest() / Test.stopTest().
Apex Code
@isTest
static void testContactSyncQueueable() {
Contact c = new Contact(LastName = 'Queue Test');
insert c;
Test.startTest();
System.enqueueJob(new ContactSyncQueueable(c.Id));
Test.stopTest();
Contact updated = [SELECT Sync_Status__c FROM Contact WHERE Id = :c.Id];
Assert.areEqual('Synced', updated.Sync_Status__c,
'Contact should be marked as synced after job completes');
}
Mock Callouts
You can’t make real HTTP callouts from Apex tests — Salesforce blocks them. Instead, you implement the HttpCalloutMock interface to intercept the callout and return a controlled response.
Apex Code
@isTest
global class MockHttpCallout implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest request) {
HttpResponse response = new HttpResponse();
response.setHeader('Content-Type', 'application/json');
response.setBody('{"status": "success", "id": "12345"}');
response.setStatusCode(200);
return response;
}
}
Then in your test:
Apex Code
@isTest
static void testExternalApiIntegration_success() {
Test.setMock(HttpCalloutMock.class, new MockHttpCallout());
Test.startTest();
ExternalApiService.syncRecord('001000000000001');
Test.stopTest();
// Assert the outcome based on the mocked response
External_Log__c log = [SELECT Status__c FROM External_Log__c LIMIT 1];
Assert.areEqual('Success', log.Status__c);
}
For more complex scenarios — error responses, timeouts, different endpoints returning different payloads — create a configurable mock class that accepts parameters in its constructor. This keeps your test code clean and your mock logic reusable.
Testing StaticResourceCalloutMock is another option when you need to mock a large response payload (like a full JSON API response). Store the JSON as a Static Resource and reference it in your test:
Apex Code
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
mock.setStaticResource('MockAPIResponse');
mock.setStatusCode(200);
mock.setHeader('Content-Type', 'application/json');
Test.setMock(HttpCalloutMock.class, mock);
Code Coverage Strategies
Hitting 75% isn’t hard. Maintaining meaningful coverage as your org grows is where it gets interesting.
Start with your critical paths. If your revenue recognition logic has a bug, that’s a P0 incident. Test that code thoroughly — not just for coverage, but for correctness at edge cases. High-value, high-risk code deserves 95%+ coverage with multiple test scenarios.
Test bulk behavior explicitly. A huge portion of Apex bugs only appear when processing more than one record at a time — specifically because of SOQL queries inside loops, list indexing errors, or map key collisions. Always test with 200 records.
Track coverage trends over time. A single developer pushing untested code can drop your org-wide coverage below 75% and block everyone’s deployments. Use Salesforce CLI’s sf apex get coverage and incorporate coverage checks into your CI/CD pipeline.
Don’t chase the number. If you find yourself writing System.assert(true) or inserting records without any assertion, stop. That test is worthless. Delete it and write a meaningful test for one fewer branch of logic instead.
Advanced Testing Techniques
Once you’ve got the fundamentals down, these techniques will take your test suite to the next level.
Testing with different user contexts. Use System.runAs() to test permission-sensitive code:
Apex Code
@isTest
static void testRestrictedAccess_standardUser() {
User standardUser = [SELECT Id FROM User
WHERE Profile.Name = 'Standard User'
AND IsActive = true LIMIT 1];
System.runAs(standardUser) {
try {
RestrictedService.performAdminAction();
Assert.fail('Standard user should not have access');
} catch (System.NoAccessException e) {
// Expected
}
}
}
Testing with custom metadata and custom settings. Custom Metadata records are visible in test context without any special setup. Custom Settings, however, require you to insert records in your test:
Apex Code
@testSetup
static void setupCustomSettings() {
Feature_Flags__c flags = new Feature_Flags__c(
SetupOwnerId = UserInfo.getOrganizationId(),
Enable_Beta_Feature__c = true
);
insert flags;
}
Testing scheduled jobs. Similar to batch and queueable, scheduled jobs execute synchronously in test context:
Apex Code
Test.startTest();
String jobId = System.schedule('Test Job', '0 0 0 * * ?', new MyScheduledJob());
Test.stopTest();
// Job has run — assert outcomes here
Test Class Organization
Consistent organization makes your test suite maintainable as the org scales.
One test class per production class or trigger. Name your test class after the class it tests: AccountService → AccountServiceTest. This makes it immediately obvious what each test class covers.
Use descriptive method names. A method named test1() tells you nothing. A method named testOpportunityStageUpdate_whenAmountExceedsThreshold_setsHighValueFlag() tells you everything. Yes, it’s verbose — but when that test fails in a deployment six months from now, you’ll thank yourself.
Group related tests with @testSetup. If multiple test methods share the same initial data state, they belong in the same class with a shared @testSetup block. If they need fundamentally different starting conditions, consider a separate test class.
Keep test classes focused. A test class with 50 methods is a sign that either the production class is too large, or unrelated tests have accumulated in one place. Aim for test classes that are cohesive — all testing the same production class or the same feature area.
Debugging Failed Tests
When tests fail, here’s a systematic approach that saves time.
Read the assertion message first. If you wrote good assertion messages (you did, right?), the failure message tells you exactly what went wrong. Expected: Synced, Actual: null immediately points to the sync logic not running, rather than the assertion being wrong.
Check governor limits. If you’re seeing System.LimitException: Too many SOQL queries: 101, your code under test has a query-in-a-loop problem that only surfaces with bulk data. This is a bug, not a test problem — fix the production code.
Use System.debug() strategically. Add debug statements in the production code or test class, run the test, and check the debug log in Setup → Apex Jobs, or in VS Code with the Salesforce Extension Pack. Filter by DEBUG level to cut through the noise.
Isolate the failure. Comment out assertions one at a time to identify exactly which check is failing. Then work backwards: was the data created correctly? Did the trigger fire? Did the method run?
Check for data isolation issues. If a test passes in isolation but fails when run as part of a full suite, you likely have a data pollution problem — another test’s @testSetup data is leaking into yours. Avoid @isTest(seeAllData=true), and make your queries specific enough that they only return your test data.
Use the Developer Console’s test runner. Select specific test classes to run rather than the full suite during debugging. It’s faster and easier to isolate failures.
Conclusion
Apex testing is one of those skills that compounds. Every well-written test you add today makes tomorrow’s refactoring safer, tomorrow’s deployment more confident, and tomorrow’s on-call rotation calmer.
The fundamentals — @isTest, proper assertions, bulk testing, mocks — are learnable in an afternoon. The judgment to write tests that actually catch real bugs takes longer, but it’s built one test at a time.
Start with your highest-risk code. Build a test data factory if you don’t have one. Wire up a coverage check in your CI/CD pipeline. Then make it a habit: no production code goes in without a corresponding test.
Testing Checklist
Use this before every deployment:
- All new Apex classes have a corresponding test class
- All triggers are tested with both single-record and 200-record bulk scenarios
- All test methods use
Test.startTest()/Test.stopTest()around the code under test - All assertions include a descriptive failure message
- HTTP callouts use
HttpCalloutMock— no live callouts in tests @isTest(seeAllData=true)is NOT used (unless absolutely unavoidable with a documented reason)- Test data is created entirely within test context (factory or
@testSetup) - Error paths and exception cases are tested, not just happy paths
System.runAs()is used for any permission-sensitive logic- Org-wide code coverage is ≥ 75% after your changes (check before pushing)
- No assertion-less tests (
System.assert(true)or unchecked inserts) - Scheduled jobs, batch classes, and queueable jobs are tested for functional correctness

Read the official Salesforce Documentation on code coverage best practices here.
FAQ
What is the minimum code coverage required to deploy Apex in Salesforce? Salesforce requires at least 75% code coverage across all Apex classes and triggers in your org before deploying to production. Individual triggers must have at least some coverage. Note that test classes themselves (@isTest) don’t count toward your org’s code size or coverage calculations.
What is the @isTest annotation in Salesforce? The @isTest annotation marks a class or method as part of Salesforce’s test framework. Test classes don’t count against your org’s Apex code size limit and run in an isolated context that doesn’t modify real production data. Any class or method annotated with @isTest cannot be called from non-test Apex code.
Should I use @isTest(seeAllData=true) in my test classes? Almost never. seeAllData=true lets your tests access real org data, which makes tests fragile (they break when data changes), environment-dependent (tests pass in production but fail in sandbox), and potentially destructive (DML operations could affect live records). Always create your own test data within the test context instead.
How do I test HTTP callouts in Apex? Salesforce blocks real HTTP callouts during test execution. Implement the HttpCalloutMock interface to intercept and return a controlled response. Use Test.setMock(HttpCalloutMock.class, yourMock) before calling the code that makes the callout. For large response payloads, StaticResourceCalloutMock lets you store the mock response as a Static Resource.
How do I achieve good code coverage without just writing coverage-filling tests? Focus on testing behavior, not lines. For each public method, ask: what are the valid inputs? What should the output be? What should happen if the input is invalid? Write one test per meaningful scenario, not one test per line of code. Tests that verify actual business outcomes — field values changed correctly, records created with right relationships, exceptions thrown with right messages — naturally achieve high coverage as a side effect.
What’s the difference between @testSetup and creating data in each test method? @testSetup runs once per test class and rolls back data between test methods, which is faster than creating data fresh in each method. Use it when multiple test methods share the same initial data state. Create data inside individual test methods when you need unique starting conditions, or when the setup logic is very simple and would be clearer in-context.
Why do my Apex tests pass individually but fail when run together? This usually indicates data interference between tests. Common causes: queries without specific filters returning records from another test’s @testSetup, hardcoded names that collide across test classes, or @isTest(seeAllData=true) picking up data that varies by execution order. Tighten your queries to filter on unique fields you control, and avoid seeAllData=true.
Source- https://claude.ai/chat/820d665d-12bc-4c8a-b995-1cb39af3ef18