skills/sf-apex-testing/SKILL.md
Apex unit testing — test structure, TestDataFactory, governor limit testing, async testing, mocks, coverage. Use when writing tests or improving coverage. Do NOT use for TDD workflow or LWC Jest tests.
npx skillsauth add jiten-singh-shahi/salesforce-claude-code sf-apex-testingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Procedures and patterns for writing effective Apex tests. Constraint rules (never/always lists for test isolation, assertions, SeeAllData) live in sf-testing-constraints. This skill covers the how — test structure, factories, mocks, async testing, and coverage strategies.
Reference: @../_reference/TESTING_STANDARDS.md
Related: For the TDD workflow (red-green-refactor process), see
sf-tdd-workflow.
@isTest
private class AccountServiceTest {
@TestSetup
static void makeData() {
// Runs once before any test method in this class
// Each test method gets a fresh transaction with this data
}
@isTest
static void testCreateAccount_validData_createsSuccessfully() {
// Arrange / Act / Assert
}
}
Test classes do not count toward coverage calculations but DO count toward the 6 MB Apex code character limit. Use @TestVisible to make private members accessible in tests without changing access modifiers.
Runs once per test class. Each test method gets its own database rollback, so modifications in one test do not bleed into another.
@TestSetup
static void makeData() {
Account acc = new Account(
Name = 'Test Corp',
Type = 'Customer',
AnnualRevenue = 1000000,
Customer_Tier__c = 'Standard'
);
insert acc;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 10; i++) {
opps.add(new Opportunity(
Name = 'Test Opp ' + i,
AccountId = acc.Id,
StageName = i < 5 ? 'Prospecting' : 'Qualification',
CloseDate = Date.today().addDays(30 + i),
Amount = 5000 * (i + 1)
));
}
insert opps;
}
A central factory class creates test records consistently across the test suite, preventing duplicated record-creation logic.
@isTest
public class TestDataFactory {
public static Account createAccount() {
return createAccount(new Map<String, Object>());
}
public static Account createAccount(Map<String, Object> overrides) {
Account acc = new Account(
Name = 'Test Account ' + generateUniqueString(),
Type = 'Customer',
Industry = 'Technology',
AnnualRevenue = 500000,
Customer_Tier__c = 'Standard'
);
applyOverrides(acc, overrides);
insert acc;
return acc;
}
public static List<Account> createAccounts(Integer count) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(new Account(
Name = 'Bulk Test Account ' + i,
Type = 'Customer',
Customer_Tier__c = 'Standard'
));
}
insert accounts;
return accounts;
}
public static User createUserWithProfile(String profileName) {
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
User u = new User(
Alias = 'tstuser',
Email = generateUniqueString() + '@testfactory.example.com',
EmailEncodingKey = 'UTF-8',
LastName = 'Testing',
LanguageLocaleKey = 'en_US',
LocaleSidKey = 'en_US',
ProfileId = p.Id,
TimeZoneSidKey = 'America/Los_Angeles',
UserName = generateUniqueString() + '@testfactory.example.com'
);
insert u;
return u;
}
private static Integer uniqueCounter = 0;
private static String generateUniqueString() {
return String.valueOf(++uniqueCounter) + '_' +
String.valueOf(Datetime.now().getTime()).right(6);
}
private static void applyOverrides(SObject record, Map<String, Object> overrides) {
for (String fieldName : overrides.keySet()) {
record.put(fieldName, overrides.get(fieldName));
}
}
}
Every test method follows three phases with a blank line between them.
@isTest
static void testCalculateDiscount_premiumTier_returns20Percent() {
// Arrange
Account acc = TestDataFactory.createAccount(
new Map<String, Object>{ 'Customer_Tier__c' => 'Premium' }
);
Decimal orderAmount = 10000;
// Act
Test.startTest();
Decimal discount = DiscountCalculator.calculate(acc.Id, orderAmount);
Test.stopTest();
// Assert
Assert.areEqual(2000, discount,
'Premium tier accounts should receive a 20% discount on $10,000 orders');
}
Format: test{MethodName}_{scenario}_{expectedResult}
testCalculateDiscount_premiumTier_returns20Percent()
testCalculateDiscount_nullAmount_returnsZero()
testCreateAccount_duplicateName_addsFieldError()
Always test with 200 records (standard trigger batch size). A method that works with 1 record may fail at governor limits with 200.
@isTest
static void testTrigger_bulkInsert_staysWithinLimits() {
List<Account> accounts = TestDataFactory.createAccounts(200);
List<Account> processed = [
SELECT Id, Customer_Tier__c FROM Account
WHERE Id IN :new Map<Id, Account>(accounts).keySet()
];
System.assertEquals(200, processed.size(), 'All 200 accounts should be present');
}
Use Test.startTest() / Test.stopTest() to reset governor limit counters, giving the code under test a fresh limit context.
@isTest
static void testUpgradeToPremium_insufficientRevenue_throwsUpgradeException() {
Account acc = TestDataFactory.createAccount(
new Map<String, Object>{ 'AnnualRevenue' => 10000 }
);
Test.startTest();
try {
AccountsService.upgradeToPremium(new Set<Id>{ acc.Id });
Assert.fail('Expected UpgradeException was not thrown');
} catch (AccountsService.UpgradeException e) {
Assert.isTrue(
e.getMessage().contains('Annual revenue must be at least'),
'Exception message should explain the reason. Got: ' + e.getMessage()
);
}
Test.stopTest();
}
Use the Assert class (see @../_reference/API_VERSIONS.md for minimum version): Assert.areEqual, Assert.isTrue, Assert.isNotNull, Assert.fail.
@isTest
static void testViewRestrictedReport_standardUser_throwsException() {
User standardUser = TestDataFactory.createUserWithProfile('Standard User');
Restricted_Report__c report = new Restricted_Report__c(Name = 'Confidential Q4');
insert report;
Test.startTest();
System.runAs(standardUser) {
try {
ReportService.viewReport(report.Id);
Assert.fail('Standard user should not be able to view restricted reports');
} catch (ReportService.AccessDeniedException e) {
Assert.isTrue(true, 'Expected AccessDeniedException thrown correctly');
}
}
Test.stopTest();
}
Test.startTest() / Test.stopTest() forces @future and Queueable jobs to execute synchronously.
@isTest
static void testFutureCallout_sendsRequest() {
Test.setMock(HttpCalloutMock.class, new MockERPCallout(200, '{"status":"ok"}'));
Account acc = TestDataFactory.createAccount();
Test.startTest();
ExternalDataSync.syncAccountToERP(acc.Id);
Test.stopTest();
List<Integration_Error_Log__c> errors = [
SELECT Id FROM Integration_Error_Log__c WHERE Account__c = :acc.Id
];
System.assertEquals(0, errors.size(), 'No errors should be logged');
}
@isTest
static void testBatch_processesAllRecords() {
// insert 200 records
Test.startTest();
Database.executeBatch(new AccountAnnualReviewBatch(), 200);
Test.stopTest(); // start(), execute(), finish() all run synchronously
// Assert results
}
@isTest
public class MockERPCallout implements HttpCalloutMock {
private Integer statusCode;
private String responseBody;
public MockERPCallout(Integer statusCode, String responseBody) {
this.statusCode = statusCode;
this.responseBody = responseBody;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(statusCode);
res.setBody(responseBody);
res.setHeader('Content-Type', 'application/json');
return res;
}
}
@isTest
public class MultiCalloutMock implements HttpCalloutMock {
private Map<String, HttpResponse> responses = new Map<String, HttpResponse>();
public MultiCalloutMock addResponse(String urlPattern, Integer statusCode, String body) {
HttpResponse res = new HttpResponse();
res.setStatusCode(statusCode);
res.setBody(body);
responses.put(urlPattern, res);
return this;
}
public HttpResponse respond(HttpRequest req) {
for (String pattern : responses.keySet()) {
if (req.getEndpoint().contains(pattern)) {
return responses.get(pattern);
}
}
throw new CalloutException('No mock response configured for: ' + req.getEndpoint());
}
}
For mocking dependencies without HTTP, use the System.StubProvider interface with Test.createStub().
IAccountsSelector mockSelector = (IAccountsSelector)
Test.createStub(IAccountsSelector.class, new MockAccountsSelector(mockAccounts));
Note: The instance field must be typed as the interface (e.g.,
IAccountsSelector), not the concrete class. Casting a stub proxy to a concrete class throws TypeException at runtime.
Line coverage is misleading. A method with an if statement can show 100% line coverage if you only test the true branch. Test every branch.
// This method has 4 branches — test each:
// 1. testCalculateDiscount_premiumTier
// 2. testCalculateDiscount_standardTier
// 3. testCalculateDiscount_unknownTier (else)
// 4. testCalculateDiscount_nullTier
public Decimal calculateDiscount(String tier, Decimal amount) {
if (tier == 'Premium') return amount * 0.20;
else if (tier == 'Standard') return amount * 0.10;
else return 0;
}
Requires the minimum API version for this feature (see @../_reference/API_VERSIONS.md). If
sfdx-project.jsonspecifies asourceApiVersionbelow it,RunRelevantTestssilently falls back toRunLocalTests.
@testFor explicitly declares which production class a test class covers, improving RunRelevantTests selection accuracy.
@isTest
@testFor(AccountService)
private class AccountServiceTest {
// tests for AccountService
}
Rules: Reference any top-level Apex class (not inner classes). Cannot be placed on non-test classes. Use separate test classes for each target — Apex does not support stacking duplicate annotations.
sf-apex-agent — For interactive, in-depth guidancesf-tdd-workflow — TDD workflow for Apexsf-testing-constraints — Enforces test isolation, assertion requirements, SeeAllData prohibition, and coverage thresholdsdevelopment
Update Salesforce platform reference docs with latest release features and deprecation announcements. Use when SessionStart hook warns docs are outdated or a new Salesforce release has shipped. Do NOT use for Apex or LWC development.
development
Use when syncing documentation after Salesforce Apex code changes. Update README, API docs, and deploy metadata references to match the current org codebase.
development
Use when managing context during long Salesforce Apex development sessions. Suggests manual compaction at logical intervals to preserve deploy and org context across phases.
tools
Visualforce development — pages, controllers, extensions, ViewState, JS Remoting, LWC migration. Use when maintaining VF pages, building PDFs, or planning VF-to-LWC migration. Do NOT use for LWC, Aura, or Flow.