Table of Contents
- Why Test Classes Exist in Salesforce
- The @isTest Annotation and Class Structure
- @testSetup — Shared Test Data
- System.assert, assertEquals, assertNotEquals, and assertThrows
- Test.startTest() and Test.stopTest() — Governor Limits & Async Flush
- Test Data Factory Pattern
- Mocking HTTP Callouts with HttpCalloutMock
- Testing Async Apex — Future, Queueable, Batch, Scheduled
1 Why Test Classes Exist in Salesforce
Salesforce enforces automated testing as a platform-level gate. Before you can deploy any Apex code to a production org, the platform runs every test class in that org and verifies a minimum code-coverage threshold. This is not optional or advisory — the deployment pipeline literally refuses to proceed if the tests fail or coverage falls short.
Beyond the deployment gate, well-written tests serve as a safety net for future changes: they catch regressions before they reach users and document the intended behaviour of your code with executable precision. A test class that only chases coverage numbers without asserting anything meaningful is worse than no test at all — it gives a false sense of security.
What counts as a covered line?
A line is counted as covered when a test execution causes that line to be evaluated — meaning the JVM actually executes it. Comments, blank lines, and class/method declaration lines themselves do not count toward the denominator. Only executable statements matter. You can view per-class coverage in Setup → Apex Test Execution → View Test Results, or via the Salesforce CLI with sf apex run test --code-coverage.
| Scenario | Coverage Required? | Notes |
|---|---|---|
| Deploy Apex class to production | 75% org-wide | Combined average across all classes and triggers |
| Deploy Apex trigger to production | Must have coverage | At least one test must execute the trigger body |
| Deploy to sandbox | Not enforced | Tests still run but coverage gate does not block deployment |
| Scratch org / CI pipeline | Configurable | Use --code-coverage flag to enforce a threshold in CI |
| Managed package release | 75% per class | ISV packaging enforces 75% individually per class |
2 The @isTest Annotation and Class Structure
The @isTest annotation tells the Apex compiler that the class (or method) is only for testing purposes. Test classes do not count against your org's Apex code size limit of 6 MB. They cannot be called from production code — the compiler enforces this. Individual test methods are identified by applying @isTest to the method itself (historically you could also use the testMethod keyword, but the annotation is the modern standard).
@isTest
private class AccountServiceTest {
// Runs once, data is rolled back + re-snapshotted between each test method
@testSetup
static void setup() {
Account acc = new Account(Name = 'Test Corp', Industry = 'Technology');
insert acc;
}
@isTest
static void testGetAccountName_returnsCorrectName() {
Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Test Corp' LIMIT 1];
Test.startTest();
String result = AccountService.getAccountName(acc.Id);
Test.stopTest();
System.assertEquals('Test Corp', result, 'Expected account name to match');
}
@isTest
static void testGetAccountName_nullId_returnsNull() {
Test.startTest();
String result = AccountService.getAccountName(null);
Test.stopTest();
System.assertNull(result, 'Null Id should return null');
}
}
Key rules for test classes
- Test classes must be declared
private(orpublic— both are allowed, butprivateis idiomatic since they are never called externally). - Every test method must be
static void. They cannot accept parameters or return values. - A single test class can contain up to 500 test methods (practical limit imposed by the runner).
- Test classes do not require a constructor. The test runner instantiates nothing — it calls each
@isTest static voidmethod directly. - You can nest private inner test helper classes inside a test class for grouping, but they cannot be
@isTestthemselves. - The
@isTest(SeeAllData=true)parameter disables test data isolation — avoid it except for very specific legacy scenarios (e.g., testing against custom metadata or certain setup objects that cannot be created in test context).
@isTest(SeeAllData=true) depend on real org data. They fail in orgs where that data does not exist (scratch orgs, new sandboxes), making CI unreliable. Always create explicit test data instead.Naming conventions
Salesforce does not enforce a naming convention, but the community has converged on the following patterns:
- Class name:
ClassUnderTestTestorClassUnderTest_Test— suffix withTest. - Method name:
testMethodName_scenario_expectedOutcome— include the method being tested, the input scenario, and what you expect. For example:testGetAccountName_nullId_returnsNull.
Descriptive method names make it immediately obvious which scenario failed when you look at a test run report, without reading the code.
3 @testSetup — Shared Test Data
The @testSetup annotation marks a static void method that runs once before any @isTest methods execute. All records inserted in this method are committed to the test database, and then a snapshot is taken. Before each individual test method runs, the database is rolled back to that snapshot — so every test starts with an identical, clean set of data, without re-running the setup code.
This is a significant performance win for classes with many test methods that share common prerequisites (e.g., a standard Account, a set of Contacts, a price book). Without @testSetup, each test method would re-insert all that data, multiplying DML operations by the number of methods.
@isTest
private class OpportunityServiceTest {
@testSetup
static void setup() {
// Use a factory so test data creation is centralised
Account acc = TestDataFactory.createAccount('Acme Corp', true);
List<Opportunity> opps = TestDataFactory.createOpportunities(acc.Id, 5, true);
}
@isTest
static void testCloseOpportunity_setsStageToClosedWon() {
// Query from the snapshot — fresh for this test
Opportunity opp = [SELECT Id, StageName FROM Opportunity LIMIT 1];
Test.startTest();
OpportunityService.closeOpportunity(opp.Id);
Test.stopTest();
opp = [SELECT StageName FROM Opportunity WHERE Id = :opp.Id];
System.assertEquals('Closed Won', opp.StageName, 'Stage should be Closed Won');
}
@isTest
static void testGetOpenOpportunities_returnsOnlyOpen() {
// This test also starts with the same fresh 5 Opportunities from @testSetup
Test.startTest();
List<Opportunity> results = OpportunityService.getOpenOpportunities();
Test.stopTest();
System.assertEquals(5, results.size(), 'Should return all 5 open opps');
}
}
Test.startTest() or Test.stopTest() inside a @testSetup method — that pair is only valid within individual @isTest test methods. Also, @testSetup cannot be used if the class has @isTest(SeeAllData=true).When to use @testSetup vs. inline data creation
Use @testSetup when multiple test methods in the same class share the same prerequisite records. Use inline data creation (inside the test method itself) when the data setup is specific to that one test, or when you intentionally want different data for different tests. Mixing both is fine — @testSetup handles the shared baseline and each test method creates only what is unique to it.
4 System.assert, assertEquals, assertNotEquals, and assertThrows
Assertions are the reason test classes exist. A test that inserts records and runs code but never asserts anything does not verify correctness — it only verifies that the code does not throw an unhandled exception. Every meaningful test method should contain at least one assertion that verifies the expected outcome.
In API version 58.0 (Summer '23), Salesforce introduced a modern assertion library under the Assert class. The classic System.assert* methods still work but the Assert class methods produce more readable failure messages.
| Method (Classic) | Method (Modern Assert class) | What it checks |
|---|---|---|
System.assert(condition) |
Assert.isTrue(condition) |
Condition is true |
System.assert(!condition) |
Assert.isFalse(condition) |
Condition is false |
System.assertEquals(expected, actual) |
Assert.areEqual(expected, actual) |
Expected equals actual |
System.assertNotEquals(notExpected, actual) |
Assert.areNotEqual(notExpected, actual) |
Values are not equal |
System.assertEquals(null, actual) |
Assert.isNull(actual) |
Value is null |
System.assertNotEquals(null, actual) |
Assert.isNotNull(actual) |
Value is not null |
| n/a — use try/catch | Assert.fail(message) |
Unconditionally fail the test |
System.assertEquals, the first parameter is the expected value and the second is the actual value. Getting these backwards does not change whether the test passes or fails, but it makes the failure message misleading: it will say "Expected: [actual value], Actual: [expected value]" — confusing everyone who reads the report.Testing for expected exceptions
When you expect your code to throw a specific exception under certain conditions, you need to verify that the exception is thrown — and that it carries the right message or type. The classic pattern uses a try/catch with a System.assert(false) call that only runs if no exception was thrown:
@isTest
static void testProcessOrder_invalidAmount_throwsException() {
Boolean exceptionThrown = false;
String exceptionMessage = '';
Test.startTest();
try {
OrderService.processOrder(null, -100);
} catch (OrderService.InvalidOrderException e) {
exceptionThrown = true;
exceptionMessage = e.getMessage();
}
Test.stopTest();
System.assert(exceptionThrown, 'Expected InvalidOrderException to be thrown');
System.assert(
exceptionMessage.contains('Amount must be positive'),
'Exception message should describe the problem'
);
}
Always include assertion messages
Every System.assertEquals and System.assert call accepts an optional third (or second) string message parameter. Always provide it. When a test fails in a CI pipeline at 2 AM, a message like 'Contact count should equal number of inserted records after trigger' is vastly more useful than the default 'Assertion failed'.
5 Test.startTest() and Test.stopTest() — Governor Limits & Async Flush
Test.startTest() and Test.stopTest() are two of the most important — and most misunderstood — methods in Apex testing. They serve two distinct, independent purposes that happen to be delivered by the same pair of calls.
Purpose 1: Governor limit reset
Salesforce governor limits apply to the entire test method execution. Code that runs before Test.startTest() — such as data setup that queries or performs DML — consumes governor limits. By calling Test.startTest(), you get a fresh, clean set of limits for the code under test. This means your setup code and your test subject each get their own bucket of SOQL queries, DML statements, CPU time, and so on.
Purpose 2: Synchronous flush of async operations
When Test.stopTest() is called, the platform forces all asynchronous work that was enqueued after Test.startTest() to execute synchronously before stopTest() returns. This includes:
@futuremethods — normally run after the transaction completesQueueablejobs — normally run in a separate asynchronous contextDatabase.executeBatch()— the batch job executes synchronouslySystem.schedule()— the scheduled job fires immediately
This is why you must always put your assertions after Test.stopTest() when you are testing async code. Before stopTest returns, the async work has not happened yet.
@isTest
static void testSendWelcomeEmail_futureSentAfterInsert() {
// Setup code — runs before startTest, consumes its own governor limit bucket
Account acc = new Account(Name = 'Test Inc');
insert acc;
Contact con = new Contact(
FirstName = 'Test', LastName = 'User',
AccountId = acc.Id, Email = 'test@example.com'
);
insert con;
Test.startTest();
// Code under test — gets a fresh governor limit bucket
// This method internally calls a @future method to send email
ContactService.sendWelcomeEmail(con.Id);
Test.stopTest();
// stopTest() flushes the @future method synchronously before returning
// Safe to assert here — the future method has already run
Contact result = [SELECT Welcome_Email_Sent__c FROM Contact WHERE Id = :con.Id];
System.assertEquals(true, result.Welcome_Email_Sent__c, 'Email sent flag should be set');
}
@isTest
static void testSendWelcomeEmail_WRONG() {
Contact con = [SELECT Id FROM Contact LIMIT 1];
Test.startTest();
ContactService.sendWelcomeEmail(con.Id);
// BUG: asserting HERE means the @future method has NOT run yet!
// This assertion will always see the old value and may produce a false pass or false failure.
Contact result = [SELECT Welcome_Email_Sent__c FROM Contact WHERE Id = :con.Id];
System.assertEquals(true, result.Welcome_Email_Sent__c); // wrong placement!
Test.stopTest();
}
Test.startTest() / Test.stopTest() pair per test method. Calling startTest() a second time throws a runtime error: System.AsyncException: startTest/stopTest may only be used once per test method.6 Test Data Factory Pattern
The Test Data Factory (also called a Test Utility class) is a dedicated Apex class, annotated with @isTest, that provides static methods for creating standard SObjects. Instead of duplicating Account or Contact creation code across hundreds of test methods, every test class calls the factory. When field requirements change (a new required field is added to Account), you update the factory in one place.
Factory methods typically accept a boolean doInsert parameter — callers that need the record in the database pass true; callers who want to set additional fields before inserting pass false and call insert themselves.
@isTest
public class TestDataFactory {
public static Account createAccount(String name, Boolean doInsert) {
Account acc = new Account(
Name = name,
Industry = 'Technology',
Phone = '555-0100',
BillingCity = 'San Francisco'
);
if (doInsert) insert acc;
return acc;
}
public static Contact createContact(Id accountId, String lastName, Boolean doInsert) {
Contact con = new Contact(
FirstName = 'Test',
LastName = lastName,
AccountId = accountId,
Email = lastName.toLowerCase() + '@testexample.com'
);
if (doInsert) insert con;
return con;
}
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),
Amount = 1000 * (i + 1)
));
}
if (doInsert) insert opps;
return opps;
}
public static User createStandardUser(String lastName) {
Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
User u = new User(
LastName = lastName,
FirstName = 'Test',
Alias = lastName.left(8),
Email = lastName.toLowerCase() + '@testexample.com',
Username = lastName.toLowerCase() + '@testexample.com.test',
ProfileId = p.Id,
TimeZoneSidKey = 'America/Los_Angeles',
LocaleSidKey = 'en_US',
EmailEncodingKey = 'UTF-8',
LanguageLocaleKey = 'en_US'
);
insert u;
return u;
}
}
Benefits of the factory pattern
- Single point of change: When a new required field is added to Account in your org, you update
TestDataFactory.createAccount()once and every test class benefits. - Readability: Test methods focus on the scenario they are testing, not on the plumbing of building valid SObjects with all required fields populated.
- Bulk testing: Factory methods that accept a
countparameter make it easy to create large datasets for governor limit testing — insert 200 Contacts with a single call and verify that your trigger handles bulk operations correctly. - Flexibility: The
doInsertflag lets callers customise the object before committing it to the database, while still benefiting from the factory's field defaults.
public (so other test classes can reference it), non-test code cannot call it — the compiler enforces this.7 Mocking HTTP Callouts with HttpCalloutMock
Salesforce blocks real outbound HTTP callouts during test execution. If your Apex code calls an external REST API and you run a test without setting up a mock, you will get a runtime error: System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out. — or in many cases the more explicit: Callouts are not allowed from within test methods.
The solution is to implement the HttpCalloutMock interface and register it with Test.setMock(). The platform intercepts any HttpRequest sent during the test and routes it to your mock's respond() method instead of the real network.
@isTest
global class MockWeatherApiResponse implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest req) {
// Inspect the request if needed — validate the endpoint, method, headers
System.assert(
req.getEndpoint().contains('api.weather.example.com'),
'Wrong endpoint called'
);
System.assertEquals('GET', req.getMethod(), 'Expected GET request');
// Build the fake response
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setBody('{"temperature": 22, "condition": "Sunny", "city": "London"}');
res.setStatusCode(200);
return res;
}
}
@isTest
static void testGetWeather_returnsTemperature() {
// Register the mock BEFORE calling Test.startTest()
Test.setMock(HttpCalloutMock.class, new MockWeatherApiResponse());
Test.startTest();
WeatherService.WeatherResult result = WeatherService.getWeather('London');
Test.stopTest();
System.assertEquals(22, result.temperature, 'Temperature should be 22');
System.assertEquals('Sunny', result.condition, 'Condition should be Sunny');
}
Multi-callout mocking
If your code makes multiple callouts to different endpoints within a single transaction, you can register multiple mocks by using a single dispatcher mock that inspects the request URL and delegates to specialised inner mock instances:
@isTest
global class MultiEndpointMock implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest req) {
String endpoint = req.getEndpoint();
if (endpoint.contains('/weather')) {
HTTPResponse res = new HTTPResponse();
res.setStatusCode(200);
res.setBody('{"temperature":18}');
return res;
} else if (endpoint.contains('/currency')) {
HTTPResponse res = new HTTPResponse();
res.setStatusCode(200);
res.setBody('{"rate":1.25}');
return res;
}
// Fail loudly for unexpected endpoints
HTTPResponse err = new HTTPResponse();
err.setStatusCode(404);
err.setBody('Unexpected endpoint: ' + endpoint);
return err;
}
}
StaticResourceCalloutMock
For large or complex JSON/XML response bodies, you can store the payload in a Static Resource and use StaticResourceCalloutMock instead of embedding the string in Apex code. Create a Static Resource with your JSON body, then:
@isTest
static void testGetProductCatalog_parsesAllProducts() {
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
mock.setStaticResource('MockProductCatalogResponse'); // Name of your Static Resource
mock.setStatusCode(200);
mock.setHeader('Content-Type', 'application/json');
Test.setMock(HttpCalloutMock.class, mock);
Test.startTest();
List<Product2> products = ProductSyncService.syncFromApi();
Test.stopTest();
System.assert(products.size() > 0, 'Products should have been synced');
}
statusCode = 500 or a malformed body, and verify that your service layer handles the failure gracefully rather than throwing an uncaught exception.8 Testing Async Apex — Future, Queueable, Batch, Scheduled
Async Apex — future methods, Queueable jobs, Batch Apex, and Scheduled Apex — all run outside the synchronous transaction context. In production, the platform queues them and executes them later. In test context, Test.stopTest() forces them to run synchronously so your assertions can verify their effects.
Testing @future methods
@isTest
static void testUpdateAccountRating_futureRunsOnStopTest() {
Account acc = TestDataFactory.createAccount('Async Test Corp', true);
Test.startTest();
// AccountRatingService.recalculate() is a @future method
AccountRatingService.recalculate(acc.Id);
Test.stopTest();
// By the time we reach here, the @future has executed
Account updated = [SELECT Rating FROM Account WHERE Id = :acc.Id];
System.assertEquals('Hot', updated.Rating, 'Rating should be recalculated to Hot');
}
Testing Queueable jobs
@isTest
static void testSyncContactsQueueable_processesBatch() {
List<Contact> contacts = TestDataFactory.createContacts(50, true);
List<Id> contactIds = new List<Id>();
for (Contact c : contacts) contactIds.add(c.Id);
Test.startTest();
System.enqueueJob(new SyncContactsQueueable(contactIds));
Test.stopTest();
// stopTest() runs the queueable synchronously
List<Contact> synced = [
SELECT Sync_Status__c FROM Contact WHERE Id IN :contactIds
];
for (Contact c : synced) {
System.assertEquals('Synced', c.Sync_Status__c, 'Each contact should be marked Synced');
}
}
Testing Batch Apex
Batch Apex is tested the same way — wrap Database.executeBatch() in the startTest/stopTest pair. One important constraint: the default batch size in test execution is 200 records regardless of the size you specify. You can override it by passing a second parameter to Database.executeBatch() during the test — but you cannot exceed 200 in test context.
@isTest
static void testContactCleanupBatch_archivesOldContacts() {
// Create 100 contacts that are more than 2 years old
List<Contact> oldContacts = new List<Contact>();
for (Integer i = 0; i < 100; i++) {
oldContacts.add(new Contact(
LastName = 'Old' + i,
Last_Active__c = Date.today().addDays(-800)
));
}
insert oldContacts;
Test.startTest();
Database.executeBatch(new ContactCleanupBatch(), 200);
Test.stopTest();
// All three methods (start, execute, finish) have run by here
Integer archivedCount = [
SELECT COUNT() FROM Contact WHERE Is_Archived__c = true
];
System.assertEquals(100, archivedCount, 'All 100 old contacts should be archived');
}
Testing Scheduled Apex
@isTest
static void testDailyReportScheduler_runsExecute() {
Test.startTest();
// Schedule the job — it will fire when stopTest() is called
String cronExp = '0 0 8 * * ?'; // 8 AM every day
String jobId = System.schedule(
'Test Daily Report',
cronExp,
new DailyReportScheduler()
);
Test.stopTest();
// Verify the job was scheduled (and executed)
List<CronTrigger> triggers = [
SELECT Id, CronExpression, State
FROM CronTrigger
WHERE Id = :jobId
];
System.assertEquals(1, triggers.size(), 'Scheduled job should exist');
}
| Async Type | How to invoke in test | Flushed by stopTest()? | Special considerations |
|---|---|---|---|
@future |
Call the static method normally | Yes | Cannot be called from another future method |
| Queueable | System.enqueueJob(new MyJob()) |
Yes | Chained queueables require separate test methods |
| Batch Apex | Database.executeBatch(new MyBatch()) |
Yes | Max batch size 200 in test; all 3 methods run synchronously |
| Scheduled Apex | System.schedule('name', cron, new MyScheduler()) |
Yes | CronTrigger records are queryable; always abort test jobs in cleanup |
| Platform Event subscriber | Publish the event with EventBus.publish() |
Partial | Use Test.enableChangeDataCapture() for CDC; delivery is not always synchronous |
execute() method, only the first job runs during Test.stopTest(). Chained jobs are not flushed. Test each job class independently, or restructure your code to allow testing without chaining.Testing triggers
Triggers are exercised implicitly — you do not call the trigger directly. You perform DML operations (insert, update, delete, upsert) on the SObject the trigger fires on, and the platform fires the trigger as part of that DML. Your test should verify the state of records after the DML to confirm the trigger logic ran correctly.
@isTest
static void testAccountTrigger_setsDefaultIndustryOnInsert() {
// Account with no Industry set — trigger should default it
Account acc = new Account(Name = 'No Industry Corp');
Test.startTest();
insert acc; // This fires the before insert trigger
Test.stopTest();
Account result = [SELECT Industry FROM Account WHERE Id = :acc.Id];
System.assertEquals('Other', result.Industry, 'Trigger should default Industry to Other');
}
@isTest
static void testAccountTrigger_bulkInsert_200records() {
// Always test bulk scenarios — triggers must handle 200 records
List<Account> accs = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accs.add(new Account(Name = 'Bulk Account ' + i));
}
Test.startTest();
insert accs; // Fires trigger once with all 200 records in Trigger.new
Test.stopTest();
Integer countWithIndustry = [
SELECT COUNT() FROM Account WHERE Industry = 'Other' AND Id IN :accs
];
System.assertEquals(200, countWithIndustry, 'All 200 accounts should have default industry');
}
Practice Writing Apex Test Classes
Sharpen your testing skills on real Apex problems. ApexArena gives you immediate feedback on your code, code coverage metrics, and best-practice hints — exactly like a real Salesforce development workflow.
Write Your First Test Class
Start with a working Apex class and write the test class from scratch. Get instant coverage feedback.
Start practicing →Mock HTTP Callouts in Tests
Practice implementing HttpCalloutMock for REST integrations and verify your parser logic end-to-end.
Try this challenge →Test Batch & Future Methods
Learn the startTest/stopTest pattern for async Apex with hands-on batch and future method problems.
Start challenge →Build a Test Data Factory
Create a reusable TestDataFactory from scratch for a multi-object Salesforce data model.
Try this problem →Frequently Asked Questions
More Salesforce Tutorials
Apex Triggers — Complete Guide
Events, context variables, bulkification, handler patterns, recursive guards.
Read tutorial →SOQL & SOSL Tutorial
Queries, relationships, aggregate functions, semi-joins, and best practices.
Read tutorial →Batch Apex Tutorial
Database.Batchable, start/execute/finish, chaining, and scheduling batches.
Read tutorial →Salesforce Apex Interview Questions
50+ frequently asked Salesforce developer interview questions and answers.
Read guide →