System.AssertException: Assertion Failed: Expected: <X>, Actual: <Y>
An Apex test ran an assertion that didn't match. The Expected vs Actual values are in the message — that's where you start. The fix is either to make the code match the test's expectation, or to update the test if the new behaviour is intentional.
Also seen asSystem.AssertException·Assertion Failed: Expected·AssertException: Assertion Failed·test failure assertEquals
A release branch ships through CI on the way to a Friday afternoon deployment. The Apex test stage fails with System.AssertException: Assertion Failed: Expected: Closed Won, Actual: Closed Lost. The failing test is OpportunityCloseHandlerTest.shouldMarkAsWonWhenAmountAboveThreshold. The test passed in the developer's local sandbox an hour ago. The branch has no changes to that test or its target class. The deployment window closes in 90 minutes, and the team needs to decide whether to roll forward or hold.
What the platform is checking
System.AssertException is what Apex raises when a System.assert, System.assertEquals, System.assertNotEquals, or System.assertNotNull call detects a mismatch between an expected and an actual value. The runtime stops the test at the failing assertion, captures the message, and reports the test as failed. The exception is never caught (catching it would defeat the purpose of an assertion).
The message includes the expected value, the actual value, and any custom string the assertion provided. The line number in the stack trace points to the exact assertion call. The cause sits somewhere upstream: the code being tested produced a different result than the assertion required.
The test could fail for many reasons. The production code changed and the test now correctly detects the regression. The test fixture changed and the input no longer triggers the expected path. The org-level configuration shifted in a way the test depended on. A @TestSetup method ran differently because of a static state issue. A trigger that ran during the test produced a side effect the test did not account for. A time-dependent expectation (such as Date.today() falling on a specific weekday) silently changed when the test ran on a different day.
The platform's reasoning is straightforward: assertions exist to make tests fail when the code is wrong. The test infrastructure does not try to interpret why the assertion failed. It reports the mismatch and leaves the diagnosis to the developer.
The broken example
A test for an Opportunity close handler that marks high-value Opportunities as Closed Won:
public class OpportunityCloseHandler {
public static void onUpdate(List<Opportunity> opps, Map<Id, Opportunity> oldMap) {
for (Opportunity o : opps) {
Opportunity oldOpp = oldMap.get(o.Id);
if (o.StageName != 'Closed' || oldOpp.StageName == 'Closed') continue;
if (o.Amount != null && o.Amount > 100000) {
o.StageName = 'Closed Won';
} else {
o.StageName = 'Closed Lost';
}
}
}
}
The test:
@IsTest
private class OpportunityCloseHandlerTest {
@IsTest
static void shouldMarkAsWonWhenAmountAboveThreshold() {
Account a = new Account(Name='Big Co');
insert a;
Opportunity o = new Opportunity(
Name='Big Deal', AccountId=a.Id, StageName='Prospecting',
CloseDate=Date.today().addDays(30), Amount=200000
);
insert o;
Test.startTest();
o.StageName = 'Closed';
update o;
Test.stopTest();
Opportunity refreshed = [SELECT StageName FROM Opportunity WHERE Id = :o.Id];
System.assertEquals('Closed Won', refreshed.StageName);
}
}
The test ran clean in the developer sandbox. In CI, the assertion fails with Expected: Closed Won, Actual: Closed Lost.
Investigation reveals the cause. The CI org has a validation rule on Opportunity that forces Amount to null when StageName changes to a closed value through a flow. The flow runs after the handler sets Closed Won, the validation re-fires, the Amount becomes null, and a separate process resets the stage to Closed Lost. The developer sandbox does not have the flow enabled. The test passes locally and fails in CI.
A second shape: a test that depends on the current date. The assertion System.assertEquals('Friday', dayOfWeek) passes when the test runs on Friday and fails every other day. The CI server runs on a schedule, and the day the test ran was Wednesday.
A third shape: a test that depends on a user's profile or permission set. The test runs as the running user (the user who initiated the deployment). The developer's sandbox uses a System Administrator. The CI runs as a designated CI user with a restricted profile. The restricted profile causes the test code to take a different branch and produce a different result.
The fix, three paths
Reproduce the failure deterministically. The first step is to reproduce the CI failure locally. Run the test in the same sandbox the CI uses or pull the CI environment's configuration into a sandbox that matches. If the failure does not reproduce, the cause is environmental and the differences between sandboxes need to be understood.
sf apex run test \
--target-org ciSandbox \
--tests OpportunityCloseHandlerTest.shouldMarkAsWonWhenAmountAboveThreshold \
--result-format human \
--code-coverage
A single-test run with full output reveals the exact assertion message, the line, and any debug output the test produced.
Isolate the test from environmental drift. A reliable test mocks out org-level configuration or uses Test.startTest()/Test.stopTest() to bound the asynchronous and trigger work. If the failing test depends on a flow that may or may not be active, either disable the flow in the test setup or update the test to account for the flow's behavior.
@IsTest
static void shouldMarkAsWonWhenAmountAboveThreshold() {
Account a = new Account(Name='Big Co');
insert a;
Opportunity o = new Opportunity(
Name='Big Deal', AccountId=a.Id, StageName='Prospecting',
CloseDate=Date.today().addDays(30), Amount=200000
);
insert o;
Test.startTest();
o.StageName = 'Closed';
update o;
Test.stopTest();
Opportunity refreshed = [SELECT StageName, Amount FROM Opportunity WHERE Id = :o.Id];
System.assertEquals('Closed Won', refreshed.StageName,
'Expected Closed Won for Amount=' + refreshed.Amount);
}
The assertion message now includes the actual Amount, which would have revealed the null-Amount cause within seconds of the failure.
Fix the underlying bug. If the test correctly identifies a regression in the production code, the fix is to update the code, not the test. Adding the assertion message with context helps reveal the bug quickly. The CI run with the new message would have shown Expected Closed Won for Amount=null, pointing at the flow side-effect.
For the handler itself, capturing the original Amount before any downstream automation runs ensures the decision uses the right input:
public static void onUpdate(List<Opportunity> opps, Map<Id, Opportunity> oldMap) {
for (Opportunity o : opps) {
Opportunity oldOpp = oldMap.get(o.Id);
if (o.StageName != 'Closed' || oldOpp.StageName == 'Closed') continue;
Decimal effectiveAmount = o.Amount != null ? o.Amount : oldOpp.Amount;
if (effectiveAmount != null && effectiveAmount > 100000) {
o.StageName = 'Closed Won';
} else {
o.StageName = 'Closed Lost';
}
}
}
The handler now falls back to the old record's Amount when the new value is null. The test passes regardless of whether downstream automation has cleared the field.
The fixed example
A test that is environment-independent and provides debugging information on failure:
@IsTest
private class OpportunityCloseHandlerTest {
@TestSetup
static void setup() {
Account a = new Account(Name='Big Co');
insert a;
}
@IsTest
static void shouldMarkAsWonWhenAmountAboveThreshold() {
Account a = [SELECT Id FROM Account WHERE Name = 'Big Co' LIMIT 1];
Opportunity o = new Opportunity(
Name='Big Deal', AccountId=a.Id, StageName='Prospecting',
CloseDate=Date.today().addDays(30), Amount=200000
);
insert o;
Test.startTest();
o.StageName = 'Closed';
update o;
Test.stopTest();
Opportunity refreshed = [SELECT StageName, Amount FROM Opportunity WHERE Id = :o.Id];
System.assertEquals('Closed Won', refreshed.StageName,
'Expected Closed Won. Actual stage: ' + refreshed.StageName +
'. Amount: ' + refreshed.Amount);
}
@IsTest
static void shouldMarkAsLostWhenAmountBelowThreshold() {
Account a = [SELECT Id FROM Account WHERE Name = 'Big Co' LIMIT 1];
Opportunity o = new Opportunity(
Name='Small Deal', AccountId=a.Id, StageName='Prospecting',
CloseDate=Date.today().addDays(30), Amount=50000
);
insert o;
Test.startTest();
o.StageName = 'Closed';
update o;
Test.stopTest();
Opportunity refreshed = [SELECT StageName FROM Opportunity WHERE Id = :o.Id];
System.assertEquals('Closed Lost', refreshed.StageName);
}
}
Both branches of the handler now have dedicated assertions with meaningful messages. The @TestSetup method isolates the Account creation so each test starts from the same baseline.
Edge case: assertions inside loops
A loop that asserts on each iteration can produce a confusing failure when the assertion fires on, say, the 17th iteration of 200. The error message gives the values but not the index. Capturing the index in the assertion message clarifies which item failed:
for (Integer i = 0; i < opps.size(); i++) {
System.assertEquals('Closed Won', opps[i].StageName,
'Iteration ' + i + ' failed for opportunity ' + opps[i].Id);
}
The added context identifies the failing record within seconds.
Edge case: float-point equality
Asserting equality on Decimal or Double values that result from arithmetic can fail due to precision differences. Use a tolerance instead of strict equality for computed values:
Decimal expected = 100.0;
Decimal actual = something.compute();
System.assert(Math.abs(actual - expected) < 0.01,
'Expected approximately 100, got ' + actual);
The tolerance handles representation drift while still detecting meaningful divergence.
Edge case: order-dependent assertions
A test that asserts the order of records in a List depends on the SOQL ORDER BY clause and on insertion order. When no ORDER BY is specified, the platform may return records in any order. The fix is to always use ORDER BY in the query that the assertion examines:
List<Opportunity> opps = [
SELECT Id, Name FROM Opportunity
WHERE AccountId = :a.Id
ORDER BY Name
];
System.assertEquals('Big Deal', opps[0].Name);
Without the ORDER BY, the same test could pass or fail depending on internal ordering.
Edge case: assertions that depend on running-user context
System.runAs(someUser) changes the running user for a block of code. Assertions inside the block see that user's permissions, sharing access, and field visibility. Forgetting to wrap the assertion in runAs can produce a different result than the production code will see when a real sales rep visits the page.
User salesRep = createSalesRep();
System.runAs(salesRep) {
List<Opportunity> visible = [SELECT Id FROM Opportunity];
System.assertEquals(3, visible.size(), 'Sales rep should see 3 Opportunities');
}
The block scopes the running user to the sales rep. The query inside the block enforces the sharing model for that user. The assertion confirms the sales rep sees the expected subset. Without runAs, the query runs as the test runner (usually a System Administrator) and sees all rows, which would mask sharing-related regressions.
Edge case: governor-limit assertions
Tests that explicitly assert on governor limits (Limits.getQueries(), Limits.getDmlStatements()) help catch regressions where a refactor increases the SOQL or DML count. The assertion compares the limit consumption before and after the operation:
Integer before = Limits.getQueries();
SomeService.processBatch(records);
Integer after = Limits.getQueries();
System.assert(after - before <= 2, 'processBatch should run at most 2 queries; ran ' + (after - before));
A regression that adds an extra query (often because someone added a SOQL inside a loop) fails this assertion immediately. The message includes the actual count, which speeds the diagnosis.
Edge case: assertions on asynchronous outcomes
A test that triggers a Queueable or future method and then asserts on the outcome needs to wrap the trigger inside Test.startTest() and Test.stopTest(). The stopTest call forces async work to complete synchronously inside the test. Without it, the assertion runs before the async job finishes and the value being asserted on is still the pre-async state.
Test.startTest();
System.enqueueJob(new RecalcQueueable(opp.Id));
Test.stopTest();
Opportunity refreshed = [SELECT Score__c FROM Opportunity WHERE Id = :opp.Id];
System.assertNotEquals(null, refreshed.Score__c);
The pattern ensures the async path completes before the assertion runs. Skipping stopTest produces flaky tests that pass locally and fail in CI under different scheduling.
Defensive habits
Always include a meaningful message in every assertion. The line System.assertEquals(expected, actual) tells you the values but not the context. The line System.assertEquals(expected, actual, 'Order processing for ' + orderId) tells you which order failed and which path went wrong.
Run tests in a sandbox that mirrors CI before pushing. CI failures from sandbox-only drift waste deployment windows.
Use @TestSetup to create stable, reusable fixtures. Inline fixture creation in each test causes test interactions when one test modifies shared state.
Avoid time-dependent assertions. If a test must check a calendar property, mock the date instead of relying on Date.today(). The platform's test mock framework or a dependency-injection pattern handles this cleanly.
Test patterns
A test class structure that handles assertions cleanly:
@IsTest
private class OpportunityCloseHandlerTest {
private static final String SETUP_ACCOUNT_NAME = 'Test Account';
@TestSetup
static void seed() {
insert new Account(Name = SETUP_ACCOUNT_NAME);
}
private static Account loadAccount() {
return [SELECT Id FROM Account WHERE Name = :SETUP_ACCOUNT_NAME LIMIT 1];
}
@IsTest
static void wonForHighAmount() {
// ... uses loadAccount, asserts with message
}
@IsTest
static void lostForLowAmount() {
// ...
}
@IsTest
static void lostForNullAmount() {
// ...
}
}
Each test has a single assertion focus. The setup is shared. The error messages tell the story.
Diagnosing in production
When the assertion fires:
- Read the assertion message carefully. The expected and actual values are the strongest clue.
- Find the line of code by following the stack trace.
- Reproduce the failure in a sandbox that matches the CI environment.
- Identify the cause: production-code bug, environmental drift, or test fragility.
- Apply the fix at the right level: fix the bug, or fix the test if the test was wrong.
- Re-run and confirm.
The diagnosis is usually a 15-30 minute exercise once the failure is reproducible.
Quick recovery checklist
- Read the assertion message and find the line.
- Reproduce in a matching sandbox.
- Diagnose: bug vs. drift vs. fragility.
- Fix at the right level.
- Add assertion messages that would have made the next failure faster to diagnose.
Most AssertException failures resolve within an hour. The longer fix is investing in test quality so the next failure tells you the cause out of the box.
Further reading from Salesforce
Related dictionary terms
Share this fix
Related Deployment errors
ALREADY_IN_PROCESS: another deployment is in progress
DeploymentAn org can run only one deployment at a time. Your deploy is queued behind a deployment someone (or some automation) already started. Wait f…
ApexUnitTestClassShouldHaveAsserts / ApexBadCrypto / Code Analyzer violations blocking deploy
DeploymentSalesforce's open-source code scanners (PMD-Apex, Salesforce Code Analyzer) found rule violations in your codebase. They don't block deploys…
Average test coverage across all Apex Classes and Triggers is XX%, at least 75% required
DeploymentA production deploy of Apex requires at least 75% line coverage *org-wide*, and 100% on triggers (including triggers that aren't part of you…
BadElement / Element type required / package.xml is malformed
DeploymentYour `package.xml` (or `destructiveChanges.xml`) is invalid XML or references a metadata type the platform doesn't recognise. The error name…
Cannot change field type from <type1> to <type2> via the API
DeploymentSalesforce restricts which field type changes are allowed in a deploy. Many type transitions (Number to Text, Picklist to Lookup, Text to Lo…