Org Skills

apex-testing

Design and write Salesforce Apex tests for Spring '26 — the `Assert` class, TestDataFactory and builders, `@TestSetup`, `System.runAs`, the Stub API vs a mocking library, `HttpCalloutMock`, `Test.getEventBus().deliver()`, Queueable/Finalizer/Batch/Cursor tests, `@AuraEnabled` and `@InvocableMethod` controller tests, flow tests with `sf flow run test` and the unified `sf logic run test`, and coverage gates.

Download .zip Raw Source
When agents use itUse this whenever the user asks how to test an Apex class, trigger, handler, service, selector, Queueable, Batch, Schedulable, invocable or controller; writes or fixes an `@IsTest` class; mentions `Test.startTest`, `Test.setMock`, `Test.createStub`, `SeeAllData`, `TestDataFactory`, "75% coverage", "FlowTesting", `sf apex run test`, a failing test in CI or `UNABLE_TO_LOCK_ROW` in tests; or asks "what should I test". Also apply it when generating tests for Apex the user just wrote, even if they don't say "test". For LWC Jest and browser tests use `salesforce-frontend-testing`.

Install

Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.

npx skills add AGCO-Global/org-skills --skill apex-testing -a github-copilot
# or with the org installer (adds .github/skills/apex-testing):
npx -y github:AGCO-Global/org-skills add skill apex-testing

Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.

npx skills add AGCO-Global/org-skills --skill apex-testing
# user-level instead of project-level:
npx skills add AGCO-Global/org-skills --skill apex-testing -g

Installs the salesforce-apex-skills plugin, which bundles all Salesforce / Apex skills and keeps them updated.

/plugin marketplace add AGCO-Global/org-skills
/plugin install salesforce-apex-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill apex-testing -a claude-code

Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.

Installs into .agents/skills/, which Codex reads.

npx skills add AGCO-Global/org-skills --skill apex-testing -a codex

Installs into Cursor's skills folder.

npx skills add AGCO-Global/org-skills --skill apex-testing -a cursor

Installs into Gemini CLI's skills folder.

npx skills add AGCO-Global/org-skills --skill apex-testing -a gemini-cli

Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.

Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.

Try it — example prompts

Prompts this skill is tested against, and what a good answer includes.

  • Write the test class for this OrderService.rollUpToAccounts method (trigger-driven roll-up to Account).

    @TestSetup with a TestDataFactory bulk test with 200 records inside Test.startTest/stopTest Assert.areEqual with a message on the queried Account System.runAs negative case no SeeAllData

  • How do I test a Queueable that makes a callout and retries through a Finalizer?

    Test.setMock(HttpCalloutMock.class, ...) enqueue inside Test.startTest/stopTest force the failure and assert the Finalizer's log or re-enqueue AsyncOptions duplicate case if used

  • Our after-save flow on Case has Flow Tests, but the delete path and the async path are untested. What do we do?

    Flow Tests do not support delete-triggered flows or async paths Apex tests that delete/update the Case and assert the outcome sf flow run test and/or sf logic run test in CI

  • Write a test proving the OrderEvent__e subscriber trigger is idempotent.

    EventBus.publish twice with the same EventUuid/correlation key Test.getEventBus().deliver() assert one resulting record

  • Mock the repository dependency of this service without adding a library.

    interface for the dependency System.StubProvider implementation Test.createStub constructor injection with @TestVisible

Skill contents

Apex Testing

Targets: Salesforce Spring '26 (API 66.0) · Verified: 2026-09 against https://developer.salesforce.com (Apex Developer Guide "Testing Apex", Apex Reference Assert, Test) and the plugin-apex / plugin-flow command references

The org's bar is 90 %+, and the platform's 75 % is only a deployment floor — but the percentage is the cheapest thing about a test suite. A good Apex test builds its own data, calls the code once as 200 records would, and asserts an outcome with a message that explains the failure. Tests that only prove "no exception" are noise.

1. Decide first

Question Default Change when
What does one test class cover? One @IsTest class per production class (OrderServiceTest) Trigger handler + service tested together through DML when the handler is routing-only
Where does data come from? TestDataFactory builders called in @TestSetup Per-method data when tests need conflicting setups; never SeeAllData=true
How to isolate a dependency? Interface + Test.createStub (Stub API, no library) A mocking library (fflib ApexMocks) when the team already uses it; HttpCalloutMock for every callout
Which cases per method? Positive · bulk (200) · negative/exception · System.runAs sharing Add limit assertions (Limits.getQueries()) on hot paths
Async code? Wrap in Test.startTest()/Test.stopTest() so jobs run synchronously Finalizer paths: force the exception and assert the re-enqueue/log
Flow logic on the object? Flow Tests in Flow Builder + Apex tests that insert/update the object Delete-triggered and async paths: Apex tests only (Flow Tests can't cover them)
Coverage target 90 %+ — the org's bar, well above the platform's 75 % deployment floor Generated DTO classes may sit lower with an explicit note

2. Anatomy of a test

@IsTest
private class OrderServiceTest {
    @TestSetup
    static void setup() {
        Account acc = TestDataFactory.account().withName('Acme').persist();
        TestDataFactory.orders(acc.Id, 200).persist();
    }

    @IsTest
    static void rollUp_updatesAccountTotal_for200Orders() {
        List<Order__c> orders = [SELECT Id, Amount__c FROM Order__c];
        for (Order__c o : orders) { o.Amount__c += 10; }

        Test.startTest();
        update orders;
        Test.stopTest();

        Account acc = [SELECT Total_Order_Amount__c FROM Account LIMIT 1];
        Assert.areEqual(200 * 110, acc.Total_Order_Amount__c, 'total after bulk amount change');
        Assert.isTrue(Limits.getQueries() < 10, 'query count stays flat for 200 records');
    }

    @IsTest
    static void rollUp_withoutAccess_throwsHandled() {
        User restricted = TestDataFactory.user('Minimum Access - Salesforce');
        System.runAs(restricted) {
            try {
                OrderService.rollUpToAccounts([SELECT Id, Account__c, Amount__c FROM Order__c LIMIT 1], null);
                Assert.fail('expected a DmlException for a user without Account edit');
            } catch (DmlException e) {
                Assert.isTrue(e.getMessage().contains('INSUFFICIENT_ACCESS'), e.getMessage());
            }
        }
    }
}
  • Method names read unit_scenario_expectation. One behaviour per method.
  • Assert.areEqual / areNotEqual / isTrue / isFalse / isNull / isNotNull / isInstanceOfType / fail — always with the message argument. System.assert* is legacy.
  • Query the record back after the call; never assert on the in-memory object you passed in.
  • Test.startTest() resets limits and, at stopTest(), runs Queueable/Batch/future/scheduled jobs and delivers Platform Events.

3. Test data

  • TestDataFactory with fluent builders (account().withName().persist()) that set every required field and take record type Ids from Utils.getRecordTypeId(...), never hard-coded Ids. Pattern in references/test-data-factory.md.
  • Bulk means 200 records: that is the trigger chunk size and the number where SOQL-in-loop fails.
  • Users: TestDataFactory.user(profileName) builds a User with a unique username; create in @TestSetup or inside System.runAs(new User(Id = UserInfo.getUserId())) to avoid MIXED_DML_OPERATION.
  • Custom Metadata: read directly (deployed records are visible in tests); when the test must control values, inject them through a @TestVisible static or a settings interface.
  • @IsTest(SeeAllData=true) is never acceptable; IsParallel=true on classes that touch no shared parents to speed up runs.

4. Mocks and async

  • Stub API: (OrderRepository) Test.createStub(OrderRepository.class, new OrderRepositoryStub()) with a System.StubProvider — works for interfaces and non-final virtual classes, no library. Full example in references/mocking.md.
  • Callouts: Test.setMock(HttpCalloutMock.class, new ErpMock(200, '{"ok":true}')); one mock class with status/body constructor covers success, 4xx, 5xx and timeout (throw new CalloutException()).
  • Platform Events: publish in the test, then Test.getEventBus().deliver(); inside startTest/stopTest to run the subscriber trigger; assert EventBus.publish results with SaveResult.isSuccess().
  • Queueable: enqueue inside startTest/stopTest; assert the job's side effects. For AsyncOptions dedupe, enqueue twice and assert DuplicateMessageException.
  • Finalizer: make the Queueable throw (test-visible flag), stopTest(), assert the log record or the re-enqueued AsyncApexJob.
  • Batch: Database.executeBatch(new Job(), 200) inside startTest/stopTest; one execute chunk runs. Apex Cursors: call the chunk-processing method directly with a small list; cursor plumbing needs no test.
  • Schedulable: System.schedule('test', '0 0 0 1 1 ? 2099', new Job()), then assert a CronTrigger exists.
  • Invocables / @AuraEnabled: call the static method with a List<Request>; assert the List<Result>; negative case with runAs expects AuraHandledException.

5. Flow tests and the unified runner

  • Flow Tests are created in Flow Builder (View Tests / Convert debug run to test) for record-triggered, autolaunched and Data 360-triggered flows; limits: no delete-triggered flows, no async paths, no callouts/wait elements, max 200 tests per flow (help). Cover those gaps with Apex tests that insert/update the object.
  • CI: sf apex run test --test-level RunLocalTests --code-coverage --result-format junit --output-dir test-results --wait 30 and sf flow run test --class-names Order_After_Save --synchronous --result-format junit --output-dir test-results.
  • Unified (Beta, needs View All Data): sf logic run test --test-level RunLocalTests --test-category Apex --test-category Flow --synchronous --code-coverage --result-format junit --output-dir test-results. Command details in references/mocking.md § CI commands.

6. Deliverable format

## Test plan — table: method · positive · bulk 200 · negative · runAs · async/mocks
## Test classes — complete @IsTest classes, factory calls, Assert with messages
## Gaps — what Flow Tests cannot cover here and which Apex test covers it
## Run — the exact sf commands and expected coverage

Checklist

  • No SeeAllData=true; data from TestDataFactory in @TestSetup
  • Every test asserts an outcome queried back from the database, with a message
  • Bulk test with 200 records on every trigger path; Limits.getQueries() asserted on hot paths
  • Negative test per validation/exception branch; Assert.fail guards the try block
  • System.runAs test proves sharing/USER_MODE for one restricted user
  • Callouts mocked with HttpCalloutMock; events delivered with Test.getEventBus().deliver()
  • Async wrapped in Test.startTest()/Test.stopTest()
  • Flow Tests exist for record-triggered flows; delete/async paths covered in Apex
  • sf apex run test (and sf flow run test) green in CI with JUnit output

Anti-patterns

  • System.assert(true) or tests with no asserts → assert queried outcomes with messages; Code Analyzer flags ApexUnitTestClassShouldHaveAsserts.
  • Single-record tests only → add the 200-record case; that is where limits break.
  • Testing a cacheable=true method "does no DML" from Apex → not enforceable in a test class; enforce it in review and Jest.
  • Hard-coded record type or profile Ids in tests → describe calls and TestDataFactory.
  • Creating a User outside runAs next to standard DMLMIXED_DML_OPERATION; wrap user creation in System.runAs.
  • Test.stopTest() missing around a Queueable → job never runs; assertions pass vacuously.
  • Mocking the class under test → mock its dependencies through interfaces only.

Go deeper

  • references/test-data-factory.md — builder-style factory, user creation, bulk helpers.
  • references/mocking.md — Stub API provider, HttpCalloutMock, event bus, Queueable/Finalizer/Batch/Schedulable tests, CI commands.
  • Sibling skills: apex-development for the code under test, salesforce-automation for Flow Tests, salesforce-devops for the pipeline, salesforce-frontend-testing for Jest and browser tests, salesforce-code-review for reviews, salesforce-org-conventions for the org's naming and mandated patterns.

References

Deeper material the agent loads only when needed.