
Sf Testing
- 34 installs
- 423 repo stars
- Updated April 27, 2026
- jaganpro/claude-code-sfskills
This is a copy of sf-testing by jaganpro - installs and ranking accrue to the original listing.
Helps with testing & qa tasks.
About
sf-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- sf-testing
- Testing & QA
- AI-coding skill
Sf Testing by the numbers
- 34 all-time installs (skills.sh)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jaganpro/claude-code-sfskills --skill sf-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 34 |
|---|---|
| repo stars | ★ 423 |
| Last updated | April 27, 2026 |
| Repository | jaganpro/claude-code-sfskills ↗ |
What it does
Helps with testing & qa tasks.
Files
sf-testing: Salesforce Test Execution & Coverage Analysis
Use this skill when the user needs Apex test execution and failure analysis: running tests, checking coverage, interpreting failures, improving coverage, and managing a disciplined test-fix loop for Salesforce code.
When This Skill Owns the Task
Use sf-testing when the work involves:
sf apex run testworkflows- Apex unit-test failures
- code coverage analysis
- identifying uncovered lines and missing test scenarios
- structured test-fix loops for Apex code
Delegate elsewhere when the user is:
- writing or refactoring production Apex → sf-apex
- testing Agentforce agents → sf-ai-agentforce-testing
- testing LWC with Jest → sf-lwc
---
Required Context to Gather First
Ask for or infer:
- target org alias
- desired test scope: single class, specific methods, suite, or local tests
- coverage threshold expectation
- whether the user wants diagnosis only or a test-fix loop
- whether related test data factories already exist
---
Recommended Workflow
1. Discover test scope
Identify:
- existing test classes
- target production classes
- test data factories / setup helpers
2. Run the smallest useful test set first
Start narrow when debugging a failure; widen only after the fix is stable.
3. Analyze results
Focus on:
- failing methods
- exception types and stack traces
- uncovered lines / weak coverage areas
- whether failures indicate bad test data, brittle assertions, or broken production logic
4. Run a disciplined fix loop
When the issue is code or test quality:
- delegate code fixes to sf-apex when needed
- add or improve tests
- rerun focused tests before broader regression
5. Improve coverage intentionally
Cover:
- positive path
- negative / exception path
- bulk path (251+ records where appropriate)
- callout or async path when relevant
---
High-Signal Rules
- default to
SeeAllData=false - every test should assert meaningful outcomes
- test bulk behavior, not just single-record happy paths
- use factories /
@TestSetupwhen they improve clarity and speed - pair
Test.startTest()withTest.stopTest()when async behavior matters - do not hide flaky org dependencies inside tests
---
Output Format
When finishing, report in this order: 1. What tests were run 2. Pass/fail summary 3. Coverage result 4. Root-cause findings 5. Fix or next-run recommendation
Suggested shape:
Test run: <scope>
Org: <alias>
Result: <passed / partial / failed>
Coverage: <percent / key classes>
Issues: <highest-signal failures>
Next step: <fix class, add test, rerun scope, or widen regression>---
Cross-Skill Integration
| Need | Delegate to | Reason |
|---|---|---|
| fix production code or author tests | sf-apex | code generation and repair |
| create bulk / edge-case data | sf-data | realistic test datasets |
| deploy updated tests | sf-deploy | rollout |
| inspect detailed runtime logs | sf-debug | deeper failure analysis |
---
Reference Map
Start here
- references/cli-commands.md
- references/test-patterns.md
- references/testing-best-practices.md
- references/test-fix-loop.md
Specialized guidance
- references/mocking-patterns.md
- references/performance-optimization.md
- assets/
---
Score Guide
| Score | Meaning |
|---|---|
| 108+ | strong production-grade test confidence |
| 96–107 | good test suite with minor gaps |
| 84–95 | acceptable but strengthen coverage / assertions |
| < 84 | below standard; revise before relying on it |
# Claude Code lifecycle hooks for sf-testing
# These hooks are registered by the installer (tools/install.py)
# and are NOT part of the Agent Skills open specification.
PreToolUse:
- matcher: Bash
hooks:
- type: command
command: "python3 ${SHARED_HOOKS}/scripts/guardrails.py"
timeout: 5000
PostToolUse:
- matcher: Bash
hooks:
- type: command
command: "python3 ${SKILL_HOOKS}/parse-test-results.py"
timeout: 30000
/**
* @description Test class for {{ClassName}}
* Tests core functionality with positive, negative, and bulk scenarios.
* @author {{Author}}
* @created {{Date}}
* @coverage Target: 90%+
*/
@IsTest
private class {{ClassName}}Test {
// ═══════════════════════════════════════════════════════════════════════════
// TEST DATA SETUP
// ═══════════════════════════════════════════════════════════════════════════
@TestSetup
static void setupTestData() {
// Create test data using Test Data Factory pattern
// This data is available to all test methods and rolled back after each
List<{{ObjectName}}> records = TestDataFactory.create{{ObjectName}}s(5);
insert records;
}
// ═══════════════════════════════════════════════════════════════════════════
// POSITIVE TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests successful {{methodUnderTest}} with valid input
*/
@IsTest
static void test{{MethodUnderTest}}_ValidInput_Success() {
// ─────────────────────────────────────────────────────────────────────
// GIVEN - Set up test conditions
// ─────────────────────────────────────────────────────────────────────
{{ObjectName}} testRecord = [SELECT Id, Name FROM {{ObjectName}} LIMIT 1];
// TODO: Add any additional setup needed
// ─────────────────────────────────────────────────────────────────────
// WHEN - Execute the method under test
// ─────────────────────────────────────────────────────────────────────
Test.startTest();
{{ReturnType}} result = {{ClassName}}.{{methodUnderTest}}(testRecord);
Test.stopTest();
// ─────────────────────────────────────────────────────────────────────
// THEN - Verify expected outcomes
// ─────────────────────────────────────────────────────────────────────
Assert.isNotNull(result, 'Result should not be null');
// TODO: Add specific assertions for expected values
// Assert.areEqual(expectedValue, result, 'Result should match expected value');
}
// ═══════════════════════════════════════════════════════════════════════════
// NEGATIVE TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests {{methodUnderTest}} throws exception with null input
*/
@IsTest
static void test{{MethodUnderTest}}_NullInput_ThrowsException() {
// ─────────────────────────────────────────────────────────────────────
// GIVEN - Null input
// ─────────────────────────────────────────────────────────────────────
{{ObjectName}} nullRecord = null;
// ─────────────────────────────────────────────────────────────────────
// WHEN/THEN - Verify exception is thrown
// ─────────────────────────────────────────────────────────────────────
try {
Test.startTest();
{{ClassName}}.{{methodUnderTest}}(nullRecord);
Test.stopTest();
Assert.fail('Expected exception was not thrown');
} catch (IllegalArgumentException e) {
Assert.isTrue(
e.getMessage().containsIgnoreCase('null') ||
e.getMessage().containsIgnoreCase('required'),
'Error message should indicate null/required issue: ' + e.getMessage()
);
}
}
/**
* @description Tests {{methodUnderTest}} handles invalid data gracefully
*/
@IsTest
static void test{{MethodUnderTest}}_InvalidData_ReturnsError() {
// ─────────────────────────────────────────────────────────────────────
// GIVEN - Invalid input data
// ─────────────────────────────────────────────────────────────────────
{{ObjectName}} invalidRecord = new {{ObjectName}}(
// TODO: Set invalid field values that should fail validation
);
// ─────────────────────────────────────────────────────────────────────
// WHEN - Execute with invalid data
// ─────────────────────────────────────────────────────────────────────
Test.startTest();
// TODO: Call method and capture result/exception
Test.stopTest();
// ─────────────────────────────────────────────────────────────────────
// THEN - Verify appropriate error handling
// ─────────────────────────────────────────────────────────────────────
// TODO: Assert error message or state
}
// ═══════════════════════════════════════════════════════════════════════════
// BULK TESTS (251+ records)
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests {{methodUnderTest}} handles bulk operations (251 records)
* 251 records crosses the 200-record trigger batch boundary
*/
@IsTest
static void test{{MethodUnderTest}}_BulkOperation_Success() {
// ─────────────────────────────────────────────────────────────────────
// GIVEN - 251 records (crosses 200-record batch boundary)
// ─────────────────────────────────────────────────────────────────────
List<{{ObjectName}}> bulkRecords = TestDataFactory.create{{ObjectName}}s(251);
// ─────────────────────────────────────────────────────────────────────
// WHEN - Process bulk records
// ─────────────────────────────────────────────────────────────────────
Test.startTest();
insert bulkRecords; // Triggers fire in batches of 200, then 51
Test.stopTest();
// ─────────────────────────────────────────────────────────────────────
// THEN - Verify all records processed without governor limit issues
// ─────────────────────────────────────────────────────────────────────
Integer recordCount = [SELECT COUNT() FROM {{ObjectName}}];
Assert.isTrue(recordCount >= 251, 'All 251 records should be processed');
// Verify governor limits not approached
Assert.isTrue(Limits.getQueries() < 90,
'SOQL queries should stay well under limit: ' + Limits.getQueries() + '/100');
Assert.isTrue(Limits.getDmlStatements() < 140,
'DML statements should stay under limit: ' + Limits.getDmlStatements() + '/150');
}
// ═══════════════════════════════════════════════════════════════════════════
// EDGE CASE TESTS
// ═══════════════════════════════════════════════────────────────────────────
/**
* @description Tests {{methodUnderTest}} with empty list input
*/
@IsTest
static void test{{MethodUnderTest}}_EmptyList_NoError() {
// ─────────────────────────────────────────────────────────────────────
// GIVEN - Empty list
// ─────────────────────────────────────────────────────────────────────
List<{{ObjectName}}> emptyList = new List<{{ObjectName}}>();
// ─────────────────────────────────────────────────────────────────────
// WHEN - Process empty list
// ─────────────────────────────────────────────────────────────────────
Test.startTest();
// TODO: Call method with empty list
Test.stopTest();
// ─────────────────────────────────────────────────────────────────────
// THEN - Should handle gracefully (no exception)
// ─────────────────────────────────────────────────────────────────────
// TODO: Assert appropriate handling
}
}
/**
* @description Bulk testing template for trigger and service validation
* Tests with 251+ records to ensure bulkification compliance.
* 251 records crosses the 200-record trigger batch boundary.
* @author {{Author}}
* @created {{Date}}
*/
@IsTest
private class {{ClassName}}BulkTest {
// Record counts for different test scenarios
private static final Integer BULK_SIZE = 251; // Crosses 200-batch boundary
private static final Integer LARGE_BULK_SIZE = 501; // Multiple batch boundaries
private static final Integer SINGLE_BATCH = 200; // Single batch (edge case)
// ═══════════════════════════════════════════════════════════════════════════
// BULK INSERT TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests bulk insert with 251 records
* Verifies trigger handles multiple batch chunks correctly
*/
@IsTest
static void testBulkInsert_251Records_AllProcessed() {
// GIVEN
List<{{ObjectName}}> records = TestDataFactory.create{{ObjectName}}s(BULK_SIZE);
// WHEN
Test.startTest();
Database.SaveResult[] results = Database.insert(records, false);
Test.stopTest();
// THEN - All records should be successfully inserted
Integer successCount = 0;
Integer failCount = 0;
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
successCount++;
} else {
failCount++;
System.debug('Insert failed: ' + sr.getErrors()[0].getMessage());
}
}
Assert.areEqual(BULK_SIZE, successCount,
'All ' + BULK_SIZE + ' records should insert successfully');
Assert.areEqual(0, failCount, 'No records should fail');
// Verify governor limits
assertGovernorLimitsNotExceeded();
}
/**
* @description Tests bulk insert at exact batch boundary (200 records)
*/
@IsTest
static void testBulkInsert_ExactBatchSize_AllProcessed() {
// GIVEN
List<{{ObjectName}}> records = TestDataFactory.create{{ObjectName}}s(SINGLE_BATCH);
// WHEN
Test.startTest();
insert records;
Test.stopTest();
// THEN
Integer count = [SELECT COUNT() FROM {{ObjectName}}];
Assert.areEqual(SINGLE_BATCH, count,
'All ' + SINGLE_BATCH + ' records should be inserted');
assertGovernorLimitsNotExceeded();
}
// ═══════════════════════════════════════════════════════════════════════════
// BULK UPDATE TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests bulk update with 251 records
* Verifies update triggers handle multiple batches
*/
@IsTest
static void testBulkUpdate_251Records_AllUpdated() {
// GIVEN - Insert records first
List<{{ObjectName}}> records = TestDataFactory.create{{ObjectName}}s(BULK_SIZE);
insert records;
// Modify all records
for ({{ObjectName}} rec : records) {
// TODO: Update fields that trigger logic
// rec.SomeField__c = 'Updated Value';
}
// WHEN
Test.startTest();
Database.SaveResult[] results = Database.update(records, false);
Test.stopTest();
// THEN
Integer successCount = 0;
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
successCount++;
}
}
Assert.areEqual(BULK_SIZE, successCount,
'All ' + BULK_SIZE + ' records should update successfully');
assertGovernorLimitsNotExceeded();
}
// ═══════════════════════════════════════════════════════════════════════════
// BULK DELETE TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests bulk delete with 251 records
* Verifies delete triggers handle cleanup correctly
*/
@IsTest
static void testBulkDelete_251Records_AllDeleted() {
// GIVEN
List<{{ObjectName}}> records = TestDataFactory.create{{ObjectName}}s(BULK_SIZE);
insert records;
// WHEN
Test.startTest();
Database.DeleteResult[] results = Database.delete(records, false);
Test.stopTest();
// THEN
Integer successCount = 0;
for (Database.DeleteResult dr : results) {
if (dr.isSuccess()) {
successCount++;
}
}
Assert.areEqual(BULK_SIZE, successCount,
'All ' + BULK_SIZE + ' records should delete successfully');
Integer remainingCount = [SELECT COUNT() FROM {{ObjectName}}];
Assert.areEqual(0, remainingCount, 'No records should remain');
}
// ═══════════════════════════════════════════════════════════════════════════
// MIXED DML BULK TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests mixed operations in bulk
* Verifies partial success scenarios are handled
*/
@IsTest
static void testBulkMixedOperations_PartialSuccess() {
// GIVEN - Some valid, some invalid records
List<{{ObjectName}}> validRecords = TestDataFactory.create{{ObjectName}}s(200);
List<{{ObjectName}}> invalidRecords = new List<{{ObjectName}}>();
// Create invalid records (missing required fields or validation failures)
for (Integer i = 0; i < 51; i++) {
invalidRecords.add(new {{ObjectName}}(
// TODO: Set up record that will fail validation
));
}
List<{{ObjectName}}> allRecords = new List<{{ObjectName}}>();
allRecords.addAll(validRecords);
allRecords.addAll(invalidRecords);
// WHEN - Insert with allOrNone = false
Test.startTest();
Database.SaveResult[] results = Database.insert(allRecords, false);
Test.stopTest();
// THEN - Count successes and failures
Integer successCount = 0;
Integer failCount = 0;
for (Database.SaveResult sr : results) {
if (sr.isSuccess()) {
successCount++;
} else {
failCount++;
}
}
// Verify partial success handled correctly
Assert.isTrue(successCount > 0, 'Some records should succeed');
// Note: Uncomment if you expect failures
// Assert.isTrue(failCount > 0, 'Some records should fail validation');
}
// ═══════════════════════════════════════════════════════════════════════════
// GOVERNOR LIMIT VERIFICATION
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Helper method to verify governor limits not exceeded
* Call after Test.stopTest() to check limit usage
*/
private static void assertGovernorLimitsNotExceeded() {
// SOQL Queries (limit: 100)
Assert.isTrue(Limits.getQueries() < 90,
'SOQL queries approaching limit: ' + Limits.getQueries() + '/100');
// DML Statements (limit: 150)
Assert.isTrue(Limits.getDmlStatements() < 140,
'DML statements approaching limit: ' + Limits.getDmlStatements() + '/150');
// DML Rows (limit: 10,000)
Assert.isTrue(Limits.getDmlRows() < 9500,
'DML rows approaching limit: ' + Limits.getDmlRows() + '/10000');
// Heap Size (limit: 6MB sync, 12MB async)
Assert.isTrue(Limits.getHeapSize() < 5000000,
'Heap size approaching limit: ' + Limits.getHeapSize() + '/6000000');
// CPU Time (limit: 10,000ms sync, 60,000ms async)
Assert.isTrue(Limits.getCpuTime() < 9000,
'CPU time approaching limit: ' + Limits.getCpuTime() + '/10000');
System.debug('═══════════════════════════════════════════════════════');
System.debug('GOVERNOR LIMIT USAGE:');
System.debug(' SOQL Queries: ' + Limits.getQueries() + '/100');
System.debug(' DML Statements: ' + Limits.getDmlStatements() + '/150');
System.debug(' DML Rows: ' + Limits.getDmlRows() + '/10000');
System.debug(' Heap Size: ' + Limits.getHeapSize() + '/6000000');
System.debug(' CPU Time: ' + Limits.getCpuTime() + 'ms /10000ms');
System.debug('═══════════════════════════════════════════════════════');
}
// ═══════════════════════════════════════════════════════════════════════════
// STRESS TESTS (Optional - Run sparingly)
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Stress test with 501 records (multiple batch boundaries)
* Use sparingly - consumes significant test execution time
*/
@IsTest
static void testStressTest_501Records_AllProcessed() {
// GIVEN
List<{{ObjectName}}> records = TestDataFactory.create{{ObjectName}}s(LARGE_BULK_SIZE);
// WHEN
Test.startTest();
insert records;
Test.stopTest();
// THEN
Integer count = [SELECT COUNT() FROM {{ObjectName}}];
Assert.areEqual(LARGE_BULK_SIZE, count,
'All ' + LARGE_BULK_SIZE + ' records should be processed');
assertGovernorLimitsNotExceeded();
}
}
/**
* DML Mocking Framework
*
* Enables true unit testing by replacing database operations with in-memory tracking.
* Tests using this pattern run ~35x faster than tests with actual DML.
*
* @see https://www.jamessimone.net/blog/joys-of-apex/mocking-dml/
*/
// ═══════════════════════════════════════════════════════════════════════════
// INTERFACE: IDML
// Define the contract for DML operations
// ═══════════════════════════════════════════════════════════════════════════
/**
* Interface for DML operations - enables dependency injection
*/
public interface IDML {
void doInsert(SObject record);
void doInsert(List<SObject> records);
void doUpdate(SObject record);
void doUpdate(List<SObject> records);
void doUpsert(SObject record);
void doUpsert(List<SObject> records);
void doDelete(SObject record);
void doDelete(List<SObject> records);
}
// ═══════════════════════════════════════════════════════════════════════════
// PRODUCTION: DML
// Performs actual database operations
// ═══════════════════════════════════════════════════════════════════════════
/**
* Production implementation performing real database operations
*/
public class DML implements IDML {
public void doInsert(SObject record) {
insert record;
}
public void doInsert(List<SObject> records) {
if (records != null && !records.isEmpty()) {
insert records;
}
}
public void doUpdate(SObject record) {
update record;
}
public void doUpdate(List<SObject> records) {
if (records != null && !records.isEmpty()) {
update records;
}
}
public void doUpsert(SObject record) {
upsert record;
}
public void doUpsert(List<SObject> records) {
if (records != null && !records.isEmpty()) {
upsert records;
}
}
public void doDelete(SObject record) {
delete record;
}
public void doDelete(List<SObject> records) {
if (records != null && !records.isEmpty()) {
delete records;
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TEST MOCK: DMLMock
// Tracks operations without hitting the database
// ═══════════════════════════════════════════════════════════════════════════
/**
* Mock DML implementation for fast unit tests
*
* Usage:
* DMLMock.reset();
* MyService service = new MyService(new DMLMock());
* service.doSomething();
* Assert.areEqual(1, DMLMock.InsertedRecords.size());
*/
@IsTest
public class DMLMock implements IDML {
// ═══════════════════════════════════════════════════════════════════
// TRACKED OPERATIONS
// ═══════════════════════════════════════════════════════════════════
public static List<SObject> InsertedRecords = new List<SObject>();
public static List<SObject> UpdatedRecords = new List<SObject>();
public static List<SObject> UpsertedRecords = new List<SObject>();
public static List<SObject> DeletedRecords = new List<SObject>();
// Counter for generating unique fake IDs
private static Integer idCounter = 1;
// ═══════════════════════════════════════════════════════════════════
// DML OPERATIONS
// ═══════════════════════════════════════════════════════════════════
public void doInsert(SObject record) {
doInsert(new List<SObject>{ record });
}
public void doInsert(List<SObject> records) {
if (records == null || records.isEmpty()) {
return;
}
for (SObject record : records) {
// Generate fake ID to simulate database insert
if (record.Id == null) {
record.Id = generateFakeId(record.getSObjectType());
}
InsertedRecords.add(record);
}
}
public void doUpdate(SObject record) {
doUpdate(new List<SObject>{ record });
}
public void doUpdate(List<SObject> records) {
if (records != null && !records.isEmpty()) {
UpdatedRecords.addAll(records);
}
}
public void doUpsert(SObject record) {
doUpsert(new List<SObject>{ record });
}
public void doUpsert(List<SObject> records) {
if (records != null && !records.isEmpty()) {
for (SObject record : records) {
if (record.Id == null) {
record.Id = generateFakeId(record.getSObjectType());
}
}
UpsertedRecords.addAll(records);
}
}
public void doDelete(SObject record) {
doDelete(new List<SObject>{ record });
}
public void doDelete(List<SObject> records) {
if (records != null && !records.isEmpty()) {
DeletedRecords.addAll(records);
}
}
// ═══════════════════════════════════════════════════════════════════
// UTILITY METHODS
// ═══════════════════════════════════════════════════════════════════
/**
* Reset all tracked operations - call at start of each test
*/
public static void reset() {
InsertedRecords.clear();
UpdatedRecords.clear();
UpsertedRecords.clear();
DeletedRecords.clear();
idCounter = 1;
}
/**
* Generate a valid Salesforce ID for testing
* Uses the object's key prefix for realistic IDs
*/
private static Id generateFakeId(Schema.SObjectType sObjectType) {
String keyPrefix = sObjectType.getDescribe().getKeyPrefix();
if (keyPrefix == null) {
keyPrefix = '000'; // Fallback for objects without prefix
}
String idBody = String.valueOf(idCounter++).leftPad(12, '0');
return Id.valueOf(keyPrefix + idBody);
}
// ═══════════════════════════════════════════════════════════════════
// HELPER METHODS FOR ASSERTIONS
// ═══════════════════════════════════════════════════════════════════
/**
* Get inserted records of a specific SObject type
*/
public static List<SObject> getInsertedOfType(Schema.SObjectType sObjectType) {
List<SObject> result = new List<SObject>();
for (SObject record : InsertedRecords) {
if (record.getSObjectType() == sObjectType) {
result.add(record);
}
}
return result;
}
/**
* Get updated records of a specific SObject type
*/
public static List<SObject> getUpdatedOfType(Schema.SObjectType sObjectType) {
List<SObject> result = new List<SObject>();
for (SObject record : UpdatedRecords) {
if (record.getSObjectType() == sObjectType) {
result.add(record);
}
}
return result;
}
/**
* Get deleted records of a specific SObject type
*/
public static List<SObject> getDeletedOfType(Schema.SObjectType sObjectType) {
List<SObject> result = new List<SObject>();
for (SObject record : DeletedRecords) {
if (record.getSObjectType() == sObjectType) {
result.add(record);
}
}
return result;
}
/**
* Check if any records of a specific type were inserted
*/
public static Boolean wasInserted(Schema.SObjectType sObjectType) {
return !getInsertedOfType(sObjectType).isEmpty();
}
/**
* Check if any records of a specific type were updated
*/
public static Boolean wasUpdated(Schema.SObjectType sObjectType) {
return !getUpdatedOfType(sObjectType).isEmpty();
}
/**
* Check if any records of a specific type were deleted
*/
public static Boolean wasDeleted(Schema.SObjectType sObjectType) {
return !getDeletedOfType(sObjectType).isEmpty();
}
}
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE: Service Using Injected DML
// ═══════════════════════════════════════════════════════════════════════════
/**
* Example service class demonstrating DML injection
*/
public class AccountService {
private IDML dml;
// Production constructor - uses real DML
public AccountService() {
this(new DML());
}
// Test constructor - accepts mock DML
@TestVisible
private AccountService(IDML dml) {
this.dml = dml;
}
public Id createAccount(Account acc) {
if (acc == null) {
throw new IllegalArgumentException('Account cannot be null');
}
dml.doInsert(acc);
return acc.Id;
}
public void updateAccounts(List<Account> accounts) {
dml.doUpdate(accounts);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE: Test Class Using DML Mock
// ═══════════════════════════════════════════════════════════════════════════
@IsTest
private class AccountServiceTest {
@IsTest
static void testCreateAccount_Success() {
// Arrange
DMLMock.reset();
AccountService service = new AccountService(new DMLMock());
Account testAcc = new Account(Name = 'Test Corp', Industry = 'Technology');
// Act
Test.startTest();
Id accountId = service.createAccount(testAcc);
Test.stopTest();
// Assert
Assert.isNotNull(accountId, 'Should have generated ID');
Assert.areEqual(1, DMLMock.InsertedRecords.size(), 'Should have 1 insert');
Assert.isTrue(DMLMock.wasInserted(Account.SObjectType), 'Account was inserted');
Account inserted = (Account) DMLMock.InsertedRecords[0];
Assert.areEqual('Test Corp', inserted.Name, 'Name should match');
Assert.areEqual('Technology', inserted.Industry, 'Industry should match');
}
@IsTest
static void testCreateAccount_NullInput_ThrowsException() {
// Arrange
DMLMock.reset();
AccountService service = new AccountService(new DMLMock());
// Act/Assert
try {
service.createAccount(null);
Assert.fail('Expected IllegalArgumentException');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('null'), 'Error should mention null');
}
// Verify no DML occurred
Assert.areEqual(0, DMLMock.InsertedRecords.size(), 'No records should be inserted');
}
}
/**
* @description Test class for external API callouts using mock framework
* Demonstrates HttpCalloutMock and WebServiceMock patterns.
* @author {{Author}}
* @created {{Date}}
*/
@IsTest
private class {{ClassName}}CalloutTest {
// ═══════════════════════════════════════════════════════════════════════════
// MOCK IMPLEMENTATIONS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Mock for successful HTTP response (200 OK)
*/
private class SuccessMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setStatus('OK');
res.setHeader('Content-Type', 'application/json');
res.setBody(JSON.serialize(new Map<String, Object>{
'success' => true,
'data' => new Map<String, Object>{
'id' => '12345',
'status' => 'completed',
'timestamp' => Datetime.now().format()
}
}));
return res;
}
}
/**
* @description Mock for error HTTP response (500 Internal Server Error)
*/
private class ErrorMock implements HttpCalloutMock {
private Integer statusCode;
private String errorMessage;
public ErrorMock(Integer statusCode, String errorMessage) {
this.statusCode = statusCode;
this.errorMessage = errorMessage;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setStatus('Error');
res.setHeader('Content-Type', 'application/json');
res.setBody(JSON.serialize(new Map<String, Object>{
'success' => false,
'error' => this.errorMessage
}));
return res;
}
}
/**
* @description Mock for timeout simulation
*/
private class TimeoutMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
// Simulate timeout by throwing CalloutException
throw new CalloutException('Read timed out');
}
}
/**
* @description Mock that validates request parameters
*/
private class ValidatingMock implements HttpCalloutMock {
private String expectedEndpoint;
private String expectedMethod;
public ValidatingMock(String endpoint, String method) {
this.expectedEndpoint = endpoint;
this.expectedMethod = method;
}
public HttpResponse respond(HttpRequest req) {
// Validate the request
System.assertEquals(expectedMethod, req.getMethod(),
'HTTP method should be ' + expectedMethod);
System.assert(req.getEndpoint().contains(expectedEndpoint),
'Endpoint should contain ' + expectedEndpoint);
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"validated": true}');
return res;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SUCCESS TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests successful API call returns expected data
*/
@IsTest
static void testCallout_Success_ReturnsData() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new SuccessMock());
// WHEN
Test.startTest();
// TODO: Replace with your actual service call
// {{ClassName}}Response response = {{ClassName}}.callExternalAPI('test-param');
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/test');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
Test.stopTest();
// THEN
Assert.areEqual(200, res.getStatusCode(), 'Should return 200 OK');
Map<String, Object> responseBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Assert.areEqual(true, responseBody.get('success'), 'Response should indicate success');
Assert.isNotNull(responseBody.get('data'), 'Response should contain data');
}
/**
* @description Tests POST request with body
*/
@IsTest
static void testCallout_PostWithBody_Success() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new SuccessMock());
Map<String, Object> requestBody = new Map<String, Object>{
'name' => 'Test Record',
'value' => 100
};
// WHEN
Test.startTest();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/create');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(requestBody));
HttpResponse res = new Http().send(req);
Test.stopTest();
// THEN
Assert.areEqual(200, res.getStatusCode(), 'POST should succeed');
}
// ═══════════════════════════════════════════════════════════════════════════
// ERROR HANDLING TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests handling of 400 Bad Request
*/
@IsTest
static void testCallout_BadRequest_HandlesGracefully() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new ErrorMock(400, 'Invalid request parameters'));
// WHEN
Test.startTest();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/test');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
Test.stopTest();
// THEN
Assert.areEqual(400, res.getStatusCode(), 'Should return 400');
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Assert.areEqual(false, errorBody.get('success'), 'Should indicate failure');
Assert.isNotNull(errorBody.get('error'), 'Should contain error message');
}
/**
* @description Tests handling of 500 Internal Server Error
*/
@IsTest
static void testCallout_ServerError_HandlesGracefully() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new ErrorMock(500, 'Internal server error'));
// WHEN
Test.startTest();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/test');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
Test.stopTest();
// THEN
Assert.areEqual(500, res.getStatusCode(), 'Should return 500');
}
/**
* @description Tests handling of timeout exception
*/
@IsTest
static void testCallout_Timeout_ThrowsException() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new TimeoutMock());
// WHEN/THEN
Test.startTest();
try {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/slow');
req.setMethod('GET');
new Http().send(req);
Assert.fail('Expected CalloutException was not thrown');
} catch (CalloutException e) {
Assert.isTrue(e.getMessage().containsIgnoreCase('timeout'),
'Exception should mention timeout: ' + e.getMessage());
}
Test.stopTest();
}
/**
* @description Tests handling of 401 Unauthorized
*/
@IsTest
static void testCallout_Unauthorized_HandlesGracefully() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new ErrorMock(401, 'Invalid or expired token'));
// WHEN
Test.startTest();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/protected');
req.setMethod('GET');
HttpResponse res = new Http().send(req);
Test.stopTest();
// THEN
Assert.areEqual(401, res.getStatusCode(), 'Should return 401 Unauthorized');
}
// ═══════════════════════════════════════════════════════════════════════════
// REQUEST VALIDATION TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Verifies correct endpoint and method are used
*/
@IsTest
static void testCallout_ValidatesRequestParameters() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new ValidatingMock('/api/v1/resource', 'POST'));
// WHEN
Test.startTest();
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/api/v1/resource');
req.setMethod('POST');
req.setBody('{"test": true}');
HttpResponse res = new Http().send(req);
Test.stopTest();
// THEN - Mock validates; test passes if no assertion failure
Assert.areEqual(200, res.getStatusCode(), 'Request should be valid');
}
// ═══════════════════════════════════════════════════════════════════════════
// ASYNC CALLOUT TESTS (@future, Queueable)
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Tests @future method with callout
* Note: Must use Test.startTest/stopTest to execute @future
*/
@IsTest
static void testFutureCallout_ExecutesSuccessfully() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new SuccessMock());
// WHEN - Call @future method
Test.startTest();
// TODO: Replace with your actual @future method
// {{ClassName}}.makeAsyncCallout('test-param');
Test.stopTest(); // Forces @future to execute
// THEN - Verify results (may need to query for side effects)
// TODO: Add assertions based on what @future method does
}
/**
* @description Tests Queueable with callout
*/
@IsTest
static void testQueueableCallout_ExecutesSuccessfully() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new SuccessMock());
// WHEN
Test.startTest();
// TODO: Replace with your actual Queueable
// System.enqueueJob(new {{ClassName}}Queueable('param'));
Test.stopTest(); // Forces Queueable to execute
// THEN - Verify results
// TODO: Add assertions
}
// ═══════════════════════════════════════════════════════════════════════════
// MULTI-CALLOUT TESTS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Mock for multiple sequential callouts
*/
private class MultiCalloutMock implements HttpCalloutMock {
private Integer callCount = 0;
public HttpResponse respond(HttpRequest req) {
callCount++;
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"callNumber": ' + callCount + '}');
return res;
}
}
/**
* @description Tests multiple callouts in sequence (limit: 100 per transaction)
*/
@IsTest
static void testMultipleCallouts_AllSucceed() {
// GIVEN
Test.setMock(HttpCalloutMock.class, new MultiCalloutMock());
// WHEN - Make multiple callouts
Test.startTest();
List<HttpResponse> responses = new List<HttpResponse>();
for (Integer i = 0; i < 5; i++) {
HttpRequest req = new HttpRequest();
req.setEndpoint('https://api.example.com/call/' + i);
req.setMethod('GET');
responses.add(new Http().send(req));
}
Test.stopTest();
// THEN
Assert.areEqual(5, responses.size(), 'Should complete 5 callouts');
for (HttpResponse res : responses) {
Assert.areEqual(200, res.getStatusCode(), 'Each callout should succeed');
}
}
}
/**
* StubProvider Examples
*
* The Stub API enables dynamic mocking of interfaces and virtual classes.
* Use when you need conditional logic or method tracking in your mocks.
*
* @see https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_interface_System_StubProvider.htm
* @see https://blog.beyondthecloud.dev/blog/salesforce-mock-in-apex-tests
*/
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE 1: Simple StubProvider for Interface
// ═══════════════════════════════════════════════════════════════════════════
/**
* Interface to be stubbed
*/
public interface IAccountService {
Account getAccount(Id accountId);
List<Account> getAccounts(Set<Id> accountIds);
Integer getAccountCount();
void updateAccount(Account acc);
}
/**
* Basic stub implementation with configurable responses
*/
@IsTest
public class AccountServiceStub implements System.StubProvider {
// Track method calls for verification
public List<String> calledMethods = new List<String>();
public Map<String, List<Object>> methodParams = new Map<String, List<Object>>();
// Configurable responses
private Map<String, Object> responses = new Map<String, Object>();
/**
* Configure what a method should return
*/
public AccountServiceStub whenCalled(String methodName, Object response) {
responses.put(methodName, response);
return this;
}
/**
* Required StubProvider method
*/
public Object handleMethodCall(
Object stubbedObject,
String stubbedMethodName,
Type returnType,
List<Type> paramTypes,
List<String> paramNames,
List<Object> paramValues
) {
// Track this call
calledMethods.add(stubbedMethodName);
methodParams.put(stubbedMethodName, paramValues);
// Return configured response if available
if (responses.containsKey(stubbedMethodName)) {
return responses.get(stubbedMethodName);
}
// Default responses based on return type
if (returnType == Account.class) {
return new Account(Id = generateFakeId(), Name = 'Default Stub Account');
}
if (returnType == List<Account>.class) {
return new List<Account>{
new Account(Id = generateFakeId(), Name = 'Stub Account 1')
};
}
if (returnType == Integer.class) {
return 0;
}
return null;
}
/**
* Verify a method was called
*/
public Boolean wasCalled(String methodName) {
return calledMethods.contains(methodName);
}
/**
* Get call count for a method
*/
public Integer getCallCount(String methodName) {
Integer count = 0;
for (String called : calledMethods) {
if (called == methodName) {
count++;
}
}
return count;
}
// Fake ID generator
private static Integer idCounter = 1;
private static Id generateFakeId() {
String idBody = String.valueOf(idCounter++).leftPad(12, '0');
return Id.valueOf('001' + idBody);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE 2: Using the Stub in Tests
// ═══════════════════════════════════════════════════════════════════════════
@IsTest
private class AccountServiceStubTest {
@IsTest
static void testWithConfiguredResponse() {
// Arrange - Configure stub responses
AccountServiceStub stub = new AccountServiceStub()
.whenCalled('getAccountCount', 42)
.whenCalled('getAccount', new Account(Name = 'Configured Account'));
// Create stubbed instance
IAccountService service = (IAccountService) Test.createStub(
IAccountService.class,
stub
);
// Act
Test.startTest();
Integer count = service.getAccountCount();
Account acc = service.getAccount(null);
Test.stopTest();
// Assert - Verify responses
Assert.areEqual(42, count, 'Should return configured count');
Assert.areEqual('Configured Account', acc.Name, 'Should return configured account');
// Assert - Verify calls were tracked
Assert.isTrue(stub.wasCalled('getAccountCount'), 'getAccountCount was called');
Assert.isTrue(stub.wasCalled('getAccount'), 'getAccount was called');
Assert.areEqual(1, stub.getCallCount('getAccountCount'), 'Called once');
}
@IsTest
static void testDefaultResponses() {
// Arrange - No configured responses
AccountServiceStub stub = new AccountServiceStub();
IAccountService service = (IAccountService) Test.createStub(
IAccountService.class,
stub
);
// Act
Test.startTest();
Account acc = service.getAccount(null);
Test.stopTest();
// Assert - Verify default response
Assert.isNotNull(acc, 'Should return default account');
Assert.areEqual('Default Stub Account', acc.Name, 'Should have default name');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE 3: Conditional StubProvider
// ═══════════════════════════════════════════════════════════════════════════
/**
* Stub with conditional logic based on parameters
*/
@IsTest
public class ConditionalStub implements System.StubProvider {
public Object handleMethodCall(
Object stubbedObject,
String stubbedMethodName,
Type returnType,
List<Type> paramTypes,
List<String> paramNames,
List<Object> paramValues
) {
// Conditional response based on method and params
if (stubbedMethodName == 'getAccount') {
Id requestedId = (Id) paramValues[0];
// Return different accounts based on ID pattern
String idStr = String.valueOf(requestedId);
if (idStr.startsWith('001')) {
return new Account(Id = requestedId, Name = 'Standard Account');
} else if (idStr.startsWith('0018')) {
return new Account(Id = requestedId, Name = 'Person Account');
}
}
if (stubbedMethodName == 'getAccountCount') {
// Simulate different counts for different scenarios
return 100;
}
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE 4: Exception-Throwing Stub
// ═══════════════════════════════════════════════════════════════════════════
/**
* Stub that throws exceptions for error testing
*/
@IsTest
public class ErrorStub implements System.StubProvider {
private String methodToFail;
private String errorMessage;
public ErrorStub failOn(String methodName, String message) {
this.methodToFail = methodName;
this.errorMessage = message;
return this;
}
public Object handleMethodCall(
Object stubbedObject,
String stubbedMethodName,
Type returnType,
List<Type> paramTypes,
List<String> paramNames,
List<Object> paramValues
) {
if (stubbedMethodName == methodToFail) {
throw new AuraHandledException(errorMessage);
}
return null;
}
}
@IsTest
private class ErrorStubTest {
@IsTest
static void testErrorHandling() {
// Arrange
ErrorStub stub = new ErrorStub().failOn('getAccount', 'Account not found');
IAccountService service = (IAccountService) Test.createStub(
IAccountService.class,
stub
);
// Act/Assert
try {
service.getAccount(null);
Assert.fail('Expected AuraHandledException');
} catch (AuraHandledException e) {
Assert.areEqual('Account not found', e.getMessage());
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// EXAMPLE 5: Universal Mock (Flexible Stub)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Highly flexible stub that can mock any interface
*
* Inspired by: Suraj Pillai's UniversalMock pattern
*/
@IsTest
public class UniversalMock implements System.StubProvider {
// Store method -> return value mappings
private Map<String, Object> returnValues = new Map<String, Object>();
// Store method -> exception mappings
private Map<String, Exception> exceptions = new Map<String, Exception>();
// Track all calls
private List<MethodCall> calls = new List<MethodCall>();
public class MethodCall {
public String methodName;
public List<Object> arguments;
public Datetime calledAt;
public MethodCall(String methodName, List<Object> arguments) {
this.methodName = methodName;
this.arguments = arguments;
this.calledAt = Datetime.now();
}
}
/**
* Configure return value for a method
*/
public UniversalMock setReturnValue(String methodName, Object value) {
returnValues.put(methodName, value);
return this;
}
/**
* Configure exception for a method
*/
public UniversalMock throwException(String methodName, Exception e) {
exceptions.put(methodName, e);
return this;
}
public Object handleMethodCall(
Object stubbedObject,
String stubbedMethodName,
Type returnType,
List<Type> paramTypes,
List<String> paramNames,
List<Object> paramValues
) {
// Track the call
calls.add(new MethodCall(stubbedMethodName, paramValues));
// Throw configured exception
if (exceptions.containsKey(stubbedMethodName)) {
throw exceptions.get(stubbedMethodName);
}
// Return configured value
if (returnValues.containsKey(stubbedMethodName)) {
return returnValues.get(stubbedMethodName);
}
// Return type-appropriate defaults
if (returnType == Boolean.class) return false;
if (returnType == Integer.class) return 0;
if (returnType == String.class) return '';
if (returnType == List<SObject>.class) return new List<SObject>();
return null;
}
/**
* Get all calls to a specific method
*/
public List<MethodCall> getCalls(String methodName) {
List<MethodCall> result = new List<MethodCall>();
for (MethodCall call : calls) {
if (call.methodName == methodName) {
result.add(call);
}
}
return result;
}
/**
* Verify method was called with specific arguments
*/
public Boolean wasCalledWith(String methodName, List<Object> expectedArgs) {
for (MethodCall call : getCalls(methodName)) {
if (call.arguments.equals(expectedArgs)) {
return true;
}
}
return false;
}
}
/**
* @description Test Data Factory for creating consistent test data across all test classes.
* Use this pattern to avoid hardcoded test data and ensure test isolation.
*
* USAGE:
* List<Account> accounts = TestDataFactory.createAccounts(5);
* insert accounts;
*
* // Or with auto-insert:
* List<Account> accounts = TestDataFactory.createAndInsertAccounts(5);
*
* @author {{Author}}
* @created {{Date}}
*/
@IsTest
public class TestDataFactory {
// ═══════════════════════════════════════════════════════════════════════════
// ACCOUNT METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Creates Account records without inserting
* @param count Number of accounts to create
* @return List of Account records (not inserted)
*/
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 = 'Test Account ' + i,
Industry = 'Technology',
BillingStreet = '123 Test Street',
BillingCity = 'San Francisco',
BillingState = 'CA',
BillingPostalCode = '94105',
BillingCountry = 'USA',
Phone = '555-000-' + String.valueOf(i).leftPad(4, '0'),
Website = 'https://testaccount' + i + '.example.com'
));
}
return accounts;
}
/**
* @description Creates and inserts Account records
* @param count Number of accounts to create
* @return List of inserted Account records
*/
public static List<Account> createAndInsertAccounts(Integer count) {
List<Account> accounts = createAccounts(count);
insert accounts;
return accounts;
}
/**
* @description Creates Account with specific attributes
* @param name Account name
* @param industry Industry value
* @return Account record (not inserted)
*/
public static Account createAccount(String name, String industry) {
return new Account(
Name = name,
Industry = industry,
BillingCity = 'San Francisco',
BillingState = 'CA',
BillingCountry = 'USA'
);
}
// ═══════════════════════════════════════════════════════════════════════════
// CONTACT METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Creates Contact records without inserting
* @param count Number of contacts to create
* @param accountId Parent account ID
* @return List of Contact records (not inserted)
*/
public static List<Contact> createContacts(Integer count, Id accountId) {
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < count; i++) {
contacts.add(new Contact(
FirstName = 'Test',
LastName = 'Contact ' + i,
AccountId = accountId,
Email = 'testcontact' + i + '@example.com',
Phone = '555-001-' + String.valueOf(i).leftPad(4, '0'),
Title = 'Test Title ' + i,
MailingStreet = '456 Test Ave',
MailingCity = 'San Francisco',
MailingState = 'CA',
MailingPostalCode = '94105',
MailingCountry = 'USA'
));
}
return contacts;
}
/**
* @description Creates and inserts Contact records
* @param count Number of contacts to create
* @param accountId Parent account ID
* @return List of inserted Contact records
*/
public static List<Contact> createAndInsertContacts(Integer count, Id accountId) {
List<Contact> contacts = createContacts(count, accountId);
insert contacts;
return contacts;
}
// ═══════════════════════════════════════════════════════════════════════════
// LEAD METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Creates Lead records without inserting
* @param count Number of leads to create
* @return List of Lead records (not inserted)
*/
public static List<Lead> createLeads(Integer count) {
List<Lead> leads = new List<Lead>();
for (Integer i = 0; i < count; i++) {
leads.add(new Lead(
FirstName = 'Test',
LastName = 'Lead ' + i,
Company = 'Test Company ' + i,
Email = 'testlead' + i + '@example.com',
Phone = '555-002-' + String.valueOf(i).leftPad(4, '0'),
Status = 'Open - Not Contacted',
Industry = 'Technology',
LeadSource = 'Web'
));
}
return leads;
}
/**
* @description Creates and inserts Lead records
* @param count Number of leads to create
* @return List of inserted Lead records
*/
public static List<Lead> createAndInsertLeads(Integer count) {
List<Lead> leads = createLeads(count);
insert leads;
return leads;
}
// ═══════════════════════════════════════════════════════════════════════════
// OPPORTUNITY METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Creates Opportunity records without inserting
* @param count Number of opportunities to create
* @param accountId Parent account ID
* @return List of Opportunity records (not inserted)
*/
public static List<Opportunity> createOpportunities(Integer count, Id accountId) {
List<Opportunity> opportunities = new List<Opportunity>();
for (Integer i = 0; i < count; i++) {
opportunities.add(new Opportunity(
Name = 'Test Opportunity ' + i,
AccountId = accountId,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30 + i),
Amount = 10000 * (i + 1)
));
}
return opportunities;
}
/**
* @description Creates and inserts Opportunity records
* @param count Number of opportunities to create
* @param accountId Parent account ID
* @return List of inserted Opportunity records
*/
public static List<Opportunity> createAndInsertOpportunities(Integer count, Id accountId) {
List<Opportunity> opportunities = createOpportunities(count, accountId);
insert opportunities;
return opportunities;
}
// ═══════════════════════════════════════════════════════════════════════════
// CASE METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Creates Case records without inserting
* @param count Number of cases to create
* @param accountId Parent account ID (optional)
* @param contactId Related contact ID (optional)
* @return List of Case records (not inserted)
*/
public static List<Case> createCases(Integer count, Id accountId, Id contactId) {
List<Case> cases = new List<Case>();
for (Integer i = 0; i < count; i++) {
cases.add(new Case(
Subject = 'Test Case ' + i,
Description = 'Test case description ' + i,
Status = 'New',
Priority = 'Medium',
Origin = 'Web',
AccountId = accountId,
ContactId = contactId
));
}
return cases;
}
/**
* @description Creates and inserts Case records
* @param count Number of cases to create
* @param accountId Parent account ID (optional)
* @param contactId Related contact ID (optional)
* @return List of inserted Case records
*/
public static List<Case> createAndInsertCases(Integer count, Id accountId, Id contactId) {
List<Case> cases = createCases(count, accountId, contactId);
insert cases;
return cases;
}
// ═══════════════════════════════════════════════════════════════════════════
// USER METHODS (for System.runAs testing)
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Creates a test User with specified profile
* Use with System.runAs() for permission testing
* @param profileName Name of the profile to assign
* @param uniqueIdentifier Unique string to avoid duplicate usernames
* @return User record (not inserted)
*/
public static User createUser(String profileName, String uniqueIdentifier) {
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
String uniqueEmail = uniqueIdentifier + '@testuser.example.com';
String uniqueUsername = uniqueIdentifier + '@testuser.example.com.test';
return new User(
FirstName = 'Test',
LastName = 'User ' + uniqueIdentifier,
Email = uniqueEmail,
Username = uniqueUsername,
Alias = uniqueIdentifier.left(8),
ProfileId = p.Id,
TimeZoneSidKey = 'America/Los_Angeles',
LocaleSidKey = 'en_US',
EmailEncodingKey = 'UTF-8',
LanguageLocaleKey = 'en_US'
);
}
/**
* @description Creates and inserts a test User
* @param profileName Name of the profile to assign
* @param uniqueIdentifier Unique string to avoid duplicate usernames
* @return Inserted User record
*/
public static User createAndInsertUser(String profileName, String uniqueIdentifier) {
User u = createUser(profileName, uniqueIdentifier);
insert u;
return u;
}
// ═══════════════════════════════════════════════════════════════════════════
// CUSTOM OBJECT METHODS (Template)
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Template for custom object creation
* Copy and modify for your custom objects
* @param count Number of records to create
* @return List of custom object records (not inserted)
*/
/*
public static List<Custom_Object__c> createCustomObjects(Integer count) {
List<Custom_Object__c> records = new List<Custom_Object__c>();
for (Integer i = 0; i < count; i++) {
records.add(new Custom_Object__c(
Name = 'Test Record ' + i,
Custom_Field__c = 'Value ' + i
// Add more fields as needed
));
}
return records;
}
public static List<Custom_Object__c> createAndInsertCustomObjects(Integer count) {
List<Custom_Object__c> records = createCustomObjects(count);
insert records;
return records;
}
*/
// ═══════════════════════════════════════════════════════════════════════════
// UTILITY METHODS
// ═══════════════════════════════════════════════════════════════════════════
/**
* @description Generates a unique string for test data
* Useful for avoiding unique constraint violations
* @return Unique string based on timestamp
*/
public static String generateUniqueString() {
return String.valueOf(Datetime.now().getTime()) +
String.valueOf(Math.random()).substring(2, 8);
}
/**
* @description Creates a map of records by a specified field
* Useful for test assertions
* @param records List of SObjects
* @param fieldName API name of the field to use as key
* @return Map with field values as keys
*/
public static Map<String, SObject> createMapByField(List<SObject> records, String fieldName) {
Map<String, SObject> recordMap = new Map<String, SObject>();
for (SObject record : records) {
String keyValue = String.valueOf(record.get(fieldName));
recordMap.put(keyValue, record);
}
return recordMap;
}
}
Credits & Acknowledgments
This skill was built upon the collective wisdom of the Salesforce developer community. We gratefully acknowledge the following authors and resources whose ideas, patterns, and best practices have shaped this skill.
---
Authors & Contributors
James Simone
[Joys of Apex](https://www.jamessimone.net/blog/)
Key contributions:
- DML mocking pattern for fast tests
- Factory pattern for dependency injection
- Performant Apex test strategies
- Stub ID generation for test isolation
Referenced articles:
- Mocking DML
- Writing Performant Apex Tests
- Dependency Injection & Factory Pattern
- Mocking Apex History Records
- Testing Custom Permissions
Beyond the Cloud (Piotr Gajek)
[blog.beyondthecloud.dev](https://blog.beyondthecloud.dev/)
Key contributions:
- Mocking vs Stubbing distinction
- Test Data Factory pattern with fluent interface
- Selector layer mocking strategies
- Query result mocking
Referenced articles:
Apex Hours (Amit Chaudhary)
[apexhours.com](https://www.apexhours.com/)
Key contributions:
- Mocking framework fundamentals
- HttpCalloutMock patterns
- Test class best practices
Referenced articles:
---
Official Salesforce Resources
- Testing Best Practices: developer.salesforce.com/docs
- StubProvider Interface: Apex Reference Guide
- HttpCalloutMock Guide: Testing HTTP Callouts
- Trailhead: Apex Unit Testing
- Trailhead: Mock and Stub Objects
---
Key Patterns Integrated
| Pattern | Source | Integration |
|---|---|---|
| DML Mocking | James Simone | assets/dml-mock.cls |
| Mocking vs Stubbing | Beyond the Cloud | references/mocking-patterns.md |
| Test Data Factory | Beyond the Cloud | references/test-data-factory-guide.md |
| HttpCalloutMock | Apex Hours | assets/http-mock-response.cls |
| StubProvider | Salesforce Docs | assets/stub-provider-example.cls |
| Performant Tests | James Simone | references/performance-optimization.md |
---
Philosophy
This skill integrates concepts and patterns from community resources, teaching Apex developers how to write fast, reliable, maintainable tests. The goal is to move beyond "75% coverage" toward true unit testing with proper isolation and mocking.
---
If we've missed anyone whose work influenced this skill, please let us know so we can add proper attribution.
#!/usr/bin/env python3
"""
Parse Apex test results and format for Claude auto-fix loop.
This hook parses the JSON output from `sf apex run test` and provides
structured feedback that enables Claude to automatically fix failing tests.
Environment Variables:
TOOL_OUTPUT: The stdout from the Bash command
TOOL_INPUT: The command that was executed
Output:
Formatted test results with failure analysis and fix suggestions
"""
import json
import os
import sys
import re
from pathlib import Path
from datetime import datetime
# Only process sf apex run test commands
def should_process():
"""Check if this is an apex test command we should process."""
tool_input = os.environ.get('TOOL_INPUT', '')
return 'sf apex run test' in tool_input or 'sf apex get test' in tool_input
def parse_test_results(output: str) -> dict:
"""
Parse test results from sf CLI JSON output.
Returns:
dict with summary, failures, and coverage data
"""
try:
# Try to parse as JSON (if --result-format json was used)
data = json.loads(output)
return parse_json_results(data)
except json.JSONDecodeError:
# Parse human-readable output
return parse_text_results(output)
def parse_json_results(data: dict) -> dict:
"""Parse JSON format test results."""
result = data.get('result', data)
summary = {
'passed': 0,
'failed': 0,
'skipped': 0,
'total': 0,
'duration_ms': 0,
'coverage_percent': 0
}
failures = []
coverage = []
# Parse test results
tests = result.get('tests', [])
for test in tests:
outcome = test.get('Outcome', test.get('outcome', '')).lower()
if outcome == 'pass':
summary['passed'] += 1
elif outcome == 'fail':
summary['failed'] += 1
failures.append({
'class': test.get('ApexClass', {}).get('Name', test.get('className', 'Unknown')),
'method': test.get('MethodName', test.get('methodName', 'Unknown')),
'message': test.get('Message', test.get('message', '')),
'stack_trace': test.get('StackTrace', test.get('stackTrace', '')),
'run_time': test.get('RunTime', test.get('runTime', 0))
})
elif outcome == 'skip':
summary['skipped'] += 1
summary['total'] = summary['passed'] + summary['failed'] + summary['skipped']
# Parse coverage
coverage_data = result.get('coverage', {}).get('coverage', [])
if not coverage_data:
coverage_data = result.get('codecoverage', [])
total_lines = 0
covered_lines = 0
for cov in coverage_data:
class_name = cov.get('name', cov.get('apexClassOrTriggerName', 'Unknown'))
num_lines = cov.get('totalLines', cov.get('numLinesCovered', 0) + cov.get('numLinesUncovered', 0))
num_covered = cov.get('coveredLines', cov.get('numLinesCovered', 0))
if isinstance(num_covered, list):
num_covered = len(num_covered)
uncovered = cov.get('uncoveredLines', [])
if isinstance(uncovered, int):
uncovered = []
pct = (num_covered / num_lines * 100) if num_lines > 0 else 0
coverage.append({
'class': class_name,
'total_lines': num_lines,
'covered_lines': num_covered,
'uncovered_lines': uncovered[:10] if uncovered else [], # Limit to first 10
'percent': round(pct, 1)
})
total_lines += num_lines
covered_lines += num_covered if isinstance(num_covered, int) else 0
summary['coverage_percent'] = round(covered_lines / total_lines * 100, 1) if total_lines > 0 else 0
return {
'summary': summary,
'failures': failures,
'coverage': coverage
}
def parse_text_results(output: str) -> dict:
"""Parse human-readable test output."""
summary = {
'passed': 0,
'failed': 0,
'skipped': 0,
'total': 0,
'duration_ms': 0,
'coverage_percent': 0
}
failures = []
# Look for pass/fail patterns
pass_match = re.search(r'(\d+)\s+(?:test[s]?\s+)?pass(?:ed|ing)?', output, re.IGNORECASE)
fail_match = re.search(r'(\d+)\s+(?:test[s]?\s+)?fail(?:ed|ing|ure)?', output, re.IGNORECASE)
if pass_match:
summary['passed'] = int(pass_match.group(1))
if fail_match:
summary['failed'] = int(fail_match.group(1))
summary['total'] = summary['passed'] + summary['failed']
# Look for failure details
failure_pattern = re.compile(
r'([\w]+)\.([\w]+)\s*[-:]\s*(.*?)(?=\n\n|\n[A-Z]|$)',
re.MULTILINE | re.DOTALL
)
for match in failure_pattern.finditer(output):
if 'fail' in match.group(3).lower() or 'error' in match.group(3).lower():
failures.append({
'class': match.group(1),
'method': match.group(2),
'message': match.group(3).strip(),
'stack_trace': ''
})
return {
'summary': summary,
'failures': failures,
'coverage': []
}
def analyze_failure(failure: dict) -> dict:
"""
Analyze a test failure and suggest fix strategy.
Returns:
dict with error_type, root_cause, and suggested_fix
"""
message = failure.get('message', '')
stack_trace = failure.get('stack_trace', '')
analysis = {
'error_type': 'Unknown',
'root_cause': 'Unable to determine root cause',
'suggested_fix': 'Review the test and code under test',
'auto_fixable': False
}
# Assertion failures
if 'AssertException' in message or 'Assertion Failed' in message:
analysis['error_type'] = 'Assertion Failure'
# Extract expected vs actual
expected_match = re.search(r'[Ee]xpected[:\s]+(\S+)', message)
actual_match = re.search(r'[Aa]ctual[:\s]+(\S+)', message)
if expected_match and actual_match:
analysis['root_cause'] = f"Expected {expected_match.group(1)} but got {actual_match.group(1)}"
analysis['suggested_fix'] = "Check if the test expectation is correct, or if the code logic needs fixing"
else:
analysis['root_cause'] = "Test assertion did not match expected outcome"
analysis['suggested_fix'] = "Review the assertion and verify expected vs actual values"
analysis['auto_fixable'] = True
# Null pointer
elif 'NullPointerException' in message:
analysis['error_type'] = 'Null Pointer Exception'
# Try to extract line number
line_match = re.search(r'[Ll]ine[:\s]+(\d+)', stack_trace or message)
if line_match:
analysis['root_cause'] = f"Null reference at line {line_match.group(1)}"
else:
analysis['root_cause'] = "Attempting to access a property or method on a null reference"
analysis['suggested_fix'] = "Add null check before accessing the object, or ensure test data setup creates required records"
analysis['auto_fixable'] = True
# DML exceptions
elif 'DmlException' in message:
analysis['error_type'] = 'DML Exception'
if 'REQUIRED_FIELD_MISSING' in message:
analysis['root_cause'] = "Required field not populated in test data"
analysis['suggested_fix'] = "Add the missing required field to TestDataFactory or test setup"
elif 'FIELD_CUSTOM_VALIDATION_EXCEPTION' in message:
analysis['root_cause'] = "Record fails validation rule"
analysis['suggested_fix'] = "Modify test data to meet validation rule requirements"
elif 'DUPLICATE_VALUE' in message:
analysis['root_cause'] = "Unique field constraint violation"
analysis['suggested_fix'] = "Use unique values in test data (e.g., add timestamp or random suffix)"
else:
analysis['root_cause'] = "DML operation failed"
analysis['suggested_fix'] = "Review the DML error message and adjust test data accordingly"
analysis['auto_fixable'] = True
# Query exceptions
elif 'QueryException' in message:
analysis['error_type'] = 'Query Exception'
analysis['root_cause'] = "SOQL query returned no results or too many results"
analysis['suggested_fix'] = "Ensure test data exists before querying, or handle empty results"
analysis['auto_fixable'] = True
# Limit exceptions
elif 'LimitException' in message:
analysis['error_type'] = 'Governor Limit Exception'
if 'Too many SOQL' in message:
analysis['root_cause'] = "SOQL query limit exceeded (100 queries)"
analysis['suggested_fix'] = "Bulkify queries - query before loops, use maps for lookups"
elif 'Too many DML' in message:
analysis['root_cause'] = "DML statement limit exceeded (150 statements)"
analysis['suggested_fix'] = "Bulkify DML - collect records in list, single DML after loop"
else:
analysis['root_cause'] = "Governor limit exceeded"
analysis['suggested_fix'] = "Review code for bulkification issues"
analysis['auto_fixable'] = True
# Mixed DML
elif 'MIXED_DML_OPERATION' in message:
analysis['error_type'] = 'Mixed DML Exception'
analysis['root_cause'] = "Setup and non-setup objects modified in same transaction"
analysis['suggested_fix'] = "Use System.runAs() to separate User operations from data operations"
analysis['auto_fixable'] = True
return analysis
def format_output(results: dict) -> str:
"""Format test results for Claude consumption."""
summary = results['summary']
failures = results['failures']
coverage = results['coverage']
lines = []
lines.append("=" * 60)
lines.append("📊 APEX TEST RESULTS")
lines.append("=" * 60)
lines.append("")
# Summary
status_icon = "✅" if summary['failed'] == 0 else "❌"
lines.append(f"{status_icon} SUMMARY")
lines.append("-" * 60)
lines.append(f" Passed: {summary['passed']}")
lines.append(f" Failed: {summary['failed']}")
lines.append(f" Skipped: {summary['skipped']}")
lines.append(f" Total: {summary['total']}")
if summary['coverage_percent'] > 0:
cov_icon = "✅" if summary['coverage_percent'] >= 75 else "⚠️"
lines.append(f" Coverage: {summary['coverage_percent']}% {cov_icon}")
lines.append("")
# Failures with analysis
if failures:
lines.append("❌ FAILED TESTS")
lines.append("-" * 60)
for i, failure in enumerate(failures, 1):
analysis = analyze_failure(failure)
lines.append(f"\n{i}. {failure['class']}.{failure['method']}")
lines.append(f" Error Type: {analysis['error_type']}")
lines.append(f" Message: {failure['message'][:200]}...")
lines.append(f" Root Cause: {analysis['root_cause']}")
lines.append(f" Suggested Fix: {analysis['suggested_fix']}")
if analysis['auto_fixable']:
lines.append(" 🤖 AUTO-FIXABLE: Yes - Claude can attempt automatic fix")
lines.append("")
lines.append("=" * 60)
lines.append("🤖 AGENTIC FIX INSTRUCTIONS")
lines.append("=" * 60)
lines.append("")
lines.append("To automatically fix these failures:")
lines.append("1. Read the failing test class")
lines.append("2. Read the class under test")
lines.append("3. Apply the suggested fix")
lines.append("4. Re-run: sf apex run test --tests [ClassName].[methodName]")
lines.append("")
# Coverage details (if below threshold)
low_coverage = [c for c in coverage if c['percent'] < 75]
if low_coverage:
lines.append("⚠️ LOW COVERAGE CLASSES (<75%)")
lines.append("-" * 60)
for cov in sorted(low_coverage, key=lambda x: x['percent']):
lines.append(f" {cov['class']}: {cov['percent']}%")
if cov.get('uncovered_lines'):
lines.append(f" Uncovered lines: {cov['uncovered_lines']}")
lines.append("")
lines.append("=" * 60)
return "\n".join(lines)
def main():
"""Main entry point."""
if not should_process():
# Not an apex test command, exit silently
sys.exit(0)
output = os.environ.get('TOOL_OUTPUT', '')
if not output:
sys.exit(0)
# Check if this looks like test output
if 'test' not in output.lower() and 'coverage' not in output.lower():
sys.exit(0)
try:
results = parse_test_results(output)
# Only output if there were tests or failures
if results['summary']['total'] > 0 or results['failures']:
formatted = format_output(results)
print(formatted)
except Exception as e:
# Silently fail - don't block on parsing errors
sys.exit(0)
if __name__ == "__main__":
main()
sf-testing
Comprehensive Salesforce testing skill with test execution, code coverage analysis, and agentic test-fix loops. Run tests, analyze coverage, and automatically fix failing tests.
Features
- Test Execution: Run Apex tests via sf CLI with coverage analysis
- Coverage Analysis: Parse reports, identify untested code paths
- Failure Analysis: Parse failures, identify root causes, suggest fixes
- Agentic Test-Fix Loop: Automatically fix failing tests and re-run
- 120-Point Scoring: Validation across 6 categories
- Bulk Testing: Validate with 251+ records for governor limits
Installation
# Install as part of sf-skills
npx skills add Jaganpro/sf-skills
# Or install just this skill
npx skills add Jaganpro/sf-skills --skill sf-testingQuick Start
1. Invoke the skill
Skill: sf-testing
Request: "Run all tests and show coverage report for org dev"2. Common operations
| Operation | Example Request |
|---|---|
| Run class | "Run AccountServiceTest in org dev" |
| Run all | "Run all local tests with coverage" |
| Coverage report | "Show code coverage for AccountService" |
| Fix loop | "Run tests and fix failures automatically" |
| Generate tests | "Create tests for AccountService class" |
Key Commands
# Run single test class
sf apex run test --class-names MyClassTest --code-coverage --result-format json --target-org [alias]
# Run all local tests
sf apex run test --test-level RunLocalTests --code-coverage --result-format json --target-org [alias]
# Run specific methods
sf apex run test --tests MyClassTest.testMethod1 --target-org [alias]
# Run with output directory
sf apex run test --class-names MyClassTest --output-dir test-results --target-org [alias]Scoring System (120 Points)
| Category | Points | Focus |
|---|---|---|
| Coverage | 25 | Overall and per-class coverage |
| Assertions | 25 | Meaningful assertions, edge cases |
| Bulk Testing | 20 | 251+ records, governor limits |
| Data Isolation | 20 | @TestSetup, test data factories |
| Negative Tests | 15 | Error paths, exceptions |
| Documentation | 15 | Test descriptions, clear naming |
Test Thresholds
| Level | Coverage | Purpose |
|---|---|---|
| Production | 75% minimum | Required for deployment |
| Recommended | 90%+ | Best practice target |
| Critical paths | 100% | Business-critical code |
Cross-Skill Integration
| Related Skill | When to Use |
|---|---|
| sf-apex | Fix failing Apex code |
| sf-debug | Analyze test failures with debug logs |
| sf-data | Generate 251+ bulk test records |
| sf-deploy | Validate before deployment |
Agentic Test-Fix Loop
When enabled, the skill will: 1. Run tests and capture failures 2. Analyze error messages and stack traces 3. Generate fixes for common issues 4. Apply fixes and re-run tests 5. Repeat until all tests pass or max iterations reached
Documentation
- Testing Best Practices
Requirements
- sf CLI v2
- Target Salesforce org
- Test classes in org or local project
License
MIT License. See LICENSE file. Copyright (c) 2024-2025 Jag Valaiyapathy
<!-- Parent: sf-testing/SKILL.md -->
Salesforce CLI Test Commands Reference
Quick Reference
| Task | Command |
|---|---|
| Run single test class | sf apex run test --class-names MyTest |
| Run all local tests | sf apex run test --test-level RunLocalTests |
| Run with coverage | sf apex run test --class-names MyTest --code-coverage |
| Get JSON output | sf apex run test --class-names MyTest --result-format json |
| Run specific methods | sf apex run test --tests MyTest.method1 --tests MyTest.method2 |
Test Execution
Run Single Test Class
sf apex run test \
--class-names AccountServiceTest \
--target-org my-sandbox \
--code-coverage \
--result-format humanRun Multiple Test Classes
sf apex run test \
--class-names AccountServiceTest \
--class-names ContactServiceTest \
--class-names LeadServiceTest \
--target-org my-sandbox \
--code-coverageRun Specific Test Methods
sf apex run test \
--tests AccountServiceTest.testCreate \
--tests AccountServiceTest.testUpdate \
--target-org my-sandboxRun All Local Tests
sf apex run test \
--test-level RunLocalTests \
--target-org my-sandbox \
--code-coverage \
--output-dir test-resultsRun Test Suite
sf apex run test \
--suite-names RegressionSuite \
--target-org my-sandbox \
--code-coverageTest Levels
| Level | Description |
|---|---|
RunSpecifiedTests | Only specified tests (default when using --class-names) |
RunLocalTests | All tests except managed packages |
RunAllTestsInOrg | All tests including managed packages |
Output Formats
Human Readable (Default)
sf apex run test --class-names MyTest --result-format humanJSON (For Parsing)
sf apex run test --class-names MyTest --result-format jsonJUnit XML (For CI/CD)
sf apex run test --class-names MyTest --result-format junitTAP (Test Anything Protocol)
sf apex run test --class-names MyTest --result-format tapCode Coverage
Basic Coverage
sf apex run test \
--class-names MyTest \
--code-coverage \
--target-org my-sandboxDetailed Line-by-Line Coverage
sf apex run test \
--class-names MyTest \
--code-coverage \
--detailed-coverage \
--target-org my-sandboxSave Results to Directory
sf apex run test \
--class-names MyTest \
--code-coverage \
--output-dir ./test-results \
--target-org my-sandboxOutput files:
test-run-id.json- Test resultstest-run-id-codecoverage.json- Coverage data
Async Test Execution
Run Tests Asynchronously
sf apex run test \
--test-level RunLocalTests \
--target-org my-sandbox \
--asyncReturns a test run ID for checking status later.
Check Async Test Status
sf apex get test \
--test-run-id 707xx0000000000AAA \
--target-org my-sandboxWait for Test Completion
sf apex run test \
--test-level RunLocalTests \
--target-org my-sandbox \
--wait 10 # Wait up to 10 minutesDebug Logs During Tests
Enable Debug Logs
# List current log levels
sf apex list log --target-org my-sandbox
# Tail logs in real-time
sf apex tail log --target-org my-sandbox --colorGet Specific Log
sf apex get log \
--log-id 07Lxx0000000000AAA \
--target-org my-sandboxUseful Flags
| Flag | Description |
|---|---|
--code-coverage | Include coverage in results |
--detailed-coverage | Line-by-line coverage (slower) |
--result-format | Output format (human, json, junit, tap) |
--output-dir | Save results to directory |
--synchronous | Wait for completion (default) |
--wait | Max minutes to wait |
--async | Return immediately with run ID |
--verbose | Show additional details |
--concise | Suppress passing test details (show only failures) |
--poll-interval <seconds> | Customize polling interval (v2.116.6+) |
Common Patterns
Full Test Run with Coverage Report
sf apex run test \
--test-level RunLocalTests \
--code-coverage \
--result-format json \
--output-dir ./test-results \
--target-org my-sandbox \
--wait 30Quick Validation (Single Test)
sf apex run test \
--tests AccountServiceTest.testCreate \
--target-org my-sandboxCI/CD Pipeline Pattern
# Run tests with JUnit output for CI tools
sf apex run test \
--test-level RunLocalTests \
--result-format junit \
--output-dir ./test-results \
--code-coverage \
--target-org ci-sandbox \
--wait 60
# Check exit code
if [ $? -ne 0 ]; then
echo "Tests failed!"
exit 1
fiCoverage Validation
# Run tests and check minimum coverage
sf apex run test \
--test-level RunLocalTests \
--code-coverage \
--result-format json \
--output-dir ./test-results \
--target-org my-sandbox
# Parse coverage from JSON (requires jq)
coverage=$(jq '.result.summary.orgWideCoverage' ./test-results/*.json | tr -d '"' | tr -d '%')
if [ "$coverage" -lt 75 ]; then
echo "Coverage $coverage% is below 75% threshold!"
exit 1
fiTroubleshooting
Test Timeout
# Increase wait time for long-running tests
sf apex run test \
--test-level RunAllTestsInOrg \
--wait 120 \
--target-org my-sandboxNo Test Results
Check if tests exist:
sf data query --query "SELECT Id, Name FROM ApexClass WHERE Name LIKE '%Test%'" --target-org my-sandboxPermission Errors
Ensure user has "Author Apex" permission and API access.
Async Test Not Completing
Check system status:
sf apex get test \
--test-run-id 707xx0000000000AAA \
--target-org my-sandbox<!-- Parent: sf-testing/SKILL.md -->
Mocking Patterns in Apex Tests
This guide covers mocking and stubbing patterns that enable true unit testing in Apex. By replacing database operations and external services with mock implementations, you can write fast, isolated, reliable tests.
Sources: Beyond the Cloud, James Simone, Trailhead
---
Mocking vs Stubbing
Understanding the distinction is crucial for effective test design:
| Aspect | Mocking | Stubbing |
|---|---|---|
| Purpose | Replace objects with fakes returning predefined values | Create fake implementations with dynamic logic |
| Complexity | Simple, static responses | Complex, behavioral simulation |
| When to Use | HTTP callouts, simple return values | Interface implementations, dynamic behavior |
| Example | HttpCalloutMock returns fixed response | StubProvider with conditional logic |
---
Pattern 1: HttpCalloutMock (Required for HTTP Tests)
Salesforce requires mock callouts - you cannot make real HTTP requests in tests.
Basic Implementation
/**
* Simple HTTP mock returning a fixed response
*
* @see https://www.apexhours.com/testing-web-services-callouts-in-salesforce/
*/
@IsTest
public class MockHttpResponse implements HttpCalloutMock {
private Integer statusCode;
private String body;
public MockHttpResponse(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(this.statusCode);
res.setHeader('Content-Type', 'application/json');
res.setBody(this.body);
return res;
}
}
// Usage in test
@IsTest
static void testApiCall_Success() {
String mockBody = '{"success": true, "data": {"id": "12345"}}';
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, mockBody));
Test.startTest();
ApiResponse result = MyApiService.callExternalApi('endpoint');
Test.stopTest();
Assert.isTrue(result.success, 'API call should succeed');
}Multi-Endpoint Mock
/**
* Mock that responds differently based on request endpoint
*/
@IsTest
public class MultiEndpointMock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/json');
String endpoint = req.getEndpoint();
if (endpoint.contains('/users')) {
res.setBody('{"users": [{"id": 1, "name": "John"}]}');
} else if (endpoint.contains('/orders')) {
res.setBody('{"orders": [{"id": 100, "total": 250.00}]}');
} else {
res.setStatusCode(404);
res.setBody('{"error": "Not Found"}');
}
return res;
}
}Error Scenario Mock
/**
* Mock for testing error handling
*/
@IsTest
static void testApiCall_ServerError_HandlesGracefully() {
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(500, '{"error": "Server Error"}'));
Test.startTest();
try {
MyApiService.callExternalApi('endpoint');
Assert.fail('Expected CalloutException was not thrown');
} catch (CalloutException e) {
Assert.isTrue(e.getMessage().contains('Server Error'), 'Should contain error message');
}
Test.stopTest();
}---
Pattern 2: DML Mocking (No Database Operations)
This pattern eliminates database operations from tests, achieving 35x faster execution.
The DML Interface
/**
* Interface for DML operations - enables mocking
*
* @see https://www.jamessimone.net/blog/joys-of-apex/mocking-dml/
*/
public interface IDML {
void doInsert(SObject record);
void doInsert(List<SObject> records);
void doUpdate(SObject record);
void doUpdate(List<SObject> records);
void doUpsert(SObject record);
void doUpsert(List<SObject> records);
void doDelete(SObject record);
void doDelete(List<SObject> records);
}Production Implementation
/**
* Production DML implementation - performs actual database operations
*/
public class DML implements IDML {
public void doInsert(SObject record) {
insert record;
}
public void doInsert(List<SObject> records) {
insert records;
}
public void doUpdate(SObject record) {
update record;
}
public void doUpdate(List<SObject> records) {
update records;
}
public void doUpsert(SObject record) {
upsert record;
}
public void doUpsert(List<SObject> records) {
upsert records;
}
public void doDelete(SObject record) {
delete record;
}
public void doDelete(List<SObject> records) {
delete records;
}
}Mock Implementation
/**
* Mock DML implementation - tracks operations without database
*/
@IsTest
public class DMLMock implements IDML {
// Static lists to track operations
public static List<SObject> InsertedRecords = new List<SObject>();
public static List<SObject> UpdatedRecords = new List<SObject>();
public static List<SObject> UpsertedRecords = new List<SObject>();
public static List<SObject> DeletedRecords = new List<SObject>();
// Counter for generating fake IDs
private static Integer idCounter = 1;
public void doInsert(SObject record) {
doInsert(new List<SObject>{ record });
}
public void doInsert(List<SObject> records) {
for (SObject record : records) {
// Generate fake ID to simulate insert
if (record.Id == null) {
record.Id = generateFakeId(record.getSObjectType());
}
InsertedRecords.add(record);
}
}
public void doUpdate(SObject record) {
doUpdate(new List<SObject>{ record });
}
public void doUpdate(List<SObject> records) {
UpdatedRecords.addAll(records);
}
public void doUpsert(SObject record) {
doUpsert(new List<SObject>{ record });
}
public void doUpsert(List<SObject> records) {
UpsertedRecords.addAll(records);
}
public void doDelete(SObject record) {
doDelete(new List<SObject>{ record });
}
public void doDelete(List<SObject> records) {
DeletedRecords.addAll(records);
}
/**
* Generate a fake Salesforce ID for testing
*/
private static Id generateFakeId(Schema.SObjectType sObjectType) {
String keyPrefix = sObjectType.getDescribe().getKeyPrefix();
String idBody = String.valueOf(idCounter++).leftPad(12, '0');
return Id.valueOf(keyPrefix + idBody);
}
/**
* Reset all tracked operations (call in @TestSetup or between tests)
*/
public static void reset() {
InsertedRecords.clear();
UpdatedRecords.clear();
UpsertedRecords.clear();
DeletedRecords.clear();
idCounter = 1;
}
/**
* Get inserted records of a specific type
*/
public static List<SObject> getInsertedOfType(Schema.SObjectType sObjectType) {
List<SObject> result = new List<SObject>();
for (SObject record : InsertedRecords) {
if (record.getSObjectType() == sObjectType) {
result.add(record);
}
}
return result;
}
}Using DML Mocking in Services
/**
* Service class that accepts injected DML
*/
public class AccountService {
private IDML dml;
// Production constructor - uses real DML
public AccountService() {
this(new DML());
}
// Test constructor - accepts mock DML
@TestVisible
private AccountService(IDML dml) {
this.dml = dml;
}
public Id createAccount(Account acc) {
if (acc == null) {
throw new IllegalArgumentException('Account cannot be null');
}
dml.doInsert(acc);
return acc.Id;
}
}
// Test using mock DML
@IsTest
static void testCreateAccount_NoDatabase() {
// Arrange
DMLMock.reset();
AccountService service = new AccountService(new DMLMock());
Account testAcc = new Account(Name = 'Test Account');
// Act
Test.startTest();
Id accountId = service.createAccount(testAcc);
Test.stopTest();
// Assert
Assert.isNotNull(accountId, 'Should have fake ID');
Assert.areEqual(1, DMLMock.InsertedRecords.size(), 'Should have 1 inserted record');
Account inserted = (Account) DMLMock.InsertedRecords[0];
Assert.areEqual('Test Account', inserted.Name, 'Name should match');
}---
Pattern 3: StubProvider (Dynamic Behavior)
Use StubProvider when you need dynamic, conditional behavior in your mocks.
Basic StubProvider Implementation
/**
* StubProvider for dynamic service mocking
*
* @see https://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_interface_System_StubProvider.htm
*/
@IsTest
public class AccountServiceStub implements System.StubProvider {
private Map<String, Object> methodResponses = new Map<String, Object>();
/**
* Configure what a method should return
*/
public AccountServiceStub withMethodReturn(String methodName, Object returnValue) {
methodResponses.put(methodName, returnValue);
return this;
}
public Object handleMethodCall(
Object stubbedObject,
String stubbedMethodName,
Type returnType,
List<Type> paramTypes,
List<String> paramNames,
List<Object> paramValues
) {
// Return pre-configured response if available
if (methodResponses.containsKey(stubbedMethodName)) {
return methodResponses.get(stubbedMethodName);
}
// Default responses based on method name
if (stubbedMethodName == 'getAccount') {
return new Account(Id = generateFakeId(), Name = 'Stubbed Account');
}
if (stubbedMethodName == 'getAccounts') {
return new List<Account>{
new Account(Id = generateFakeId(), Name = 'Stubbed 1'),
new Account(Id = generateFakeId(), Name = 'Stubbed 2')
};
}
return null;
}
private static Integer idCounter = 1;
private static Id generateFakeId() {
String idBody = String.valueOf(idCounter++).leftPad(12, '0');
return Id.valueOf('001' + idBody);
}
}Using StubProvider
// Create stub with Test.createStub()
@IsTest
static void testWithStub() {
// Create the stub
AccountServiceStub stub = new AccountServiceStub()
.withMethodReturn('getAccountCount', 42);
IAccountService service = (IAccountService) Test.createStub(
IAccountService.class,
stub
);
// Use the stubbed service
Test.startTest();
Integer count = service.getAccountCount();
Account acc = service.getAccount('001000000000001');
Test.stopTest();
// Verify
Assert.areEqual(42, count, 'Should return configured value');
Assert.areEqual('Stubbed Account', acc.Name, 'Should return stub default');
}---
Pattern 4: Selector Mocking (Query Results)
Mock SOQL query results without hitting the database.
/**
* Mockable selector pattern
*/
public class AccountSelector {
@TestVisible
private static List<Account> mockResults;
public static List<Account> getActiveAccounts() {
if (Test.isRunningTest() && mockResults != null) {
return mockResults;
}
return [
SELECT Id, Name, Industry
FROM Account
WHERE IsActive__c = true
WITH SECURITY_ENFORCED
];
}
@TestVisible
private static void setMockResults(List<Account> accounts) {
mockResults = accounts;
}
}
// Usage in test
@IsTest
static void testWithMockedQuery() {
// Arrange - no database insert needed!
List<Account> mockAccounts = new List<Account>{
new Account(Name = 'Mock 1', Industry = 'Tech'),
new Account(Name = 'Mock 2', Industry = 'Finance')
};
AccountSelector.setMockResults(mockAccounts);
// Act
Test.startTest();
List<Account> result = AccountSelector.getActiveAccounts();
Test.stopTest();
// Assert
Assert.areEqual(2, result.size(), 'Should return mock data');
Assert.areEqual('Mock 1', result[0].Name);
}---
When to Use Each Pattern
| Scenario | Pattern | Why |
|---|---|---|
| HTTP callouts | HttpCalloutMock | Required by Salesforce |
| Fast unit tests | DML Mocking | Eliminates database overhead |
| Complex interfaces | StubProvider | Dynamic, conditional behavior |
| Query isolation | Selector Mocking | Test without data setup |
| Simple replacements | Direct mocking | Static @TestVisible fields |
---
Performance Comparison
| Approach | 10,000 Records | Notes |
|---|---|---|
| Actual DML | ~50 seconds | Database operations are slow |
| DML Mocking | <1 second | 35x faster |
| StubProvider | <1 second | No database at all |
---
Best Practices
1. Mock at the seams: DML, callouts, and queries are natural mock points 2. Use dependency injection: Constructor injection enables easy test swapping 3. Prefer interfaces: IDML interface allows production/mock implementations 4. Reset between tests: Call DMLMock.reset() to prevent test pollution 5. Verify mock behavior: Assert on DMLMock.InsertedRecords to confirm operations 6. Generate fake IDs: Use key prefix + counter for realistic test IDs
<!-- Parent: sf-testing/SKILL.md -->
Agentic Test-Fix Loop Implementation
How It Works
When the agentic loop is enabled, sf-testing will:
1. Run tests and capture results 2. Parse failures to identify error type and location 3. Read source files (test class + class under test) 4. Analyze root cause using the failure analysis decision tree 5. Generate fix by invoking sf-apex skill 6. Re-run failing test to verify fix 7. Iterate until passing or max attempts (3)
Example Agentic Flow
User: "Run tests for AccountService with auto-fix enabled"
Claude:
1. sf apex run test --class-names AccountServiceTest --code-coverage --result-format json
2. Parse results: 1 failure - testBulkInsert line 45 NullPointerException
3. Read AccountServiceTest.cls (line 45 context)
4. Read AccountService.cls (trace the null reference)
5. Identify: Missing null check in AccountService.processAccounts()
6. Skill(sf-apex): Add null safety to AccountService.processAccounts()
7. Deploy fix
8. Re-run: sf apex run test --tests AccountServiceTest.testBulkInsert
9. ✅ Passing! Report success.Failure Analysis Decision Tree
| Error Type | Root Cause | Auto-Fix Strategy |
|---|---|---|
System.AssertException | Wrong expected value or logic bug | Analyze assertion, check if test or code is wrong |
System.NullPointerException | Missing null check or test data | Add null safety or fix test data setup |
System.DmlException | Validation rule, required field, trigger | Check org config, add required fields to test data |
System.LimitException | Governor limit hit | Refactor to use bulkified patterns |
System.QueryException | No rows returned | Add test data or adjust query |
System.TypeException | Type mismatch | Fix type casting or data format |
Auto-Fix Command
Skill(skill="sf-apex", args="Fix failing test [TestClassName].[methodName] - Error: [error message]")<!-- Parent: sf-testing/SKILL.md -->
Test Patterns & Templates
Pattern 1: Basic Test Class
Use template: assets/basic-test.cls
@IsTest
private class AccountServiceTest {
@TestSetup
static void setupTestData() {
// Use Test Data Factory for consistent data creation
List<Account> accounts = TestDataFactory.createAccounts(5);
insert accounts;
}
@IsTest
static void testCreateAccount_Success() {
// Given
Account testAccount = new Account(Name = 'Test Account');
// When
Test.startTest();
Id accountId = AccountService.createAccount(testAccount);
Test.stopTest();
// Then
Assert.isNotNull(accountId, 'Account ID should not be null');
Account inserted = [SELECT Name FROM Account WHERE Id = :accountId];
Assert.areEqual('Test Account', inserted.Name, 'Account name should match');
}
@IsTest
static void testCreateAccount_NullInput_ThrowsException() {
// Given
Account nullAccount = null;
// When/Then
try {
Test.startTest();
AccountService.createAccount(nullAccount);
Test.stopTest();
Assert.fail('Expected IllegalArgumentException was not thrown');
} catch (IllegalArgumentException e) {
Assert.isTrue(e.getMessage().contains('cannot be null'),
'Error message should mention null: ' + e.getMessage());
}
}
}Pattern 2: Bulk Test (251+ Records)
Use template: assets/bulk-test.cls
@IsTest
static void testBulkInsert_251Records() {
// Given - 251 records crosses the 200-record batch boundary
List<Account> accounts = TestDataFactory.createAccounts(251);
// When
Test.startTest();
insert accounts; // Triggers fire in batches of 200, then 51
Test.stopTest();
// Then
Integer count = [SELECT COUNT() FROM Account];
Assert.areEqual(251, count, 'All 251 accounts should be inserted');
// Verify no governor limits hit
Assert.isTrue(Limits.getQueries() < 100,
'Should not approach SOQL limit: ' + Limits.getQueries());
}Pattern 3: Mock Callout Test
Use template: assets/mock-callout-test.cls
@IsTest
private class ExternalAPIServiceTest {
// Mock class for HTTP callouts
private class MockHttpResponse implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setBody('{"success": true, "data": {"id": "12345"}}');
return res;
}
}
@IsTest
static void testCallExternalAPI_Success() {
// Given
Test.setMock(HttpCalloutMock.class, new MockHttpResponse());
// When
Test.startTest();
String result = ExternalAPIService.callAPI('test-endpoint');
Test.stopTest();
// Then
Assert.isTrue(result.contains('success'), 'Response should indicate success');
}
}Pattern 4: Test Data Factory
Use template: assets/test-data-factory.cls
@IsTest
public class TestDataFactory {
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 = 'Test Account ' + i,
Industry = 'Technology',
BillingCity = 'San Francisco'
));
}
return accounts;
}
public static List<Contact> createContacts(Integer count, Id accountId) {
List<Contact> contacts = new List<Contact>();
for (Integer i = 0; i < count; i++) {
contacts.add(new Contact(
FirstName = 'Test',
LastName = 'Contact ' + i,
AccountId = accountId,
Email = 'test' + i + '@example.com'
));
}
return contacts;
}
// Convenience method with insert
public static List<Account> createAndInsertAccounts(Integer count) {
List<Account> accounts = createAccounts(count);
insert accounts;
return accounts;
}
}