
Running Apex Tests
- 2.5k installs
- 763 repo stars
- Updated July 24, 2026
- forcedotcom/sf-skills
running-apex-tests is a Salesforce skill for Apex test execution, coverage analysis, and structured test-fix loops via sf apex run test.
About
Running Apex Tests guides Salesforce test execution, coverage analysis, and structured test-fix loops using sf apex run test workflows and a 120-point scoring rubric. It owns tasks involving Apex unit test failures, code coverage gaps, uncovered lines, and disciplined reruns while delegating production Apex authoring to generating-apex and LWC Jest work elsewhere. The workflow discovers test scope and factories, runs the smallest useful test set first, analyzes failures and stack traces, fixes code or tests, and widens regression only after stability. High-signal rules require SeeAllData=false, meaningful assertions, bulk tests with 251+ records, Test.startTest and stopTest for async, and factories or TestSetup for clarity. Gotchas cover SeeAllData org dependencies, uncommitted work pending on callout tests, mock setup order, and TestSetup re-query requirements. Reference files span CLI commands, test patterns, mocking, performance optimization, and agentic test-fix loops with parse-test-results hooks. Output reports test scope, pass or fail summary, coverage, root causes, and next steps.
- sf apex run test scoped execution and coverage analysis.
- Disciplined narrow-to-wide test-fix loop workflow.
- SeeAllData=false and 251+ bulk record testing rules.
- Mock callout and TestSetup gotcha resolution table.
- 120-point scoring guide for test confidence quality.
Running Apex Tests by the numbers
- 2,484 all-time installs (skills.sh)
- +7 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #331 of 2,184 Testing & QA skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
running-apex-tests capabilities & compatibility
- Capabilities
- scoped sf apex run test execution · failure and stack trace analysis · coverage gap identification · bulk and async test pattern guidance · mock callout and testsetup troubleshooting · test fix loop with parse test results hook
- Works with
- salesforce
- Use cases
- testing · api development
- Pricing
- Free
What running-apex-tests says it does
Test bulk behavior with 251+ records
npx skills add https://github.com/forcedotcom/sf-skills --skill running-apex-testsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.5k |
|---|---|
| repo stars | ★ 763 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
How do I run Apex tests, interpret failures, and raise coverage without flaky org-dependent tests?
Run Salesforce Apex tests, analyze coverage, and execute disciplined test-fix loops with sf apex run test workflows.
Who is it for?
Salesforce developers debugging Apex test failures, coverage drops, or bulk and async test scenarios.
Skip if: Skip for writing production Apex, Agentforce agent testing, or LWC Jest component tests.
When should I use this skill?
User runs Apex tests, checks code coverage, fixes failing Test.cls files, or manages test-fix loops.
What you get
Focused test run report with coverage percent, root-cause findings, and next rerun or fix recommendation.
- Apex @IsTest class
- Test Data Factory setup method
By the numbers
- Targets 90%+ Apex code coverage
- TestSetup creates 5 records via Test Data Factory by default
Files
running-apex-tests: 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 running-apex-tests 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 →
generating-apexskill - testing Agentforce agents →
testing-agentforceskill - testing LWC with Jest → generating-lwc-components
---
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
generating-apexskill 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
| Rule | Rationale |
|---|---|
Default to SeeAllData=false | Ensures test isolation; prevents reliance on org-specific data |
| Every test must assert meaningful outcomes | Tests with no assertions prove nothing and give false confidence |
| Test bulk behavior with 251+ records | Triggers process in batches of 200; 251 records crosses the boundary |
Use factories / @TestSetup when they improve clarity | Consistent data creation in one place; rolled back between test methods |
Pair Test.startTest() with Test.stopTest() for async | Ensures async operations (queueable, future) complete before assertions |
| Do not hide flaky org dependencies inside tests | Prevents intermittent failures tied to org state |
---
Gotchas
| Issue | Resolution |
|---|---|
| Test passes locally but fails in CI org | Check for SeeAllData=true or undeclared dependencies on org-specific records |
| Coverage drops unexpectedly after refactor | Run focused class-level tests first, then widen to RunLocalTests to confirm |
| "Uncommitted work pending" error in callout test | DML and HTTP callouts cannot be mixed in the same test context without Test.startTest() wrapping |
| Mock not taking effect in test | Ensure Test.setMock() is called before the code that makes the callout |
@TestSetup data missing in test method | @TestSetup data is committed per test method — re-query it; do not store in static variables |
---
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 test classes | generating-apex skill | Code generation and repair |
| Create bulk / edge-case test data | handling-sf-data | Realistic test datasets |
| Deploy updated tests to org | deploying-metadata | Deployment workflows |
| Inspect detailed runtime logs | debugging-apex-logs | Deeper failure analysis |
---
Reference File Index
| File | When to read |
|---|---|
references/cli-commands.md | All sf apex run test command flags, output formats, async execution, and coverage commands |
references/test-patterns.md | Test class templates — basic, bulk (251+), mock callout, and data factory patterns |
references/testing-best-practices.md | Core testing principles — AAA pattern, naming conventions, bulk, negative, and mock strategies |
references/test-fix-loop.md | Agentic test-fix loop implementation and failure analysis decision tree |
references/mocking-patterns.md | HttpCalloutMock, DML mocking, StubProvider, and selector mocking patterns |
references/performance-optimization.md | Techniques to reduce test execution time — DML mocking, SOQL mocking, loop optimizations |
assets/basic-test.cls | Template: standard test class with @TestSetup, positive / negative / bulk / edge-case methods |
assets/bulk-test.cls | Template: bulk test with 251+ records that crosses the 200-record trigger batch boundary |
assets/mock-callout-test.cls | Template: HTTP callout mock using HttpCalloutMock |
assets/test-data-factory.cls | Template: reusable TestDataFactory with create and insert helpers |
assets/dml-mock.cls | Template: IDML interface + DMLMock implementation for database-free unit tests |
assets/stub-provider-example.cls | Template: StubProvider-based dependency injection stub |
hooks/scripts/parse-test-results.py | Post-tool hook — parses sf apex run test JSON output and formats failures for the auto-fix loop |
---
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 |
/**
* @description Test class for {{ClassName}}
* Tests core functionality with positive, negative, and bulk scenarios.
* @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.
* @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: references/mocking-patterns.md for usage guidance
*/
// ═══════════════════════════════════════════════════════════════════════════
// 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.
* @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: references/mocking-patterns.md for detailed patterns and usage guidance
*/
// ═══════════════════════════════════════════════════════════════════════════
// 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);
*
* @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 integrates concepts and patterns from Salesforce documentation and the broader Salesforce developer community. The goal is to move beyond minimum coverage targets toward true unit testing with proper isolation, mocking, and disciplined test-fix workflows.
---
Key Patterns Integrated
| Pattern | Integration |
|---|---|
| DML Mocking | assets/dml-mock.cls |
| Mocking vs Stubbing | references/mocking-patterns.md |
| Test Data Factory | assets/test-data-factory.cls |
| HttpCalloutMock | assets/mock-callout-test.cls |
| StubProvider | assets/stub-provider-example.cls |
| Performant Tests | references/performance-optimization.md |
---
Philosophy
This skill teaches Apex developers how to write fast, reliable, maintainable tests using vanilla Apex patterns — no external packages or libraries required. All patterns work in any Salesforce org out of the box.
#!/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()
running-apex-tests
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
Quick Start
1. Invoke the skill
Skill: running-apex-tests
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 |
| debugging-apex-logs | Analyze test failures with debug logs |
| handling-sf-data | Generate 251+ bulk test records |
| deploying-metadata | 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
<!-- Parent: running-apex-tests/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: running-apex-tests/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.
This guide aligns with official Salesforce documentation on Apex testing patterns.
---
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
*/
@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": "Test User"}]}');
} 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
*/
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
*
* StubProvider implementation for dynamic service mocking in Apex tests
*/
@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: running-apex-tests/SKILL.md -->
Performance Optimization for Apex Tests
Fast tests enable faster development. When test suites run quickly, developers refactor confidently. This guide covers techniques to dramatically reduce test execution time.
---
Why Test Speed Matters
| Test Suite Duration | Impact |
|---|---|
| < 5 minutes | Developers run frequently, catch issues early |
| 5-30 minutes | Developers run occasionally, issues slip through |
| 30+ minutes | Developers avoid running, tests become stale |
| Hours | CI/CD bottleneck, blocked deployments |
The goal: Sub-second unit tests, with integration tests taking seconds, not minutes.
---
Technique 1: Mock DML Operations
Database operations are the #1 cause of slow tests.
The Numbers
| Operation | 10,000 Records | Notes |
|---|---|---|
| Actual insert | ~50 seconds | Database round-trips |
| DML mocking | <1 second | In-memory only |
| Improvement | ~35x faster |
Implementation
See assets/dml-mock.cls and references/mocking-patterns.md for complete implementation.
// ❌ SLOW: Actual database insert
List<Account> accounts = TestDataFactory.createAccounts(1000);
insert accounts; // ~5 seconds
// ✅ FAST: Mock DML
DMLMock.reset();
AccountService service = new AccountService(new DMLMock());
service.createAccounts(accounts); // <0.1 seconds
Assert.areEqual(1000, DMLMock.InsertedRecords.size());---
Technique 2: Mock SOQL Queries
Query execution adds overhead, especially with large result sets.
// ❌ SLOW: Actual query requiring test data setup
@TestSetup
static void setup() {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 1000; i++) {
accounts.add(new Account(Name = 'Test ' + i));
}
insert accounts; // Slow
}
// ✅ FAST: Mock query results
AccountSelector.setMockResults(new List<Account>{
new Account(Name = 'Mock 1'),
new Account(Name = 'Mock 2')
});
List<Account> results = AccountSelector.getActiveAccounts(); // Instant---
Technique 3: Minimize @TestSetup
@TestSetup runs before every test method. Large setups compound execution time.
// ❌ SLOW: Heavy @TestSetup
@TestSetup
static void setup() {
List<Account> accounts = TestDataFactory.createAccounts(100);
insert accounts;
List<Contact> contacts = TestDataFactory.createContacts(500, accounts);
insert contacts;
List<Opportunity> opps = TestDataFactory.createOpportunities(200, accounts);
insert opps;
// Total: 800 DML operations, runs before EACH test method
}
// ✅ FAST: Minimal @TestSetup, mock what you can
@TestSetup
static void setup() {
// Only create what MUST exist in database
Account parentAccount = new Account(Name = 'Required Parent');
insert parentAccount;
}---
Technique 4: Choose Efficient Loop Constructs
Loop performance varies significantly with large iterations.
Benchmark Results (10,000 iterations)
| Loop Type | Duration | Notes |
|---|---|---|
| While loop | ~0.4s | Fastest |
| Cached iterator | ~0.8s | Good alternative |
| For loop (index) | ~1.4s | Acceptable |
| Enhanced for loop | ~2.4s | Convenient but slower |
| Uncached iterator | CPU limit | Avoid |
Recommendation
// ✅ PREFERRED: While loop for large iterations
Iterator<Account> iter = accounts.iterator();
while (iter.hasNext()) {
Account acc = iter.next();
// process
}
// ✅ ACCEPTABLE: Standard for loop
for (Integer i = 0; i < accounts.size(); i++) {
Account acc = accounts[i];
// process
}
// ⚠️ CONVENIENT BUT SLOWER: Enhanced for
for (Account acc : accounts) {
// process
}---
Technique 5: Batch Test Data Creation
Creating records one-by-one is slow. Batch operations are faster.
// ❌ SLOW: One-by-one creation
for (Integer i = 0; i < 200; i++) {
Account acc = new Account(Name = 'Test ' + i);
insert acc; // 200 DML statements!
}
// ✅ FAST: Batch creation
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test ' + i));
}
insert accounts; // 1 DML statement---
Technique 6: Use Assert Instead of System.assert
The modern Assert class is cleaner and provides better error messages.
// ❌ OLD: System.assert (still works but verbose)
System.assert(result != null, 'Result should not be null');
System.assertEquals(expected, actual, 'Values should match');
// ✅ MODERN: Assert class (Apex 56.0+)
Assert.isNotNull(result, 'Result should not be null');
Assert.areEqual(expected, actual, 'Values should match');
Assert.isTrue(condition, 'Condition should be true');
Assert.fail('Should not reach here');---
Technique 7: Avoid SOSL in Tests
SOSL searches return empty results in tests unless configured.
// ❌ PROBLEM: SOSL returns nothing in tests by default
List<List<SObject>> results = [FIND 'test' IN ALL FIELDS RETURNING Account];
// results[0] is EMPTY even with matching records!
// ✅ SOLUTION: Use Test.setFixedSearchResults()
@IsTest
static void testSearch() {
Account acc = new Account(Name = 'Searchable');
insert acc;
// Configure what SOSL will return
Test.setFixedSearchResults(new List<Id>{ acc.Id });
Test.startTest();
List<List<SObject>> results = [FIND 'test' IN ALL FIELDS RETURNING Account];
Test.stopTest();
Assert.areEqual(1, results[0].size(), 'Should find configured record');
}---
Technique 8: Strategic Test Method Scoping
Run only the tests you need during development.
# ❌ SLOW: Run all tests (minutes to hours)
sf apex run test --test-level RunLocalTests --target-org sandbox
# ✅ FAST: Run single test class
sf apex run test --class-names MyClassTest --target-org sandbox
# ✅ FASTER: Run single test method
sf apex run test --tests MyClassTest.testSpecificMethod --target-org sandbox---
Technique 9: Async Test Execution
Use async mode for large test suites to avoid blocking.
# Start tests asynchronously
sf apex run test --class-names MyClassTest --wait 0 --target-org sandbox
# Returns test run ID: 707xx0000000000
# Check status later
sf apex get test --test-run-id 707xx0000000000 --target-org sandbox---
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|---|---|---|
| DML in loops | N operations instead of 1 | Bulk DML outside loops |
| Large @TestSetup | Runs before every test | Minimize or mock |
| No mocking | Full database round-trips | Mock DML, queries, callouts |
| SeeAllData=true | Depends on org data | Create test data |
| Deep nested loops | O(n²) or worse | Flatten with Maps |
| String concatenation in loops | New string objects each iteration | Use List and join |
---
Optimization Checklist
Before committing tests, verify:
- [ ] DML operations are mocked where possible
- [ ] @TestSetup is minimal
- [ ] No SOQL/DML inside loops
- [ ] Uses bulk patterns (200+ records)
- [ ] Individual test methods run in <1 second
- [ ] Full test class runs in <10 seconds
- [ ] Uses Assert class (not System.assert)
---
Performance Testing Your Tests
@IsTest
static void testPerformance() {
Long startTime = System.currentTimeMillis();
// Your test code here
Long duration = System.currentTimeMillis() - startTime;
System.debug('Test duration: ' + duration + 'ms');
// Assert performance constraint
Assert.isTrue(duration < 1000, 'Test should complete in <1 second, took: ' + duration + 'ms');
}Related skills
Forks & variants (1)
Running Apex Tests has 1 known copy in the catalog totaling 485 installs. They canonicalize to this original listing.
- forcedotcom - 485 installs
FAQ
Why test with 251+ records?
Bulk tests should cross the 200-record trigger batch boundary to expose governor limit issues.
When is SeeAllData=true risky?
It ties tests to org-specific records and causes passes locally but failures in CI orgs.
How handle async queueable tests?
Wrap execution with Test.startTest and Test.stopTest so async work completes before assertions.
Is Running Apex Tests safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.