agentleFS
grzmolgrzmol/vibe-force/skills/sf-apex-testing/SKILL.md

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

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
  1. Very specific:

    Not counted as covered: System.debug calls, test methods, and test classes.

    line 20
  2. Very specific:

    Use the Assert class. System.assert* still compiles but carries no message discipline and is legacy.

    line 110
  3. Distinctive:

    Use 200 records: the documented Apex trigger batch size, and the point where a non-bulkified implementation breaks.

    line 306

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Apex Testing

Coverage and test-level rules below are platform requirements; the numeric gates are vibe-force policy from .vibeforce/config.json.

RuleValueSource
Platform minimum coverage to deploy or package Apex75% of Apex code, all tests passingApex Developer Guide
Platform per-class minimum under RunSpecifiedTests75% per class and per trigger, computed individuallySalesforce CLI reference
Every trigger must have some coveragenon-negotiableApex Developer Guide
vibe-force org coverage gategates.apexOrgCoverageMin = 85config/vibe-force.defaults.json
vibe-force per-class coverage gategates.apexClassCoverageMin = 75config/vibe-force.defaults.json
New Apex class requires a test classgates.requireTestForApexClass = trueconfig/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

SituationDo 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 runscreate them before Test.startTest()
Need a clean governor-limit budget for the assertion windowwrap the exercise in Test.startTest() / Test.stopTest()
Async job must complete before assertionsenqueue inside the startTest/stopTest block; assert after stopTest()
Platform event must be deliveredTest.getEventBus().deliver(); after Test.stopTest()
Callout in the code pathTest.setMock(HttpCalloutMock.class, new XMock());
Collaborator class must return canned dataStub API: Test.createStub(IFoo.class, provider)
Collaborator is static, private, inner, a trigger, or BatchableStub API cannot mock it — inject an interface instead
SOSL must return known rowsTest.setFixedSearchResults(ids) — SOSL otherwise returns empty in tests
Verify behaviour for a restricted userSystem.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 dataavoid; if truly unavoidable @IsTest(SeeAllData=true) and document why
Bulk behaviour200 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.

BehaviourDetail
Limit contextcode 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 flushall async calls made after startTest are collected and run synchronously at stopTest
Exception during flushhalts the synchronous processing — for example an unhandled exception in a batch execute prevents finish from running
Savepointsfrom 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.

LegacyModern
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 equivalentAssert.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:

NeedMock
One endpoint, logic in ApexHttpCalloutMock implementation
Response body held as a static resourceStaticResourceCalloutMock
Several endpoints, each with its own static resourceMultiStaticResourceCalloutMock
SOAP callout from a WSDL2Apex stubWebServiceMock
Collaborator Apex classStub 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 featureTest techniqueConstraint
QueueableSystem.enqueueJob inside startTest/stopTestchaining is not executed in tests beyond the first job
Batch ApexDatabase.executeBatch inside the blockmax 5 batch jobs submitted per running test; only one execute chunk runs
Future methodcall the method inside the block@Future cannot be invoked from another @Future
SchedulableSystem.schedule inside the block; job runs at stopTestcron string still required
Flex queue orderingTest.enqueueBatchJobs(n) plus Test.getFlexQueueOrder()enqueues no-operation jobs for ordering assertions
Platform eventsTest.getEventBus().deliver(); after Test.stopTest()one deliver() per downstream publishing hop
BatchApexErrorEventDatabase.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-patternWhy it failsFix
@IsTest(SeeAllData=true) to make a test passcoverage and results diverge between sandbox and production when org data changescreate the data in the test
No assertions, only executioncoverage rises, defects shipassert observable outcomes with messages
Assert.isTrue(true) or bare "did not throw"tautologyassert the value, the field, or the error text
Hard-coded record Ids or profile Idsfails in every other orgquery by name, or use TestIds to fabricate synthetic Ids
Test.startTest() after the exerciselimits and async flush apply to the wrong windowarrange, startTest, act, stopTest, assert
Assertions before Test.stopTest() for async workjob has not runassert after stopTest()
Callout with no Test.setMocktests do not support callouts; the method failsinstall the appropriate mock
One test method covering ten branchesa failure localises nothingone behaviour per method, named after the behaviour
System.assertEquals(1, results.size()) with no messagefailure output says nothingalways pass the third message argument
Relying on record Id orderingIds are not created in ascending order across separate requestsadd ORDER BY
Sharing a mutable static between test methodsstatics are not preserved across methodsbuild state inside each method or in @TestSetup
Test.isRunningTest() branch in production codeuntested production path, hides defectsinject a collaborator or use the Stub API
1-record test for trigger logicnon-bulkified code passes200-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 fileContents
references/test-patterns.mdTestDataFactory and builders, Test.loadData, setCreatedDate, async recipes for Queueable/Batch/future/Schedulable, platform-event tests, runAs with permission sets
references/coverage-and-gates.mdcoverage 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.mdcomplete Assert API, Stub API walkthrough and limits, all four callout-mock variants, multi-endpoint router, WebServiceMock
Official sourceURL
Testing Apexhttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing.htm
Understanding testing in Apexhttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_intro.htm
Testing best practiceshttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_best_practices.htm
Test.startTest / Test.stopTesthttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_start_stop_test.htm
System.runAshttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_tools_runas.htm
@TestSetuphttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_testsetup_using.htm
SeeAllDatahttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_seealldata_using.htm
Test.loadDatahttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_load_data.htm
Common test utility classeshttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_utility_classes.htm
Stub APIhttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_stub_api.htm
Testing HTTP calloutshttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_restful_http_testing.htm
Code coverage best practiceshttps://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_code_coverage_best_pract.htm
Assert classhttps://developer.salesforce.com/docs/atlas.en-us.apexref.meta/apexref/apex_class_System_Assert.htm
Salesforce CLI apex commandshttps://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference_apex_commands_unified.htm

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.