If you work in a mature Salesforce org, you know the pain. You fix a two-line bug in an Apex trigger, kick off a deployment, and spend the next 45 minutes watching thousands of tests run — most of them completely unrelated to the code you just touched. By the time it finishes, you’ve lost your flow state and moved on to something else.
Salesforce added a fifth test level to fix this: RunRelevantTests. It analyses your deployment before running anything, builds a dependency graph between your deployed Apex and your test classes, and runs only the tests actually connected to the changed code. One flag change. No new tests to write, no architecture to rethink.
For orgs running 40–60 minute test suites, this can mean dropping a routine deployment down to under five minutes.
How RunRelevantTests works
The four existing test levels are still there:
NoTestRun— skip tests (sandboxes only)RunSpecifiedTests— you name the test classes manuallyRunLocalTests— everything local, excluding managed packagesRunAllTestsInOrg— every test in the org
RunRelevantTests is different from all of them. Rather than you deciding scope or running everything, the platform decides — based on what you’re actually deploying.
The analysis is static and runs before any tests execute. It traces method-level dependencies between your deployed components and your test classes. Deploy a utility class, it finds every test that calls a method on it. Deploy a trigger, it finds tests that operate on that object. Tests with no dependency path to your deployment don’t run.
This isn’t sampling or a heuristic. It’s a real dependency graph. Whether that graph is accurate enough for your org depends a lot on your test structure — more on that below.
Switching to RunRelevantTests
One parameter change in the CLI:
sf project deploy start \
--source-dir force-app/main/default \
--test-level RunRelevantTests \
--target-org myOrgOr directly in the Metadata API:
<testLevel>RunRelevantTests</testLevel>If you’re already scripting deployments, this is about as easy as a migration gets.
Two new annotations
The static analyser handles the common case well, but it can’t trace everything. Tests that call Apex through an interface, use dynamic dispatch, or hit a method through an anonymous utility won’t show up in the dependency graph automatically. Two new annotations let you fill those gaps.
@IsTest(critical=true)
Marks a test class to always run, no matter what’s being deployed. Security tests, permission validation, org-wide integration health checks — anything where a regression wouldn’t necessarily appear in the dependency graph but would absolutely matter in production.
@IsTest(critical=true)
public class SecurityAndPermissionTests {
@IsTest
static void testProfileAccessRestrictions() {
User restrictedUser = TestDataFactory.createRestrictedUser();
System.runAs(restrictedUser) {
try {
Account acc = [SELECT Id, AnnualRevenue FROM Account LIMIT 1];
System.assert(acc.AnnualRevenue == null,
'Restricted user should not see AnnualRevenue');
} catch (QueryException e) {
// Expected — query should fail with insufficient access
}
}
}
}The temptation is to mark everything as critical=true. Resist it. Do that and you’ve just rebuilt RunLocalTests with extra ceremony.
@IsTest(testFor='ApexClass:MyClass')
Declares explicitly which components a test class covers. When those components are in the deployment, this test runs — even if the analyser missed the dependency.
@IsTest(testFor='ApexClass:OrderCalculationEngine')
public class OrderCalculationEngineTests {
@IsTest
static void testDiscountCalculation() {
Order__c testOrder = TestDataFactory.createOrderWithItems(5, 100.00);
Test.startTest();
Decimal discount = OrderCalculationEngine.calculateDiscount(testOrder);
Test.stopTest();
System.assertEquals(0.10, discount, 'Expected 10% discount for 5-item order');
}
}You can target multiple components — classes, triggers, or both:
@IsTest(testFor='ApexTrigger:AccountTrigger')
public class AccountTriggerTests { ... }
@IsTest(testFor='ApexClass:LeadConversionService,ApexTrigger:LeadTrigger')
public class LeadProcessingTests { ... }This is most useful in orgs with LWC Apex controllers, interface-based service layers, or utility classes that get called through abstraction the analyser can’t follow.
What the numbers actually look like
Picture a typical enterprise Salesforce org: 5,000 test methods across 300 classes, full test runs taking 40–60 minutes. A developer changes one or two Apex classes.
With RunLocalTests, all 5,000 methods run. With RunRelevantTests, the same deployment, might run 50–200 tests. Under five minutes for a change that used to take the better part of an hour is realistic.
The difference shows up most in a few specific situations.
Hotfix deployments. A 45-minute RunLocalTests cycle is a genuine liability when you’re pushing a production fix during business hours. Five minutes is manageable. That’s also true for on-call engineers at midnight.
Pull request pipelines. A 40-minute CI pipeline is one developers stop trusting and start working around. At 5–10 minutes, it gives feedback while the code is still fresh.
# GitHub Actions example
- name: Deploy to CI sandbox with relevant tests
run: |
sf project deploy start \
--source-dir force-app/main/default \
--test-level RunRelevantTests \
--target-org ci-sandbox \
--wait 30Large managed packages. An ISV with 8,000 test methods across multiple product modules shouldn’t need the Inventory module’s entire test suite to fire when someone changes a Billing class. RunRelevantTests with @IsTest(testFor=...) annotations handles that without bespoke test selection scripting.
Where it fits in a pipeline
RunRelevantTests is a development speed tool. It isn’t a gate replacement.
| Pipeline stage | Test level | Why |
|---|---|---|
| Feature branch PR | RunRelevantTests | Fast feedback while you’re still in context |
| Merge to develop/staging | RunLocalTests | Full coverage before integration |
| Pre-production validation | RunLocalTests or RunAllTestsInOrg | Nothing skipped before production |
| Production hotfix | RunRelevantTests with critical=true tests | Speed with a safety net |
Keep a full test run somewhere in your pipeline before code reaches production. The analyser is static — it won’t catch dependencies that live in dynamic SOQL construction, reflection patterns, or other indirect paths it can’t trace. Your critical=true annotations help, but they’re only as thorough as the work you put into them.
A few things worth knowing before you adopt it
The dependency graph is only as useful as your test structure. If most of your tests funnel through one shared utility class or a god-object factory, the analyser sees shallow paths and the targeted test set grows less meaningful. Auditing how your test classes call production code before switching is worth the time.
The feature is currently in Beta. The analysis algorithm will improve, and there are known edge cases where relevant tests get missed in the current version. Validate it by running RunRelevantTests on a change, then running RunLocalTests on the same change in a clean sandbox, and comparing what ran. Do that a few times before trusting it fully.
One other update worth pairing with this: Database.purgeOldAsyncJobs() now accepts an upper bound on the number of records to delete, so you can clear large backlog of completed async jobs incrementally. A cleaner async job table speeds up test runs, since queries over that table in test setup finish faster.
Getting started
Upgrade to Salesforce CLI v2.115+ and run your next sandbox deployment with --test-level RunRelevantTests. Time it. Compare it to your usual RunLocalTests run.
Then go through your test suite and add @IsTest(critical=true) to anything that validates security, permissions, or cross-object integrity. Add @IsTest(testFor=...) to any test class that calls production code through an interface or dynamic pattern the analyser is unlikely to follow.
The annotation work is a day or two. The time savings stack up across every deployment after that.