Install
$ agentstack add skill-jiten-singh-shahi-salesforce-claude-code-sf-apex-testing ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Apex Testing
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/TESTINGSTANDARDS.md
When to Use
- When writing test classes for new Apex code before deploying to production
- When existing tests pass but only cover happy paths
- When a deployment fails with "insufficient code coverage" errors
- When building mock patterns for callouts or dependency injection
> Related: For the TDD workflow (red-green-refactor process), see sf-tdd-workflow.
@isTest Annotation
@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.
@TestSetup
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 opps = new List();
for (Integer i = 0; i ());
}
public static Account createAccount(Map 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 createAccounts(Integer count) {
List accounts = new List();
for (Integer i = 0; i overrides) {
for (String fieldName : overrides.keySet()) {
record.put(fieldName, overrides.get(fieldName));
}
}
}
Test Structure: Arrange-Act-Assert
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{ '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');
}
Test Method Naming
Format: test{MethodName}_{scenario}_{expectedResult}
testCalculateDiscount_premiumTier_returns20Percent()
testCalculateDiscount_nullAmount_returnsZero()
testCreateAccount_duplicateName_addsFieldError()
Governor Limit Testing
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 accounts = TestDataFactory.createAccounts(200);
List processed = [
SELECT Id, Customer_Tier__c FROM Account
WHERE Id IN :new Map(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.
Exception Testing
@isTest
static void testUpgradeToPremium_insufficientRevenue_throwsUpgradeException() {
Account acc = TestDataFactory.createAccount(
new Map{ 'AnnualRevenue' => 10000 }
);
Test.startTest();
try {
AccountsService.upgradeToPremium(new Set{ 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/APIVERSIONS.md for minimum version): Assert.areEqual, Assert.isTrue, Assert.isNotNull, Assert.fail.
Permission Testing with System.runAs
@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();
}
Async Testing
@future and Queueable
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 errors = [
SELECT Id FROM Integration_Error_Log__c WHERE Account__c = :acc.Id
];
System.assertEquals(0, errors.size(), 'No errors should be logged');
}
Batch Apex
@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
}
Mock Patterns
HttpCalloutMock
@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;
}
}
Multi-Callout Mock
@isTest
public class MultiCalloutMock implements HttpCalloutMock {
private Map responses = new Map();
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());
}
}
Stub API (System.StubProvider)
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.
Coverage Strategy
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;
}
RunRelevantTests and @testFor (Spring '26)
> Requires the minimum API version for this feature (see @../reference/APIVERSIONS.md). If sfdx-project.json specifies a sourceApiVersion below it, RunRelevantTests silently falls back to RunLocalTests.
@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.
Related
- Agent:
sf-apex-agent— For interactive, in-depth guidance - Skills:
sf-tdd-workflow— TDD workflow for Apex
Guardrails
sf-testing-constraints— Enforces test isolation, assertion requirements, SeeAllData prohibition, and coverage thresholds
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: jiten-singh-shahi
- Source: jiten-singh-shahi/salesforce-claude-code
- License: MIT
- Homepage: https://www.npmjs.com/package/scc-universal
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.