sf-apex-testing
Skill2 starsChanged 2 months agoMITView on GitHub
Writes and repairs Salesforce Apex unit tests — @IsTest classes and methods, @TestSetup, Test.startTest/Test.stopTest limit contexts, System.runAs for user-context and FLS/CRUD negative tests, the modern Assert class (Assert.areEqual, isTrue, isNull, isInstanceOfType, fail) versus legacy System.assertEquals, test data factory and builder patterns, Test.loadData with static resources, HttpCalloutMock, StaticResourceCalloutMock, MultiStaticResourceCalloutMock, WebServiceMock, Test.setMock, the Stub API (System.StubProvider and Test.createStub), asynchronous testing of Queueable, Batch, future and Schedulable jobs, Test.getEventBus().deliver() for platform events, Test.enqueueBatchJobs, 200-record bulk trigger tests, code coverage rules and the 75% deployment requirement, sf apex run test flag selection, test suites, flaky-test diagnosis, and how vf-check apex gates apexOrgCoverageMin and apexClassCoverageMin. Use this skill when creating or fixing a *Test.cls, when coverage or a deployment test level blocks a release, when a test is flaky or org-data dependent, or before running vf-check apex or sf apex run test.
1 other project uses a version of this.
Adopted by
Most recently taken up by
- jiten-singh-shahi/salesforce-claude-codetheir own version, first seen Apr 10, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- jiten-singh-shahi/salesforce-claude-code★ 15same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository34 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- sf-agentforce-developmentSkill
- sf-apex-developmentSkill
- sf-async-apex-patternsSkill
- sf-cli-operationsSkill
- sf-code-analyzer-qualitySkill
- sf-data-cloudSkill
- sf-data-managementSkill
- sf-debugging-logsSkill
- sf-deployment-strategiesSkill
- sf-fflib-domain-service-uowSkill
- sf-fflib-foundationsSkill
- sf-fflib-operationsSkill
- sf-fflib-selector-layerSkill
- sf-fflib-testingSkill
- sf-flow-automationSkill
- sf-governor-limitsSkill
- sf-integration-patternsSkill
- sf-local-developmentSkill
- sf-lwc-developmentSkill
- sf-lwc-jest-testingSkill
- sf-minimal-changeSkill
- sf-org-security-auditSkill
- sf-packaging-releaseSkill
- sf-post-deploy-verificationSkill
- sf-process-builder-migrationSkill
- sf-project-structureSkill
- sf-scratch-orgs-sandboxesSkill
- sf-security-modelSkill
- sf-setup-automationSkill
- sf-soql-sosl-optimizationSkill
- sf-technical-debt-auditSkill
- sf-ui-test-automationSkill
- sf-workflow-orchestrationSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 20
Not counted as covered: System.debug calls, test methods, and test classes.
- Very specific: line 110
Use the Assert class. System.assert* still compiles but carries no message discipline and is legacy.
- Distinctive: line 306
Use 200 records: the documented Apex trigger batch size, and the point where a non-bulkified implementation breaks.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Apex Testing
Coverage and test-level rules below are platform requirements; the numeric gates are vibe-force
policy from .vibeforce/config.json.
| Rule | Value | Source |
| Platform minimum coverage to deploy or package Apex | 75% of Apex code, all tests passing | Apex Developer Guide |
Platform per-class minimum under RunSpecifiedTests | 75% per class and per trigger, computed individually | Salesforce CLI reference |
| Every trigger must have some coverage | non-negotiable | Apex Developer Guide |
vibe-force org coverage gate | gates.apexOrgCoverageMin = 85 | config/vibe-force.defaults.json |
vibe-force per-class coverage gate | gates.apexClassCoverageMin = 75 | config/vibe-force.defaults.json |
| New Apex class requires a test class | gates.requireTestForApexClass = true | config/vibe-force.defaults.json |
Not counted as covered: System.debug calls, test methods, and test classes. Conditional and
ternary operators count as executed only when both branches run.
When to use
- Creating or repairing any
*Test.cls, or a deployment fails its test level. - Coverage is below a gate, or differs between sandbox and production.
- A test is flaky, order-dependent, or reads org data.
- Asynchronous work (Queueable, Batch, future, Schedulable, platform events) needs deterministic assertions.
- A callout, an external service, or a collaborator class must be mocked.
Adjacent skills: sf-apex-development (code under test), sf-async-apex-patterns (job design),
sf-governor-limits (Limits assertions), sf-security-model (permission sets for negative
tests), sf-lwc-jest-testing (client tests), sf-deployment-strategies (test levels per
environment), sf-debugging-logs (diagnosing a failing test).
Decision table
| Situation | Do this |
| Shared records for every method in the class | @TestSetup static void setup() — rolled back after each test method |
| Records must exist before the code under test runs | create them before Test.startTest() |
| Need a clean governor-limit budget for the assertion window | wrap the exercise in Test.startTest() / Test.stopTest() |
| Async job must complete before assertions | enqueue inside the startTest/stopTest block; assert after stopTest() |
| Platform event must be delivered | Test.getEventBus().deliver(); after Test.stopTest() |
| Callout in the code path | Test.setMock(HttpCalloutMock.class, new XMock()); |
| Collaborator class must return canned data | Stub API: Test.createStub(IFoo.class, provider) |
Collaborator is static, private, inner, a trigger, or Batchable | Stub API cannot mock it — inject an interface instead |
| SOSL must return known rows | Test.setFixedSearchResults(ids) — SOSL otherwise returns empty in tests |
| Verify behaviour for a restricted user | System.runAs(u) { ... } with a permission-set-assigned user |
| Mixed DML (setup object plus normal object) | wrap in System.runAs(thisUser) { ... } |
Field is not writable from Apex (e.g. CreatedDate) | Test.setCreatedDate(recordId, dt) |
| Must read existing org data | avoid; if truly unavoidable @IsTest(SeeAllData=true) and document why |
| Bulk behaviour | 200 records — the documented Apex trigger batch size |
Core patterns
1. Test class anatomy
@IsTest
private class CaseEscalationServiceTest {
@TestSetup
static void makeData() {
// Runs once per test method, before it; changes roll back afterwards.
insert new List<Account>{
new Account(Name = 'Platinum Co', Tier__c = 'Platinum'),
new Account(Name = 'Standard Co', Tier__c = 'Standard')
};
}
@IsTest
static void escalatesPlatinumCaseToHigh() {
Account platinum = [SELECT Id, OwnerId FROM Account WHERE Tier__c = 'Platinum' LIMIT 1];
Case c = new Case(Subject = 'Outage', Priority = 'Low', AccountId = platinum.Id);
insert c;
Test.startTest();
CaseEscalationService.escalate(new List<Case>{ c });
Test.stopTest();
Case after = [SELECT Priority, OwnerId FROM Case WHERE Id = :c.Id];
Assert.areEqual('High', after.Priority, 'Platinum tier must escalate to High');
Assert.areEqual(platinum.OwnerId, after.OwnerId, 'Case should transfer to the account owner');
}
}@IsTest classes must be top-level, cannot be interfaces or enums, and their access modifiers are
irrelevant — the framework finds the methods regardless. @IsTest code is excluded from the 6 MB
org code limit and from coverage arithmetic. Static member values changed in a @TestSetup or test
method are not preserved for other methods in the class. @TestSetup is unsupported in a class
annotated @IsTest(SeeAllData=true).
2. Test.startTest / Test.stopTest semantics
Both may be called at most once per test method.
| Behaviour | Detail |
| Limit context | code between the two calls receives a new set of governor limits; startTest adds a context rather than refreshing it, so limits consumed before it are restored after stopTest |
| Asynchronous flush | all async calls made after startTest are collected and run synchronously at stopTest |
| Exception during flush | halts the synchronous processing — for example an unhandled exception in a batch execute prevents finish from running |
| Savepoints | from API 60.0, all savepoints are released when startTest and stopTest are called; a SAVEPOINT_RESET event is logged |
Arrange before startTest, act between, assert after stopTest.
3. Assertions
Use the Assert class. System.assert* still compiles but carries no message discipline and is
legacy.
| Legacy | Modern |
System.assertEquals(a, b) | Assert.areEqual(a, b, 'why') |
System.assertNotEquals(a, b) | Assert.areNotEqual(a, b, 'why') |
System.assert(cond) | Assert.isTrue(cond, 'why') |
System.assert(!cond) | Assert.isFalse(cond, 'why') |
System.assert(x == null) | Assert.isNull(x, 'why') |
System.assert(x != null) | Assert.isNotNull(x, 'why') |
System.assert(false, 'unreachable') | Assert.fail('DmlException expected') |
| no equivalent | Assert.isInstanceOfType(o, Account.class) / Assert.isNotInstanceOfType(...) |
Argument order is (expected, actual, message). Negative tests assert the failure, not merely that
something threw:
@IsTest
static void rejectsNegativeAmount() {
Opportunity o = new Opportunity(
Name = 'Bad', StageName = 'Prospecting', CloseDate = Date.today(), Amount = -1
);
try {
insert o;
Assert.fail('Expected a DmlException for a negative Amount');
} catch (DmlException e) {
Assert.areEqual(1, e.getNumDml(), 'Exactly one record should fail');
Assert.isTrue(
e.getDmlMessage(0).contains('Amount'),
'Error must name the offending field, got: ' + e.getDmlMessage(0)
);
}
}Full Assert API table in references/assertions-and-mocks.md.
4. Test data factory
Public @IsTest utility classes are excluded from the org code limit and callable only from test
context. Build records; never read org data.
@IsTest
public class TestDataFactory {
public static List<Account> accounts(Integer count, String tier) {
List<Account> out = new List<Account>();
for (Integer i = 0; i < count; i++) {
out.add(new Account(Name = 'Acct ' + i, Tier__c = tier));
}
insert out;
return out;
}
public static List<Case> cases(Integer perAccount, List<Account> accounts) {
List<Case> out = new List<Case>();
for (Account a : accounts) {
for (Integer i = 0; i < perAccount; i++) {
out.add(new Case(Subject = 'C' + i, Priority = 'Low', AccountId = a.Id));
}
}
insert out;
return out;
}
}Tests are isolated from org data by default — @IsTest(SeeAllData=false) is the implicit behaviour
for API 24.0 and later. SeeAllData=true at class level cannot be overridden by a method-level
false, and it is incompatible with IsParallel=true. Builders, Test.loadData with static
resources, and Test.setCreatedDate recipes are in
references/test-patterns.md.
5. Mocking callouts and collaborators
Tests do not support HTTP callouts; without a mock the test fails.
@IsTest
private class PaymentsClientTest {
private class Mock implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
Assert.areEqual('callout:Payments/charge', req.getEndpoint());
Assert.areEqual('POST', req.getMethod());
HttpResponse res = new HttpResponse();
res.setStatusCode(201);
res.setHeader('Content-Type', 'application/json');
res.setBody('{"id":"ch_1","status":"succeeded"}');
return res;
}
}
@IsTest
static void chargeReturnsProviderId() {
Test.setMock(HttpCalloutMock.class, new Mock());
Test.startTest();
String chargeId = PaymentsClient.charge(1000, 'USD');
Test.stopTest();
Assert.areEqual('ch_1', chargeId, 'Client must surface the provider charge id');
}
}Mock selection:
| Need | Mock |
| One endpoint, logic in Apex | HttpCalloutMock implementation |
| Response body held as a static resource | StaticResourceCalloutMock |
| Several endpoints, each with its own static resource | MultiStaticResourceCalloutMock |
| SOAP callout from a WSDL2Apex stub | WebServiceMock |
| Collaborator Apex class | Stub API — System.StubProvider plus Test.createStub |
Stub API limitations shape design: it cannot mock static methods (including future methods),
private methods, properties, triggers, inner classes, system types, classes implementing
Batchable, or classes with only private constructors; iterators cannot be return or parameter
types; and the mocked object must live in the same namespace as the Test.createStub() call.
A multi-endpoint router and a full StubProvider are in
references/assertions-and-mocks.md.
6. Asynchronous tests
@IsTest
private class InvoiceSyncQueueableTest {
@IsTest
static void queueableMarksInvoicesSynced() {
List<Invoice__c> invoices = TestDataFactory.invoices(5);
Test.setMock(HttpCalloutMock.class, new InvoiceSyncMock());
Test.startTest();
System.enqueueJob(new InvoiceSyncQueueable(new Map<Id, Invoice__c>(invoices).keySet()));
Test.stopTest(); // job runs synchronously here
Assert.areEqual(
5,
[SELECT COUNT() FROM Invoice__c WHERE Sync_Status__c = 'Synced'],
'Every queued invoice should be marked synced'
);
}
}| Async feature | Test technique | Constraint |
| Queueable | System.enqueueJob inside startTest/stopTest | chaining is not executed in tests beyond the first job |
| Batch Apex | Database.executeBatch inside the block | max 5 batch jobs submitted per running test; only one execute chunk runs |
| Future method | call the method inside the block | @Future cannot be invoked from another @Future |
| Schedulable | System.schedule inside the block; job runs at stopTest | cron string still required |
| Flex queue ordering | Test.enqueueBatchJobs(n) plus Test.getFlexQueueOrder() | enqueues no-operation jobs for ordering assertions |
| Platform events | Test.getEventBus().deliver(); after Test.stopTest() | one deliver() per downstream publishing hop |
BatchApexErrorEvent | Database.RaisesPlatformEvents on the batch class, then deliver() | wrap the block in try/catch to swallow the job's exception |
7. Negative CRUD/FLS tests with System.runAs
Inside a runAs block the user's sharing rules and object- and field-level permissions are
enforced regardless of the test class's sharing mode. If a method defined elsewhere is called
inside the block, the sharing mode of that class applies.
@IsTest
private class CaseEscalationServiceSecurityTest {
@IsTest
static void minimumAccessUserCannotEscalate() {
User restricted = TestDataFactory.minimumAccessUser(); // no permission set assigned
Account a = TestDataFactory.accounts(1, 'Platinum')[0];
Case c = new Case(Subject = 'Outage', Priority = 'Low', AccountId = a.Id);
insert c;
System.runAs(restricted) {
Test.startTest();
try {
CaseEscalationService.escalate(new List<Case>{ c });
Assert.fail('A user without Case edit must not be able to escalate');
} catch (Exception e) {
Assert.isTrue(
e.getMessage().containsIgnoreCase('insufficient access')
|| e.getTypeName() == 'System.DmlException',
'Expected an access failure, got ' + e.getTypeName() + ': ' + e.getMessage()
);
}
Test.stopTest();
}
}
}runAs is test-only, ignores user-license limits, may be nested, and every call counts against
the DML statement limit. The positive counterpart assigns a permission set to the same user and
asserts the operation succeeds — that pair is what proves a WITH USER_MODE query is correct.
8. Bulk and trigger tests
Use 200 records: the documented Apex trigger batch size, and the point where a non-bulkified implementation breaks. Testing best practices call for at least 20; 200 matches the platform batch.
@IsTest
static void bulkUpdateStaysWithinLimits() {
List<Case> cases = TestDataFactory.cases(100, TestDataFactory.accounts(2, 'Platinum'));
Test.startTest();
for (Case c : cases) {
c.Priority = 'Medium';
}
update cases;
Integer queries = Limits.getQueries();
Integer statements = Limits.getDmlStatements();
Test.stopTest();
Assert.isTrue(queries <= 5, 'Handler issued ' + queries + ' SOQL queries for one bulk update');
Assert.isTrue(statements <= 3, 'Handler issued ' + statements + ' DML statements');
}Assert an upper bound on Limits.getQueries() and Limits.getDmlStatements() rather than an exact
count — exact counts make the test brittle when an unrelated handler is added. See
sf-governor-limits.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
@IsTest(SeeAllData=true) to make a test pass | coverage and results diverge between sandbox and production when org data changes | create the data in the test |
| No assertions, only execution | coverage rises, defects ship | assert observable outcomes with messages |
Assert.isTrue(true) or bare "did not throw" | tautology | assert the value, the field, or the error text |
| Hard-coded record Ids or profile Ids | fails in every other org | query by name, or use TestIds to fabricate synthetic Ids |
Test.startTest() after the exercise | limits and async flush apply to the wrong window | arrange, startTest, act, stopTest, assert |
Assertions before Test.stopTest() for async work | job has not run | assert after stopTest() |
Callout with no Test.setMock | tests do not support callouts; the method fails | install the appropriate mock |
| One test method covering ten branches | a failure localises nothing | one behaviour per method, named after the behaviour |
System.assertEquals(1, results.size()) with no message | failure output says nothing | always pass the third message argument |
| Relying on record Id ordering | Ids are not created in ascending order across separate requests | add ORDER BY |
Sharing a mutable static between test methods | statics are not preserved across methods | build state inside each method or in @TestSetup |
Test.isRunningTest() branch in production code | untested production path, hides defects | inject a collaborator or use the Stub API |
| 1-record test for trigger logic | non-bulkified code passes | 200-record test |
Verification
# Local gate first (no org): format, lint, analyzer, LWC Jest
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed
# Apex tests plus coverage gates; writes .vibeforce/reports/apex-<ISO>.json
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-dev
# Narrow, fast loop on one class, synchronously, with per-test coverage detail
sf apex run test --tests CaseEscalationServiceTest --synchronous \
--code-coverage --detailed-coverage --result-format human --target-org vf-dev
# Suite run with machine-readable output for CI
sf apex run test --suite-names CoreRegression --code-coverage \
--result-format json --output-dir /tmp/apex-results --wait 30 --target-org vf-dev
# Retrieve results for an async run that outlived --wait
sf apex get test --test-run-id 707xx0000000001 --code-coverage --json --target-org vf-dev--synchronous runs the methods of a single Apex class; anything broader runs asynchronously
and returns a test run id. --concise shows only failures and works with human output only.
testRunCoverage in JSON and JUnit output is the percentage of covered lines across all classes
evaluated by that run — it is not the org coverage figure the gate uses.
References
| Reference file | Contents |
| references/test-patterns.md | TestDataFactory and builders, Test.loadData, setCreatedDate, async recipes for Queueable/Batch/future/Schedulable, platform-event tests, runAs with permission sets |
| references/coverage-and-gates.md | coverage semantics, sandbox versus production divergence, full sf apex run test and sf apex get test flag tables, report JSON shape, gate tuning |
| references/assertions-and-mocks.md | complete Assert API, Stub API walkthrough and limits, all four callout-mock variants, multi-endpoint router, WebServiceMock |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

