Home / apex / test-class-standards
test-class-standards
Use when writing, reviewing, or debugging Apex test classes, test data factories, async test behavior, negative-path assertions, or callout mocking. Triggers: 'SeeAllData', 'Test.startTest', 'HttpCalloutMock', 'test data factory', 'missing assertions'. NOT for the factory class itself — use apex/test-data-factory-patterns. NOT for the mock class itself — use apex/apex-http-callout-mocking.
Open the skill folder on GitHub
Use this skill when Apex tests need to prove behavior instead of merely satisfying deployment coverage. The objective is deterministic, isolated tests that verify positive paths, negative paths, bulk behavior, async execution, and callout behavior without depending on org data.
Before Starting
- What business behavior must this test prove beyond "method executes without exception"?
- What data relationships and user context does the code require?
- Does the code under test enqueue async work, run in sharing-sensitive contexts, or make HTTP callouts?
Questions to Ask Before Configuring
Every row below exists because a gotcha in references/gotchas.md bit someone who skipped it.
| Ask the requester | Why it matters | What a good answer adds | Traces to |
|---|---|---|---|
| What must be true after this runs that is not true before? | A test that only proves "no exception" passes forever while the behaviour rots. | One observable outcome per entry point, which becomes one test method name and one assertion. | Gotcha 4 |
| Which records must exist first, and is every one of them creatable inside a test transaction? | Some standard objects are not creatable, and history and feed-tracking records need committed parents that a test never commits. | A fixture plan that fails at design time instead of at run time, plus an explicit note where a stub replaces a record. | Gotcha 12 |
| Does any step read data the test did not create — record types, custom settings, queues, reports? | That is the pressure that produces SeeAllData=true, which then removes @TestSetup from the class and bars it from parallel execution. | Either a factory method for the missing data, or a written justification narrow enough to sit on one class. | Gotchas 2, 7 |
| Does the path enqueue async work or publish a platform event, and who consumes it? | Queueable, Batch and future work runs at Test.stopTest(); a published event does nothing until the test delivers it. | The correct boundary placement, and a Test.getEventBus().deliver() call per subscriber hop. | Gotchas 1, 10 |
| Does the path make an HTTP callout, and does DML have to run before it? | DML leaves uncommitted work that blocks callouts unless Test.startTest() is called before Test.setMock(...). | The exact statement order, plus the failure status codes the mock must also return. | Gotcha 9 |
| Whose access does the behaviour depend on, and which permission set grants it? | Sharing and field-level bugs are invisible to a test that runs as the deploying admin, and every runAs call spends a DML statement. | A named profile plus permission set for the runAs user, and a bounded number of runAs blocks. | Gotchas 3, 11 |
| Which permission set does the running user hold in production, and does this deployment contain it? | Code that enforces user mode is read through the running user's FLS. At validation the running user is whoever is deploying, and a profile deployed by Metadata API carries field permissions only for objects in the same request — so fields shipped alongside the code are invisible to that profile and every test method fails before it asserts anything. | The exact permission set API name to assign inside @TestSetup, which turns the runAs user from decoration into the thing that makes the suite deployable. | Gotcha 13 |
| Does the code rely on static state or cached configuration between invocations? | Statics reinitialise for every test method, so state seeded in @TestSetup is gone by the time a test method reads it. | State passed through records rather than statics, and an explicit cache reset where the code under test caches. | Gotcha 8 |
A proper test design differs from "just write a test" in what it buys you later: the suite tells you which contract broke and for whom, rather than telling you that something somewhere in a 400-line class no longer compiles or no longer passes.
Core Concepts
SeeAllData=false Is The Safe Default
Salesforce testing guidance favors isolated tests that create their own data. Tests that rely on production-like org data are brittle, order-dependent, and hard to maintain. Use factories or @testSetup to build what the test needs. Reach for SeeAllData=true only in rare, justified edge cases and document why.
Coverage Is A Side Effect, Not The Goal
A test with no meaningful assertions can still raise coverage. That does not make it valuable. A good Apex test verifies outcomes: field values, records created, exceptions thrown, side effects prevented, and access or security behavior preserved.
Prefer The System.Assert Class For Assertions
The Assert class (added in Winter '23, API 56.0) is Salesforce's recommended assertion style. Its methods read as intent — Assert.areEqual, Assert.areNotEqual, Assert.isNull, Assert.isNotNull, Assert.isTrue, Assert.isFalse, Assert.isInstanceOfType, Assert.isNotInstanceOfType, and Assert.fail — and its failure output is easier to read than the legacy methods. The optional message argument must be a String, which is stricter than the legacy System.assert* methods that accepted arbitrary objects and could emit unhelpful debug output. The legacy System.assert, System.assertEquals, and System.assertNotEquals methods remain supported with no announced retirement, so existing tests do not need a rewrite, but new tests should default to the Assert class.
Mock Non-HTTP Dependencies With The Stub API
Callout mocking (below) covers HTTP dependencies. For everything else — a service class calling a selector, a controller calling a service — Apex provides a general-purpose Stub API: implement the StubProvider interface and build the stub with Test.createStub(TypeToMock.class, providerInstance). This isolates the class under test from collaborators without writing a hand-rolled fake subclass, so a unit test can verify one layer's logic independently of the layers it depends on.
Test.startTest() And Test.stopTest() Have A Specific Job
These methods reset governor limits for the measured block and force async work to complete by stopTest(). Do not wrap half the test in startTest() just because it is fashionable. Use it around the action under test, especially for Queueable, Batch, Scheduled Apex, future methods, and limit-sensitive code.
Mock External Dependencies
Callouts never belong in real tests. Use Test.setMock(HttpCalloutMock.class, mock) or the relevant mock interface so the test is deterministic and can assert on success and failure responses explicitly.
Common Patterns
@testSetup Plus Test Data Factory
When to use: Multiple test methods need a common baseline such as Accounts, Opportunities, or custom settings.
How it works: Keep object creation in reusable factory methods. Use @testSetup for shared baseline data, then modify or extend the data in each individual test method as needed.
Why not the alternative: Rebuilding the same graph in every test makes the suite noisy and harder to maintain.
Arrange / Act / Assert For Positive, Negative, And Bulk Paths
When to use: Any service, trigger, or controller that handles business logic.
How it works: Create explicit inputs, invoke the code once in a focused action block, then assert on results. Include at least one negative test and one bulk or multi-record test for code that is supposed to be bulk-safe.
Mock-Driven Callout Test
When to use: The code under test performs an HTTP callout.
How it works: Register a mock before the action, execute inside startTest()/stopTest() if async is involved, and assert both the data mutation and failure behavior.
Stub API For Non-HTTP Collaborators
When to use: The class under test delegates to another Apex class (service, selector, or gateway) and you want to test it in isolation without touching that collaborator's real implementation.
How it works: Write a StubProvider whose handleMethodCall returns canned values for the intercepted methods, build the stub with Test.createStub(), and inject it into the class under test. Assert on how the class under test reacts to the stubbed responses.
Why not the alternative: Hand-written fake subclasses drift from the real interface and add maintenance overhead; the Stub API generates the double at runtime and fails loudly if the mocked type changes. Note the API's boundaries — it cannot mock static or @future methods, private methods, properties, triggers, inner classes, system types, classes that implement the Batchable interface, or classes that have only private constructors — so keep those seams behind mockable instance methods.
Decision Guidance
| Situation | Recommended Approach | Reason |
|---|---|---|
| Many tests need the same baseline records | @testSetup plus factory methods | Consistent setup with less duplication |
| Code under test enqueues async work | Test.startTest() / Test.stopTest() around the action | Ensures the async job actually runs in the test |
| Service makes HTTP callouts | Test.setMock(HttpCalloutMock.class, mock) | Deterministic, offline, and assertion-friendly |
| Class under test delegates to a non-HTTP collaborator | StubProvider + Test.createStub() | Isolates one layer without a hand-rolled fake |
| New assertions in fresh tests | Assert.areEqual / Assert.isTrue / Assert.fail | Clearer failure output and a discoverable, centralized API |
Team wants to use SeeAllData=true because setup feels hard | Build a factory instead | Reliability of the suite matters more than short-term convenience |
Recommended Workflow
- Name the contracts before the fixture. Work through the questions above and write one observable outcome per entry point. Each becomes a test method name; anything you cannot state as an outcome is not yet testable.
- Choose the fixture source.
templates/apex/tests/TestDataFactory.clscovers Account, Contact, Opportunity, Case and Lead throughcreateXxx(count, overrides);templates/apex/tests/TestRecordBuilder.clscovers everything the factory does not;templates/apex/tests/TestUserFactory.clsmints theSystem.runAsuser with its permission sets. Put the shared baseline in@TestSetup— unless the class needsSeeAllData=true, which forbids it. - Write the four mandatory methods: a bulk-200 method modelled on
templates/apex/tests/BulkTestPattern.cls, a negative method that names the expected exception, an access method wrapped inSystem.runAs, and — whenever the path calls out — a mock method built ontemplates/apex/tests/MockHttpResponseGenerator.cls, ordered DML,Test.startTest(),Test.setMock(...), action,Test.stopTest().If the code under test enforces user mode —
WITH USER_MODEon a query,as useron a DML statement,AccessLevel.USER_MODEon aDatabasemethod,Security.stripInaccessible, or a legacyWITH SECURITY_ENFORCEDclause —System.runAsstops being one method's concern and becomes the shape of the whole class: mint the user in@TestSetupwithTestUserFactory, assign the permission set(s) the deployment ships, and put every body that touches the code under test insideSystem.runAs(testUser). Skipping it does not weaken the suite, it breaks the deploy (Gotcha 13). Worked example:references/examples.mdExample 5. - Ship every template class the test names. A test that references
TestDataFactoryand deploys withoutTestDataFactory.clsfails withVariable does not exist. Copy each template verbatim with its-meta.xmland record it in the deploy order. - Run the checker —
python3 skills/apex/test-class-standards/scripts/check_test_class_standards.py --manifest-dir <class directory>. Exit 1 means an ERROR rule fired (missing assertion, unjustifiedSeeAllData=true, unmocked callout, user-mode code with no permissionedSystem.runAs); add--strictin CI to fail on WARN findings too (includingfixture-seeded-as-persona— plain fixture DML inside a permissionedrunAs, Gotcha 15). - Deploy dry.
sf project deploy start --manifest package.xml --dry-run --test-level RunSpecifiedTests --tests <TestClass>compiles the package and runs the tests without persisting metadata — the last check that the provenance set is complete. UNVERIFIED (2026-09-12): thesfCLI flag spellings are not corpus-grounded — checksf project deploy start --helpbefore using this command in a runbook. - Record what you could not prove. Any behaviour left untested because the record is not creatable, or the member is not stubbable, belongs in a comment beside the test, not in the commit message.
A full worked package — service, Queueable, test class, -meta.xml, package.xml, deploy order and checker run — is in references/code-examples.md.
Review Checklist
- [ ] Tests create their own data or use a documented factory pattern.
- [ ]
SeeAllData=trueis absent or explicitly justified. - [ ] Assertions verify behavior, not just "no exception thrown."
- [ ] Async code is exercised with
Test.startTest()andTest.stopTest(). - [ ] Bulk-sensitive code has multi-record tests, not only single-record happy paths.
- [ ] Callout code uses mocks and verifies error handling as well as success.
- [ ] Tests for user-mode code run inside
System.runAsof a user holding the permission set(s) the deployment ships — not the deploying admin, and notrunAs(new User(Id = UserInfo.getUserId())). - [ ] Fixture seed DML uses
Database.insert(records, AccessLevel.SYSTEM_MODE)orTestDataFactory.insertAsSystemoutside the personarunAs; only the action under test sits inside it (Gotcha 15 /fixture-seeded-as-persona).
Salesforce-Specific Gotchas
- Async Apex does not execute inside a test until
Test.stopTest()— asserting before that point gives misleading failures. SeeAllData=truecouples tests to org state — a passing sandbox test can still fail in another org because the hidden data assumptions differ.- Mixed DML still affects tests — creating Users and setup-related data in the wrong sequence can fail test methods even when business logic is correct.
- One assertion at the end is not enough — bulk, negative, and security-sensitive behaviors need focused assertions, not just a single record-count check.
- User-mode code makes the test suite a deployment gate — when the running user cannot see a field that shipped in the same request, the tests fail during
--dry-runvalidation rather than merely passing for the wrong reason. Seereferences/gotchas.mdGotcha 13. - Fixture fields are not persona grants — a plain
insertinsideSystem.runAs(persona)at API 67.0+ checks Create FLS on every populated field; seed withAccessLevel.SYSTEM_MODE/insertAsSystemand keep only the action under test insiderunAs(Gotcha 15 /fixture-seeded-as-persona).
Output Artifacts
| Artifact | Description |
|---|---|
| Test review findings | Findings on isolation, assertions, async behavior, and mocking quality |
| Test scaffold | A structure for factory setup, focused actions, and strong assertions |
| Coverage-quality remediation plan | Specific changes that turn brittle coverage tests into trustworthy behavioral tests |
Related Skills
apex/exception-handling— use to define how negative tests should assert exceptions or boundary-safe error messages.apex/async-apex— use when the test problem is really caused by wrong Queueable, Batch, or scheduler design.apex/callouts-and-http-integrations— use when mock design depends on outbound integration structure or Named Credential usage.
Package files
- references/code-examples.md
- references/examples.md
- references/gotchas.md
- references/llm-anti-patterns.md
- references/well-architected.md
- templates/test-class-standards-template.md
- scripts/check_test_class_standards.py
- scripts/fixtures/apostrophes-before-runas/ApostrophesBeforeRunAsTest.cls
- scripts/fixtures/apostrophes-before-runas/OppService.cls
- scripts/fixtures/apostrophes-before-runas/README.md
- scripts/fixtures/negative-action-inside-boundary/NegativeActionInsideBoundaryTest.cls
- scripts/fixtures/negative-action-inside-boundary/README.md
- scripts/fixtures/negative-mixed-dml-fence/NegativeMixedDmlFenceTest.cls
- scripts/fixtures/negative-mixed-dml-fence/README.md
- scripts/fixtures/negative-no-starttest/NegativeNoStartTest.cls
- scripts/fixtures/negative-no-starttest/README.md
- scripts/fixtures/negative-system-mode-seed/NegativeSystemModeSeedTest.cls
- scripts/fixtures/negative-system-mode-seed/README.md
- scripts/fixtures/negative-system-mode-seed/TestDataFactory.cls
- scripts/fixtures/positive-plain-insert-in-runas/PositivePlainInsertInRunAsTest.cls
- scripts/fixtures/positive-plain-insert-in-runas/README.md
- scripts/fixtures/positive-plain-insert-in-runas/TestDataFactory.cls
- scripts/fixtures/runas-in-comment-only/OppService.cls
- scripts/fixtures/runas-in-comment-only/README.md
- scripts/fixtures/runas-in-comment-only/RunAsInCommentOnlyTest.cls