
Generating Apex Test
- 2.6k installs
- 763 repo stars
- Updated July 24, 2026
- forcedotcom/sf-skills
generating-apex-test is an agent skill that generates and validates Salesforce Apex test classes with TestDataFactory patterns, bulk coverage, mocking, and Assert-based assertions.
About
generating-apex-test is a Salesforce agent skill that produces production-ready Apex test classes with disciplined test-fix loops and coverage analysis. Core principles require one behavior per method, bulk tests with 251 or more records, TestDataFactory delegation in every @TestSetup, meaningful Assert class assertions, HttpCalloutMock boundaries, negative path coverage, and Test.startTest plus Test.stopTest wrappers. The workflow gathers target classes, existing factories, coverage thresholds, and org aliases, then emits both the test class and matching .cls-meta.xml metadata files. It forbids legacy System.assert calls, SOQL inside loops, magic-number assertions, and god test classes over 500 lines while pointing to reference docs for async testing, mocking, and assertion patterns. Anti-pattern tables document fixes for generic exception catches and long Given-When-Then methods. Developers reach for generating-apex-test when creating new Apex tests, improving coverage, debugging failing tests, or implementing patterns for triggers, services, controllers, batch jobs, and integrations on Salesforce platforms.
- Requires one behavior per test method with separate positive, negative, and bulk scenarios.
- Mandates 251+ record bulk tests and TestDataFactory usage inside every @TestSetup.
- Uses Assert.areEqual and Assert.fail instead of legacy System.assert methods.
- Creates both Test.cls and Test.cls-meta.xml deliverables for every generated class.
- Wraps code under test in Test.startTest and Test.stopTest to reset governor limits.
Generating Apex Test by the numbers
- 2,645 all-time installs (skills.sh)
- +7 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #318 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)
generating-apex-test capabilities & compatibility
- Capabilities
- apex test class scaffolding · bulk and negative path test patterns · mocking and assert based verification
- Use cases
- testing
npx skills add https://github.com/forcedotcom/sf-skills --skill generating-apex-testAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.6k |
|---|---|
| repo stars | ★ 763 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | forcedotcom/sf-skills ↗ |
How do I create Salesforce-compliant Apex tests with bulk data, negative paths, and enough coverage to deploy?
Generate complete, Salesforce-compliant Apex test classes that follow AAA structure, bulk testing, and positive/negative path coverage.
Who is it for?
Salesforce developers adding or fixing Apex tests for triggers, services, batch jobs, or integrations.
Skip if: Skip for production Apex implementation without tests or for Jest and Lightning Web Component front-end tests.
When should I use this skill?
User creates or fixes Apex test classes, runs sf apex test, or needs coverage for triggers, services, or batch jobs.
What you get
Ready-to-run @isTest classes with metadata files, factory-backed setup, bulk scenarios, mocks, and meaningful assertions.
- @isTest Apex class file
- Bulk and path-coverage test method stubs
By the numbers
- Bulk tests target 251+ records to cross the 200-record trigger batch boundary
Files
Generating Apex Tests
Generate production-ready Apex test classes and run disciplined test-fix loops with coverage analysis.
Core Principles
1. One behavior per method — each test method validates a single scenario. Separate positive, negative, and bulk tests. NEVER combine related-but-distinct inputs (e.g., null and empty) in one method — create _NullInput_ and _EmptyInput_ as separate test methods 2. Bulkify tests — test with 251+ records to cross the 200-record trigger batch boundary. Batch Apex exception: in test context only one execute() invocation runs, so set batchSize >= testRecordCount. See references/async-testing.md 3. Isolate test data — every @TestSetup must delegate record creation to a TestDataFactory class. If none exists, create one first. Never build record lists inline in @TestSetup. Never rely on org data (SeeAllData=false) or hardcoded IDs. For duplicate rule handling, see references/test-data-factory.md 4. Assert meaningfully — use exact expected values computed from test data setup. NEVER use range assertions or approximate counts when the value is deterministic. Always include failure messages. See references/assertion-patterns.md 5. Use `Assert` class only — Assert.areEqual, Assert.isTrue, Assert.fail, etc. Never use legacy System.assert, System.assertEquals, or System.assertNotEquals 6. Mock external boundaries — use HttpCalloutMock for callouts, Test.setFixedSearchResults for SOSL, DML mock classes for database isolation. Design for testability via constructor injection. See references/mocking-patterns.md 7. Test negative paths — validate error handling and exception scenarios, not just happy paths 8. Wrap with start/stop — pair Test.startTest() with Test.stopTest() to reset governor limits and force async execution
Test.startTest() / Test.stopTest()
Always wrap the code under test in Test.startTest() / Test.stopTest():
- Resets governor limits so the test measures only the code under test
- Executes async operations synchronously (queueables, batch, future methods)
- Fires scheduled jobs immediately
Test Code Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
| SOQL/DML inside loops | Query once before the loop; use Map<Id, SObject> for lookups |
| Magic numbers in assertions | Derive expected values from setup constants |
| God test class (>500 lines) | Split into multiple test classes by behavior area |
| Long test methods (>30 lines) | Extract Given/When/Then into helper methods |
Generic Exception catch | Catch the specific expected type (e.g., DmlException) |
Workflow
Step 1 — Gather Context
Before generating or fixing tests, identify:
- the target production class(es) under test
- existing test classes, test data factories, and setup helpers
- desired test scope (single class, specific methods, suite, or local tests)
- coverage threshold (75% minimum for deploy, 90%+ recommended)
- org alias when running tests against an org
Step 2 — Generate the Test Class
Apply the structure, naming conventions, and patterns from the asset templates and reference docs.
MANDATORY — File Deliverables: For every test class, create BOTH files: 1. {ClassName}Test.cls — the test class (use assets/test-class-template.cls as starting point) 2. {ClassName}Test.cls-meta.xml — the metadata file:
<?xml version="1.0" encoding="UTF-8"?>
<ApexClass xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>66.0</apiVersion>
<status>Active</status>
</ApexClass>If no TestDataFactory exists in the project, create TestDataFactory.cls + TestDataFactory.cls-meta.xml using assets/test-data-factory-template.cls.
@TestSetup Example
@TestSetup
static void setupTestData() {
List<Account> accounts = TestDataFactory.createAccounts(251, true);
}Test Method Structure
Use Given/When/Then:
@isTest
static void shouldUpdateStatus_WhenValidInput() {
// Given
List<Account> accounts = [SELECT Id FROM Account];
// When
Test.startTest();
MyService.processAccounts(accounts);
Test.stopTest();
// Then
List<Account> updated = [SELECT Id, Status__c FROM Account];
Assert.areEqual(251, updated.size(), 'All accounts should be processed');
}Negative Test — Exception Pattern
Use try/catch with Assert.fail to verify expected exceptions:
@isTest
static void shouldThrowException_WhenInvalidInput() {
// Given
List<Account> emptyList = new List<Account>();
// When/Then
Test.startTest();
try {
MyService.processAccounts(emptyList);
Assert.fail('Expected MyCustomException to be thrown');
} catch (MyCustomException e) {
Assert.isTrue(e.getMessage().contains('cannot be empty'),
'Exception message should indicate empty input');
}
Test.stopTest();
}Naming Convention
should[ExpectedResult]_When[Scenario]:shouldSendNotification_WhenOpportunityClosedWon[SubjectOrAction]_[Scenario]_[ExpectedResult]:AccountUpdate_ChangeName_Success
Step 3 — Run Tests
Start narrow when debugging; widen after the fix is stable.
# Single test class
sf apex run test --class-names MyServiceTest --result-format human --code-coverage --target-org <alias>
# Specific test methods
sf apex run test --tests MyServiceTest.shouldUpdateStatus_WhenValidInput --result-format human --target-org <alias>
# All local tests
sf apex run test --test-level RunLocalTests --result-format human --code-coverage --target-org <alias>Step 4 — Analyze Results
Focus on:
- failing methods — exception types and stack traces
- uncovered lines and weak coverage areas
- whether failures indicate bad test data, brittle assertions, or broken production logic
Step 5 — Fix Loop
When tests fail, run a disciplined fix loop (max 3 iterations — stop and surface root cause if still failing):
1. Read the failing test class and the class under test 2. Identify root cause from error messages and stack traces 3. Apply fix — adjust test data or assertions for test-side issues; delegate production code issues to the generating-apex skill 4. Rerun the focused test before broader regression 5. Repeat until all tests pass, iteration limit reached, or root cause requires design change
Step 6 — Validate Coverage
| Level | Coverage | Purpose |
|---|---|---|
| Production deploy | 75% minimum | Required by Salesforce |
| Recommended | 90%+ | Best practice target |
| Critical paths | 100% | Business-critical code |
Cover all paths: positive, negative/exception, bulk (251+ records), callout/async.
What to Test by Component
| Component | Key Test Scenarios |
|---|---|
| Trigger | Bulk insert/update/delete, recursion guard, field change detection |
| Service | Valid/invalid inputs, bulk operations, exception handling |
| Controller | Page load, action methods, view state |
| Batch | start/execute/finish, scope matching (batch size >= record count), Database.Stateful tracking, error handling, chaining (separate methods — finish() calling Database.executeBatch() throws UnexpectedException) |
| Queueable | Chaining (only first job runs in tests), bulkification, error handling, callout mocks before Test.startTest() |
| Callout | Success response, error response, timeout |
| Selector | Valid/null/empty inputs, bulk (251+), field population, sort order, WITH USER_MODE via System.runAs |
| Scheduled | Direct execution via execute(null), CRON registration via CronTrigger query |
| Platform Event | Test.enableChangeDataCapture(), Test.getEventBus().deliver(), verify subscriber side effects |
Output Expectations
Deliverables per test class:
{ClassName}Test.cls+{ClassName}Test.cls-meta.xml(match API version of class under test; default66.0)TestDataFactory.cls+TestDataFactory.cls-meta.xml(if not already present)
Reference Files
Load on demand for detailed patterns:
| Reference | When to use |
|---|---|
| references/test-data-factory.md | TestDataFactory patterns, field overrides, duplicate rule handling |
| references/assertion-patterns.md | Assertion best practices, anti-patterns, common pitfalls |
| references/mocking-patterns.md | HttpCalloutMock, DML mocking, StubProvider, SOSL, Email, Platform Events |
| references/async-testing.md | Batch, Queueable, Future, Scheduled job testing |
/**
* Test class for {ClassUnderTest}.
* Tests bulk operations (251+ records), positive/negative paths,
* and exception handling.
*/
@isTest
private class {ClassUnderTest}Test {
@TestSetup
static void setupTestData() {
List<Account> accounts = TestDataFactory.createAccounts(251, true);
}
// ─── Positive Tests ───────────────────────────────────────────────────
@isTest
static void shouldPerformExpectedBehavior_WhenValidInput() {
// Given
List<Account> accounts = [SELECT Id, Name FROM Account];
// When
Test.startTest();
// {ClassUnderTest}.methodUnderTest(params);
Test.stopTest();
// Then
// Assert.areEqual(expected, actual, 'Descriptive failure message');
}
@isTest
static void shouldHandleBulkRecords_WhenProcessing251() {
// Given
List<Account> accounts = [SELECT Id FROM Account];
Assert.areEqual(251, accounts.size(), 'Should have 251 test records');
// When
Test.startTest();
// {ClassUnderTest}.bulkMethod(accounts);
Test.stopTest();
// Then
// List<Account> results = [SELECT Id, Status__c FROM Account];
// for (Account acc : results) {
// Assert.areEqual('Processed', acc.Status__c, 'All records should be processed');
// }
}
// ─── Negative Tests ───────────────────────────────────────────────────
@isTest
static void shouldThrowException_WhenNullInput() {
Test.startTest();
try {
// {ClassUnderTest}.methodUnderTest(null);
Assert.fail('Expected exception for null input');
} catch (MyCustomException e) {
Assert.isTrue(e.getMessage().contains('cannot be null'),
'Exception message should mention null input');
}
Test.stopTest();
}
@isTest
static void shouldReturnEmpty_WhenEmptyInput() {
Test.startTest();
// List<SObject> results = {ClassUnderTest}.methodUnderTest(new List<Id>());
Test.stopTest();
// Assert.isTrue(results.isEmpty(), 'Should return empty list for empty input');
}
// ─── Edge Case Tests ──────────────────────────────────────────────────
@isTest
static void shouldHandleMixedRecords_WhenSomeQualify() {
// Given
List<Account> accounts = [SELECT Id, Status__c FROM Account];
Integer half = accounts.size() / 2;
// for (Integer i = 0; i < half; i++) {
// accounts[i].Status__c = 'Qualifying';
// }
// update accounts;
// When
Test.startTest();
// {ClassUnderTest}.conditionalMethod(accounts);
Test.stopTest();
// Then
// List<Account> qualifying = [SELECT Id FROM Account WHERE Processed__c = true];
// Assert.areEqual(half, qualifying.size(), 'Only qualifying records should be processed');
}
}
/**
* @description Centralized factory for creating test data with sensible defaults.
* All methods accept a doInsert flag for flexibility.
* Bulk methods create multiple records; single-record methods delegate to bulk.
*/
@isTest
public class TestDataFactory {
// ─── Accounts ─────────────────────────────────────────────────────────
public static List<Account> createAccounts(Integer count, Boolean doInsert) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(new Account(
Name = 'Test Account ' + i,
BillingCity = 'San Francisco',
BillingState = 'CA',
BillingCountry = 'USA',
Industry = 'Technology',
Type = 'Customer'
));
}
if (doInsert) insert accounts;
return accounts;
}
public static Account createAccount(Boolean doInsert) {
return createAccounts(1, doInsert)[0];
}
// ─── Contacts ─────────────────────────────────────────────────────────
public static List<Contact> createContacts(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
List<Contact> contacts = new List<Contact>();
Integer idx = 0;
for (Account acc : accounts) {
for (Integer i = 0; i < countPerAccount; i++) {
contacts.add(new Contact(
FirstName = 'Test',
LastName = 'Contact ' + idx,
Email = 'test.contact' + idx + '@example.com',
AccountId = acc.Id
));
idx++;
}
}
if (doInsert) insert contacts;
return contacts;
}
// ─── Opportunities ────────────────────────────────────────────────────
public static List<Opportunity> createOpportunities(List<Account> accounts, Integer countPerAccount, Boolean doInsert) {
List<Opportunity> opps = new List<Opportunity>();
Integer idx = 0;
for (Account acc : accounts) {
for (Integer i = 0; i < countPerAccount; i++) {
opps.add(new Opportunity(
Name = 'Test Opportunity ' + idx,
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 10000 + (idx * 1000)
));
idx++;
}
}
if (doInsert) insert opps;
return opps;
}
// ─── Users ────────────────────────────────────────────────────────────
public static User createUser(String profileName, Boolean doInsert) {
Profile p = [SELECT Id FROM Profile WHERE Name = :profileName LIMIT 1];
String uniqueKey = String.valueOf(DateTime.now().getTime());
User u = new User(
FirstName = 'Test',
LastName = 'User ' + uniqueKey,
Email = 'testuser' + uniqueKey + '@example.com',
Username = 'testuser' + uniqueKey + '@example.com.test',
Alias = 'tuser',
TimeZoneSidKey = 'America/Los_Angeles',
LocaleSidKey = 'en_US',
EmailEncodingKey = 'UTF-8',
LanguageLocaleKey = 'en_US',
ProfileId = p.Id
);
if (doInsert) insert u;
return u;
}
// ─── Field Override Pattern ────────────────────────────────────────────
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
Account acc = new Account(
Name = 'Test Account',
Industry = 'Technology'
);
for (String fieldName : fieldOverrides.keySet()) {
acc.put(fieldName, fieldOverrides.get(fieldName));
}
if (doInsert) insert acc;
return acc;
}
// ─── Custom Objects ───────────────────────────────────────────────────
// Add methods for your custom objects following the same pattern:
// public static List<MyObject__c> createMyObjects(Integer count, Boolean doInsert) { ... }
}
Credits & Acknowledgments
This skill is built on established Salesforce Apex development patterns and the collective knowledge of the Salesforce developer community.
---
Assertion Patterns
Assertion Methods
| Method | Use Case |
|---|---|
Assert.areEqual(expected, actual, msg) | Exact equality |
Assert.areNotEqual(expected, actual, msg) | Value should differ |
Assert.isTrue(condition, msg) | Boolean condition |
Assert.isFalse(condition, msg) | Negated boolean condition |
Assert.fail(msg) | Force failure (e.g., expected exception not thrown) |
Assert.isNotNull(value, msg) | Non-null check |
Assert.isNull(value, msg) | Null check |
Always include the message parameter — makes test failures actionable.
Good vs Bad Assertions
Bad: No message, tests coverage not behavior
Assert.isTrue(result); // no message
Assert.isTrue(accounts.size() > 0); // vague — use areEqual with exact countGood: Descriptive message, tests specific behavior
Assert.isTrue(result, 'Service should return true for valid input');
Assert.areEqual(200, accounts.size(), 'All 200 accounts should be processed');Common Assertion Patterns
Collection Size
Assert.areEqual(200, results.size(), 'Should process all 200 records');
Assert.isTrue(results.isEmpty(), 'No results expected for invalid input');
Assert.isFalse(results.isEmpty(), 'Results should not be empty');Field Values
Assert.areEqual('Processed', acc.Status__c, 'Account status should be updated to Processed');
for (Account acc : updatedAccounts) {
Assert.areEqual('Active', acc.Status__c,
'Account ' + acc.Name + ' should have Active status');
}Exception Testing
@isTest
static void shouldThrowException_WhenInputInvalid() {
Test.startTest();
try {
MyService.process(null);
Assert.fail('Expected MyCustomException for null input');
} catch (MyCustomException e) {
Assert.isTrue(e.getMessage().contains('cannot be null'),
'Exception message should mention null input');
}
Test.stopTest();
}DML Results
// Insert success
Database.SaveResult[] results = Database.insert(accounts, false);
for (Database.SaveResult sr : results) {
Assert.isTrue(sr.isSuccess(), 'Insert should succeed: ' + sr.getErrors());
}
Database.SaveResult sr = Database.insert(invalidAccount, false);
Assert.isFalse(sr.isSuccess(), 'Insert should fail for invalid data');
Assert.isTrue(sr.getErrors()[0].getMessage().contains('REQUIRED_FIELD_MISSING'),
'Error should indicate missing required field');Null Checks
Assert.isNull(result.ErrorMessage__c, 'No error expected for valid input');
Assert.isNotNull(result.Id, 'Record should have been inserted');Date/DateTime
Assert.areEqual(Date.today(), record.CreatedDate__c, 'Should be created today');
Assert.isTrue(record.DueDate__c >= Date.today(), 'Due date should be in the future');Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
Assert.isTrue(results.size() > 0) | Use Assert.areEqual(expectedCount, results.size(), ...) |
Assert.isTrue(results.size() >= expected) | Compute exact expected count, use Assert.areEqual |
| Testing implementation not behavior | Assert on observable outcomes (field values, record counts) |
| Missing negative test assertions | Verify the actual outcome, not just that no exception occurred |
Assert.isTrue(count != 0) | Use Assert.areEqual with deterministic value from test data |
Async Testing Patterns
Key Principle
Test.stopTest() forces all async operations to execute synchronously, allowing assertions on their results.
Batch Apex Testing
Basic Batch Test
Critical: In test context only one execute() invocation runs, so always set batchSize >= testRecordCount (e.g., Database.executeBatch(batch, 200) with 200 records). Never create more records than the batch size.
@isTest
static void shouldProcessAllRecords_WhenBatchExecutes() {
List<Account> accounts = TestDataFactory.createAccounts(200, true);
Test.startTest();
MyBatchClass batch = new MyBatchClass();
Id batchId = Database.executeBatch(batch, 200);
Test.stopTest();
List<Account> updated = [SELECT Id, Status__c FROM Account];
for (Account acc : updated) {
Assert.areEqual('Processed', acc.Status__c,
'Batch should update all account statuses');
}
}Batch with Failures
@isTest
static void shouldLogErrors_WhenRecordsFail() {
List<Account> accounts = TestDataFactory.createAccounts(198, true);
List<Account> invalidAccounts = new List<Account>();
for (Integer i = 0; i < 2; i++) {
invalidAccounts.add(new Account(
Name = 'Invalid Account ' + i,
Invalid_Field__c = 'triggers_validation_error'
));
}
insert invalidAccounts;
Test.startTest();
MyBatchClass batch = new MyBatchClass();
Database.executeBatch(batch, 200);
Test.stopTest();
List<Error_Log__c> errors = [SELECT Id, Message__c FROM Error_Log__c];
Assert.areEqual(2, errors.size(), 'Should log 2 failed records');
}Batch Chaining
Test finish() chaining in a separate test method — calling Database.executeBatch() inside finish() during a test can throw UnexpectedException. Verify the first batch independently, then test that finish() enqueues the next batch.
Queueable Testing
Basic Queueable Test
@isTest
static void shouldCompleteProcessing_WhenQueueableEnqueued() {
Account acc = TestDataFactory.createAccount(true);
Test.startTest();
MyQueueableClass queueable = new MyQueueableClass(acc.Id);
System.enqueueJob(queueable);
Test.stopTest();
Account updated = [SELECT Id, Status__c FROM Account WHERE Id = :acc.Id];
Assert.areEqual('Processed', updated.Status__c,
'Queueable should update account status');
}Queueable Chaining
Only the first chained queueable executes in tests. Design tests to verify: 1. First job completes correctly 2. Chain is properly enqueued (query AsyncApexJob) 3. Each job works independently in its own test method
@isTest
static void shouldChainNextJob_WhenMoreRecordsExist() {
List<Account> accounts = TestDataFactory.createAccounts(500, true);
Test.startTest();
MyChainedQueueable queueable = new MyChainedQueueable(0, 100);
System.enqueueJob(queueable);
Test.stopTest();
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
Assert.areEqual(100, processed.size(), 'First batch should process 100 records');
List<AsyncApexJob> jobs = [
SELECT Id, Status, JobType
FROM AsyncApexJob
WHERE ApexClass.Name = 'MyChainedQueueable'
];
Assert.isTrue(jobs.size() >= 1, 'Chained job should be enqueued');
}Queueable with Callouts
Set the callout mock before Test.startTest():
@isTest
static void shouldMakeCallout_WhenQueueableWithCallout() {
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, '{"status":"ok"}'));
Account acc = TestDataFactory.createAccount(true);
Test.startTest();
MyQueueableWithCallout queueable = new MyQueueableWithCallout(acc.Id);
System.enqueueJob(queueable);
Test.stopTest();
Account updated = [SELECT Id, External_Status__c FROM Account WHERE Id = :acc.Id];
Assert.areEqual('Synced', updated.External_Status__c,
'Should update status after successful callout');
}Future Method Testing
@isTest
static void shouldExecuteFutureMethod() {
Account acc = TestDataFactory.createAccount(true);
Test.startTest();
MyClass.processFuture(acc.Id);
Test.stopTest();
Account updated = [SELECT Id, Processed__c FROM Account WHERE Id = :acc.Id];
Assert.isTrue(updated.Processed__c, 'Future should process record');
}Scheduled Apex Testing
Direct Execution
@isTest
static void shouldExecuteScheduledJob() {
List<Account> accounts = TestDataFactory.createAccounts(50, true);
Test.startTest();
MyScheduledClass scheduled = new MyScheduledClass();
scheduled.execute(null);
Test.stopTest();
List<Account> processed = [SELECT Id FROM Account WHERE Processed__c = true];
Assert.areEqual(50, processed.size(), 'Scheduled job should process records');
}CRON Registration
@isTest
static void shouldScheduleJob() {
Test.startTest();
String cronExp = '0 0 6 * * ?';
String jobId = System.schedule('Daily Processing', cronExp, new MyScheduledClass());
Test.stopTest();
CronTrigger ct = [
SELECT Id, CronExpression, State
FROM CronTrigger
WHERE Id = :jobId
];
Assert.areEqual('0 0 6 * * ?', ct.CronExpression, 'CRON should match');
Assert.areEqual('WAITING', ct.State, 'Job should be waiting');
}Common Pitfalls
| Pitfall | Impact |
|---|---|
Missing Test.stopTest() | Async never executes, assertions fail silently |
| Expecting all chained queueables to run | Only the first runs; test each independently |
Mock set after Test.startTest() | Callout mock must be set before Test.startTest() |
| Batch size < record count in tests | Only batchSize records processed; set batchSize >= recordCount |
Mocking Patterns
HTTP Callout Mocking
Apex doesn't allow real HTTP callouts in tests. Use HttpCalloutMock interface.
Basic Mock
@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(statusCode);
res.setBody(body);
res.setHeader('Content-Type', 'application/json');
return res;
}
}Using the Mock
@isTest
static void shouldProcessApiResponse_WhenCalloutSucceeds() {
String mockResponse = '{"status": "success", "data": [{"id": "123"}]}';
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(200, mockResponse));
Test.startTest();
List<ExternalRecord> results = MyIntegrationService.fetchRecords();
Test.stopTest();
Assert.areEqual(1, results.size(), 'Should parse one record from response');
Assert.areEqual('123', results[0].externalId, 'Should extract correct ID');
}
@isTest
static void shouldHandleError_WhenCalloutFails() {
String errorResponse = '{"error": "Unauthorized"}';
Test.setMock(HttpCalloutMock.class, new MockHttpResponse(401, errorResponse));
Test.startTest();
CalloutResult result = MyIntegrationService.fetchRecords();
Test.stopTest();
Assert.areEqual(false, result.isSuccess, 'Should indicate failure');
Assert.isTrue(result.errorMessage.contains('Unauthorized'), 'Should capture error');
}Multi-Request Mock
For services making multiple callouts:
@isTest
public class MultiRequestMock implements HttpCalloutMock {
private Map<String, HttpResponse> endpointResponses;
public MultiRequestMock(Map<String, HttpResponse> responses) {
this.endpointResponses = responses;
}
public HTTPResponse respond(HTTPRequest req) {
String endpoint = req.getEndpoint();
for (String key : endpointResponses.keySet()) {
if (endpoint.contains(key)) {
return endpointResponses.get(key);
}
}
HttpResponse res = new HttpResponse();
res.setStatusCode(404);
res.setBody('{"error": "Not found"}');
return res;
}
}StaticResourceCalloutMock
Use when response JSON is large or complex:
@isTest
static void shouldParseComplexResponse() {
StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
mock.setStaticResource('TestApiResponse');
mock.setStatusCode(200);
mock.setHeader('Content-Type', 'application/json');
Test.setMock(HttpCalloutMock.class, mock);
Test.startTest();
Result r = MyService.callExternalApi();
Test.stopTest();
Assert.isNotNull(r, 'Should parse response');
}SOSL Mocking
SOSL returns empty results in tests by default. Call Test.setFixedSearchResults(List<Id>) before the search:
@isTest
static void shouldReturnSearchResults() {
Account acc = TestDataFactory.createAccount(true);
Test.setFixedSearchResults(new List<Id>{ acc.Id });
Test.startTest();
List<Account> results = MyService.searchAccounts('Test');
Test.stopTest();
Assert.areEqual(1, results.size(), 'Should return mocked search result');
}DML Mocking (Constructor Injection)
Design for testability — use a public constructor for production and a @TestVisible private constructor that accepts mock interfaces:
public class MyService {
private IDML dmlHandler;
public MyService() {
this.dmlHandler = new DMLHandler();
}
@TestVisible
private MyService(IDML dmlHandler) {
this.dmlHandler = dmlHandler;
}
public void createRecords(List<Account> accounts) {
dmlHandler.doInsert(accounts);
}
}Stub API (System.StubProvider)
For mocking Apex class dependencies:
@isTest
public class MyServiceMock implements System.StubProvider {
public Object handleMethodCall(
Object stubbedObject, String stubbedMethodName, Type returnType,
List<Type> paramTypes, List<String> paramNames, List<Object> args
) {
if (stubbedMethodName == 'getAccountData') {
return new AccountData('Mock Account', 'Active');
}
return null;
}
}
@isTest
static void shouldUseAccountData() {
MyServiceMock mockProvider = new MyServiceMock();
IMyService mockService = (IMyService) Test.createStub(IMyService.class, mockProvider);
MyController controller = new MyController(mockService);
Test.startTest();
String result = controller.displayAccountInfo();
Test.stopTest();
Assert.isTrue(result.contains('Mock Account'), 'Should use mocked data');
}Email Testing
Apex doesn't actually send emails in tests. Use limits to verify:
@isTest
static void shouldSendEmail_WhenTriggered() {
Integer emailsBefore = Limits.getEmailInvocations();
Test.startTest();
MyService.sendNotification(testContact);
Test.stopTest();
Assert.areEqual(emailsBefore + 1, Limits.getEmailInvocations(),
'One email should be sent');
}Platform Event Testing
@isTest
static void shouldPublishEvent_WhenRecordCreated() {
Test.startTest();
Test.enableChangeDataCapture();
Account acc = TestDataFactory.createAccount(true);
Test.getEventBus().deliver();
Test.stopTest();
// Query platform event trigger results
List<EventLog__c> logs = [SELECT Id FROM EventLog__c WHERE AccountId__c = :acc.Id];
Assert.areEqual(1, logs.size(), 'Event handler should create log record');
}TestDataFactory Patterns
For the base class template, see assets/test-data-factory-template.cls.
Design Rules
1. Always accept a `doInsert` flag — lets callers modify records before insert 2. Append loop index to all fields that participate in matching rules — prevents DUPLICATES_DETECTED errors from active Duplicate Rules 3. Single-record methods delegate to bulk — e.g., createAccount(doInsert) calls createAccounts(1, doInsert)[0] 4. Return created records — enables chaining and further manipulation 5. Set all required fields — include fields enforced by validation rules, not just schema-required fields
Field Override Pattern
Allow callers to override default values without creating new factory methods:
public static Account createAccount(Map<String, Object> fieldOverrides, Boolean doInsert) {
Account acc = new Account(
Name = 'Test Account',
Industry = 'Technology'
);
for (String fieldName : fieldOverrides.keySet()) {
acc.put(fieldName, fieldOverrides.get(fieldName));
}
if (doInsert) insert acc;
return acc;
}
// Usage:
Account acc = TestDataFactory.createAccount(new Map<String, Object>{
'Name' => 'Custom Name',
'Industry' => 'Healthcare'
}, true);Record Type Support
public static Account createAccountByRecordType(String recordTypeName, Boolean doInsert) {
Id recordTypeId = Schema.SObjectType.Account
.getRecordTypeInfosByDeveloperName()
.get(recordTypeName)
.getRecordTypeId();
Account acc = new Account(
Name = 'Test Account',
RecordTypeId = recordTypeId
);
if (doInsert) insert acc;
return acc;
}Handling Duplicate Rules
When unique field values alone are not sufficient, use Database.insert() with a DuplicateRuleHeader:
public static List<Account> createAccountsAllowDuplicates(Integer count, Boolean doInsert) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(new Account(
Name = 'Test Account ' + i,
Phone = '555-000-' + String.valueOf(i).leftPad(4, '0')
));
}
if (doInsert) {
Database.DMLOptions dml = new Database.DMLOptions();
dml.DuplicateRuleHeader.allowSave = true;
Database.insert(accounts, dml);
}
return accounts;
}Related skills
Forks & variants (1)
Generating Apex Test has 1 known copy in the catalog totaling 1.6k installs. They canonicalize to this original listing.
- forcedotcom - 1.6k installs
How it compares
Pick generating-apex-test when you need Salesforce-specific @isTest scaffolding with bulk and path coverage rather than generic unit-test generators.
FAQ
How many records should bulk Apex tests use?
Test with 251 or more records to cross the 200-record trigger batch boundary unless batch Apex rules apply.
Which assertion API must tests use?
The Assert class such as Assert.areEqual and Assert.fail, never legacy System.assert methods.
What files must be created for each test class?
Both the Test.cls file and matching Test.cls-meta.xml metadata with apiVersion and Active status.
Is Generating Apex Test safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.