Posted in

Why 75% Apex Test Coverage Still Lets Bugs Reach Production

Apex Test Coverage

Salesforce makes Apex testing unavoidable. A production deployment that includes Apex must satisfy the platform’s test requirements, and the number most teams remember is 75%. That threshold is useful because it blocks code that has barely been exercised. It can also create a false finish line. A deployment can clear 75% Apex test coverage while important business behavior remains untested.

The reason is simple: coverage records execution, not correctness. A line can run during a test without the test checking whether the line produced the right result. A branch can remain untouched. A bulk operation can fail even though the same logic passed with 1 record. A permission edge case can surface only after release. The gap between executed code and verified behavior is where production bugs survive.

That makes the real question bigger than “Did we reach 75%?” Teams need to ask whether their tests would fail when the code becomes wrong. Strong Salesforce Apex testing treats coverage as a map of exercised code and then adds assertions, boundary cases, user context, bulk behavior, and integration checks that give the number meaning.

TL;DR

Apex test coverage is a deployment gate, not proof that a release is safe. Salesforce requires at least 75% overall Apex coverage for standard production deployments and requires every trigger to have some coverage. The platform also tells developers to test real use cases instead of aiming only at the percentage. The practical goal is a test suite that breaks when behavior breaks.

·        Use coverage to find code that tests never execute, then review whether covered code is actually checked.

·        Write assertions against business outcomes, not only against whether a method completed.

·        Test branches, invalid inputs, nulls, limits, and record sets that change code paths.

·        Add user-context tests where sharing, CRUD, field access, or user-mode behavior can change the result.

·        Treat async jobs, callouts, and platform events as separate execution paths with their own expected outcomes.

·        Put coverage beside static analysis and release checks in CI so a passing percentage never becomes the only release signal.

What 75% Apex Test Coverage Actually Measures

The 75% figure is easy to misunderstand because it looks like a quality score. It isn’t. Salesforce calculates code coverage from executable Apex lines reached by tests. The platform’s current Apex unit testing guidance says production deployments need at least 75% coverage and that every trigger needs coverage, then tells developers to focus on use cases rather than the percentage itself. That distinction matters because the metric says where execution went, not whether the result was right.

The metric is still useful. Low Salesforce code coverage tells you that executable lines aren’t being exercised. Salesforce’s developer guidance on using code coverage to drive a testing strategy says high coverage doesn’t automatically mean strong tests because code can run without the right values or assertions. Coverage can point to gaps, but it can’t judge the test oracle.

Coverage Can Tell YouCoverage Can’t Tell You
Which executable lines ran during testsWhether the returned value was correct
Which classes or triggers have untouched linesWhether a wrong branch condition would be caught
Whether a deployment meets the platform’s coverage ruleWhether permissions work for the intended users
Where additional test scenarios may be neededWhether bulk loads stay within governor limits
Whether coverage changed after code changesWhether an integration response was interpreted correctly

Deployment mode adds another detail that teams often miss. With the Salesforce CLI, RunSpecifiedTests has its own coverage behavior: the tests you choose must cover each class and trigger in the deployment package by at least 75%, rather than relying on the usual org-wide result. This is one reason a local percentage can’t be read as a universal statement about deployability.

A second trap is assuming the number seen before a deployment will be the number used during the deployment. Salesforce explains that deployment coverage can differ from an earlier estimate because the calculation is made against the production org and the code being deployed. That issue is separate from test quality, but it reinforces the same lesson: Salesforce code coverage is a platform measurement with rules and context. It isn’t a direct measure of defect risk.

Why Covered Lines Can Still Hide Wrong Behavior

A test can touch every line of a method and still miss the bug that matters. Consider a pricing method that applies a discount when an account is eligible. A test might create an eligible account, call the method, and finish without an exception. Every line can be marked as covered even if the method applies 5% when the requirement says 10%. Without an assertion on the final price, Apex test coverage reports execution while the wrong behavior passes quietly.

This problem is often called a weak test oracle. The oracle is the part of a test that decides whether the result is right. If the test only asks “Did this code run?” it has almost no power to detect a bad result. Google’s long-running testing guidance on understanding coverage data explains the general problem well: statement coverage doesn’t account for every input or every path and can reach 100% while a missing case remains hidden. The same logic applies directly to Apex unit tests.

A common Salesforce version of this problem appears in trigger tests. The test inserts a record, the trigger fires, and the test ends. Coverage rises because the trigger executed. Yet the test never queries the record again to check the field the trigger was meant to set. If that assignment is later changed, removed, or moved behind the wrong condition, the test may keep passing. The code is covered, but the contract isn’t protected.

Default values can create an even subtler false pass. If a test passes 0 and broken code leaves a numeric field at its default 0, the assertion can still pass. Google’s 2026 guidance on choosing values for stronger tests shows why inputs should be chosen to reveal mistakes. Avoid values that can accidentally match defaults or empty states.

The safest question is concrete: if a developer changes a comparison operator, swaps two fields, removes an assignment, or returns the wrong status, will at least 1 test fail for the right reason? If the answer is unclear, the suite has a behavior gap even when the coverage number looks healthy.

Assertions: The Difference Between Running Code and Verifying It

Assertions turn execution into evidence. A useful test states what should happen, runs the code, then checks the resulting state or returned value. Salesforce added a dedicated Assert class to make that intent clearer, and its guidance on Apex assertions recommends explicit methods such as Assert.areEqual, Assert.isTrue, and related checks. The point isn’t the method name. The point is that Apex unit tests should state the behavior they protect.

A weak test often has this shape:

@IsTest

static void testCreatesCase() {

Account a = TestDataFactory.account();

insert a;

CaseService.createForAccount(a.Id);

}

The method executes production code and may produce excellent coverage, but it never verifies that a Case was created or that the Case contains the right values. A stronger version queries the outcome and checks the fields that matter to the requirement. If the service is supposed to set Origin, Status, and a relationship, the assertions should cover the business-critical result rather than every incidental field.

Assertion quality matters too. Assert.isTrue(records.size() > 0) may prove that something exists, but it can miss duplicate creation. Assert.areEqual(1, records.size()) protects a stricter contract when exactly 1 record is required. The same principle applies to money, status transitions, owner assignment, and integration payloads. The closer the assertion is to the actual rule, the more likely the test is to catch a regression.

Failure messages also reduce debugging time. A message such as “Expected Renewal Opportunity to be created after Closed Won” tells the next developer what contract failed. Salesforce Apex testing becomes easier to maintain when failures explain the expected behavior instead of forcing the team to reconstruct the intent from setup code.

Good assertions also avoid checking internal implementation details that can change without changing behavior. If a service can be refactored from 2 helper methods to 4 while producing the same output, a test tied to helper-call counts becomes brittle. Apex testing best practices should favor observable state, returned values, thrown exceptions, published messages, and other outputs that represent the contract seen by the caller.

Branches, Boundaries, and Failure Paths Need Separate Tests

Line coverage can hide branch risk because 1 executed path may paint much of a method as covered. An if statement can run with the condition true while the false path remains untested. A switch can exercise the default case and skip the branch used by the highest-value customer type. An exception handler can sit untouched until production sees a malformed response. This is why strong Apex unit tests are scenario-based rather than percentage-based.

Start with decision points. For every condition that can change an outcome, ask what input makes each side execute. A validation helper that accepts a date should be tested before the boundary, at the boundary, and after it when those states produce different behavior. An amount rule should use values that make the code cross its threshold. A string parser should see blank or malformed input when the production path can receive it. These cases improve Apex test coverage as a side effect, but the reason to add them is behavioral risk.

Negative tests deserve equal weight. Salesforce’s own testing material tells developers to include negative cases, and its coverage example shows how an invalid input can expose an uncovered path. A negative test should prove the expected failure behavior, such as a specific exception, rejected state, or absence of a write. Simply wrapping a call in try/catch and letting the test finish can hide the very failure the test was meant to detect.

Boundary tests are especially useful because business rules often fail at edges. Dates around month-end, quantities at a threshold, null foreign keys, empty collections, duplicate values, and reopened records can all select different branches. Apex testing best practices should map these boundaries from requirements and production incidents, then express them as named test scenarios.

Use test names as a small specification. testRenewalCreatedWhenContractExpiresWithin30Days tells a reviewer much more than testMethod1, while testRejectsBlankExternalId exposes the failure path before anyone reads the body.

Bulk Data and Governor Limits Change the Risk

Apex runs in a multitenant platform with governor limits, so single-record success doesn’t prove bulk safety. Triggers can process record sets, and Salesforce’s bulk trigger guidance notes that triggers execute in batches of up to 200 records. Code that works for 1 record can fail on a data load because it performs a query inside a loop, issues too many DML statements, or assumes Trigger.new[0] is the only record that matters.

This is one of the clearest ways 75% Apex test coverage can give false comfort. A test that inserts 1 Account can execute every line in a trigger handler. The same handler may exceed a limit or update only the first item when 200 Accounts arrive. The coverage percentage doesn’t change because the same lines execute in both cases. The data volume changes the behavior.

Bulk tests should mirror the transaction shapes your org really sees. If Data Loader, middleware, scheduled jobs, or APIs submit many records, test a representative collection and verify outcomes across the set. Check that every eligible record changed, that ineligible records stayed unchanged, and that the code completed inside the platform’s limits. Salesforce Apex testing should also cover mixed record sets where different records take different branches in the same transaction.

Test.startTest() and Test.stopTest() help isolate the code under test from setup work. Salesforce’s trigger testing guidance explains that the pair gives the test block a fresh set of governor limits and is also important for async testing. Use the setup phase to create records, then place the action under test inside the start and stop block so limit consumption belongs to the behavior you intend to measure.

Bulk testing shouldn’t become a ritual of inserting 200 identical records. Use a record set that exposes list logic, filtering, grouping, and selective updates. A mix of eligible and ineligible records often finds defects that repeated values hide.

Permissions and User Context Can Break Correct-Looking Logic

Many production defects appear only for users who don’t share the developer’s access. Apex can run in system context in many situations, while sharing settings, user mode, CRUD checks, field access, and explicit security enforcement can change what records or fields a real user can reach. A test suite that runs only through an administrator-like context can miss failures that affect sales reps, service users, partner users, or integration identities.

Coverage has almost no power to expose that difference on its own. The same line can execute for 2 users and produce different query results. A record may be visible in one sharing context and missing in another. A field write can fail when user-mode access is enforced. Salesforce code coverage marks the line as reached either way, so the scenario has to be expressed in the test design.

Use user-context tests where security changes application behavior. System.runAs is useful for record-sharing context in tests, but don’t treat it as a complete CRUD or field-level security test by itself. The production code’s security mode still matters. When code uses WITH USER_MODE, user-mode DML, Security.stripInaccessible, or explicit permission checks, create test users and permission assignments that match the behavior you need to verify.

The expected result should be clear for both allowed and denied cases. If a rep should update a record they own but shouldn’t update another team’s record, write both scenarios. If an integration user should read a field that a standard user can’t, test the difference. These Apex unit tests often find bugs that high coverage never reveals because the missing dimension is identity, not execution.

Async Apex, Callouts, and Events Need Their Own Test Design

Asynchronous code creates a second gap between line execution and production behavior. Queueable, batch, scheduled, and future work runs in separate execution contexts. A test that verifies only that a job was enqueued doesn’t prove the job produced the required result. Salesforce Apex testing has to cross that boundary by forcing test-time execution where supported and asserting the state after the async work completes.

Test.stopTest() is central here because queued async work created after Test.startTest() is executed when the test reaches Test.stopTest() in supported test scenarios. Put assertions after that point when the expected state depends on the async job. Check the records the job changed, the follow-up job state you can observe, or another contract-level result rather than only the returned job ID.

Callouts need isolation from the live endpoint. Salesforce’s mock and stub guidance shows how HttpCalloutMock and the Stub API let tests supply controlled responses. That matters because a happy-path 200 response says little about timeout handling, malformed JSON, authentication errors, empty payloads, or partial data. Mocks let Apex unit tests force those cases without relying on a network service.

A useful callout suite usually covers more than 1 response shape. Test the successful payload that drives the normal workflow, then test failures that should change behavior. Assertions should verify both local state and error handling. If the code stores a retry marker, creates an integration log, or leaves the record untouched, check that outcome directly. This is where Apex testing best practices protect integration behavior rather than merely execute the HTTP branch.

Platform events need similar care because publishing and subscriber processing are separate concerns. Salesforce documents how to deliver platform event messages in Apex tests, including use of Test.stopTest() or Test.getEventBus().deliver() to cause test delivery. A test should verify what the subscriber did after delivery instead of stopping at the publish call.

Async chains deserve extra thought because a single test doesn’t always mirror a long production chain. Test each job’s contract separately when needed: verify the first job’s result, then test the next job with the state it expects.

Test Data Design Can Create False Confidence

Test data is part of the test, not background noise. A test can pass because its records are too simple, because every field has the same value, or because the setup accidentally avoids the condition that fails in production. The coverage report won’t tell you that the input set is weak. The lines still run.

By default, Apex tests are isolated from most existing org data, and that is a strength. Salesforce’s test data guidance explains that tests should create the records they need and that @TestSetup or test factories can reduce repeated setup. Isolated data makes tests more repeatable across sandboxes and production because they don’t depend on mutable record IDs or current business data.

Factories should make important differences easy to express. A factory that always creates the same Account shape can become a trap if every test accepts its defaults. Give tests a way to vary ownership, status, dates, record type, and other fields that drive logic. Use distinct input values when fields could be swapped or confused. If 2 parameters both receive the same string, a bug that reverses them can stay invisible.

Avoid SeeAllData=true unless the scenario truly requires data that can’t be created in the test. Tests coupled to org records can pass in one environment and fail in another because the records, configuration, or relationships differ. They can also hide missing setup because the org happens to contain a record that satisfies the query.

Good data also includes records that shouldn’t change. If a service updates qualifying Opportunities, include 1 that doesn’t qualify and assert that it remains untouched. That protects filters and catches updates that are too broad.

What Strong Apex Unit Tests Actually Check

Strong tests are small specifications for production behavior. They create a known state, perform 1 meaningful action, then verify a contract. The exact contract changes by feature, but the test should make it easy for a reviewer to answer what rule is protected and what failure would break the test.

A practical review can use the following matrix. It goes beyond Apex test coverage without turning every class into a giant test project.

Risk AreaWhat the Test Should ProveTypical Production Failure
Business ruleCorrect output for qualifying and non-qualifying inputWrong status, amount, owner, or related record
Branch logicEach meaningful path produces its expected stateRare branch ships broken
Bulk behaviorA record set processes correctly within limitsData load hits governor limits or skips records
Security contextAllowed users succeed and restricted users get the intended resultWorks for admin but fails for real users
IntegrationSuccess and failure responses produce controlled outcomesTimeout or bad payload corrupts local state
Async workState after queued work matches the contractJob enqueues but doesn’t finish the business action
Test dataInputs expose boundaries and distinguish fieldsDefaults hide a wrong assignment or comparison

Test independence matters. Each method should run alone and in any order. Use @TestSetup for common records when helpful, then let each test change only the data it owns for that execution.

Apex testing best practices also require judgment about what not to assert. Avoid locking tests to query order, helper method counts, or incidental implementation details unless those details are part of the contract. The more a test describes behavior at the public boundary of the code, the easier it is to refactor production code without rewriting the suite.

Build a Better Salesforce Apex Testing Strategy

A stronger testing strategy starts by separating coverage from confidence. Keep Apex test coverage because the platform requires it and because uncovered lines deserve attention. Add other checks based on the ways your code can fail. That gives teams a release decision built on behavior rather than a single percentage.

Start With Risk Before Coverage

List the parts of the change that can affect money, access, automation, integrations, or high-volume processing. Then write scenarios around those risks before looking at the coverage report. Google’s code coverage best practices makes a similar point: coverage is most useful for finding what isn’t tested, while the right target depends on the importance and change profile of the code. For Apex testing best practices, the same idea means a critical approval rule deserves deeper tests than a low-impact formatting helper.

Use Coverage as a Gap Finder

Apex testing best practices use the coverage report after behavior tests pass. Inspect Salesforce code coverage and find untouched code. Decide whether each uncovered area represents a missing scenario, dead code, or a low-value path that can be left uncovered for a documented reason. This order reduces the temptation to add shallow tests whose only purpose is to paint red lines blue.

Add Static Checks Beside Tests

Tests can only detect behavior they exercise. Static analysis can find a different class of problem without running the code, including risky patterns, security issues, duplication, and maintainability concerns. Salesforce’s current Code Analyzer overview describes analysis across Apex and other Salesforce code, while its engine reference lists checks such as PMD and Salesforce Graph Engine. These checks don’t replace Apex unit tests, but they can flag issues that coverage never sees.

Challenge Tests That Look Too Easy to Satisfy

A useful review technique is to imagine a small defect and ask whether the test would fail. Change > to >=. Remove a field assignment. Return an empty collection. Skip the second record in a loop. Replace a non-null ID with null. If the existing tests still appear likely to pass, the assertions or inputs are too weak.

This is the idea behind mutation testing, where tools introduce small code changes and measure whether tests detect them. Apex teams don’t need to make mutation score a release requirement to benefit from the concept. A manual “would this test catch a plausible mistake?” review is often enough to reveal suites that have high coverage and low defect sensitivity.

Gate Quality in CI/CD Without Chasing a Percentage

CI should keep the 75% requirement visible without letting it become the entire release policy. A good pipeline runs the relevant Apex tests, records failures, checks coverage, and blocks obvious quality regressions. It should also include checks that answer questions coverage can’t answer.

Start with the platform gate. Run the test level that matches your deployment policy and verify that the coverage calculation you review is the one Salesforce will apply. Teams that use selected tests need to understand the per-component coverage rule for that mode. Teams that depend on org-wide coverage need to watch how new classes can change the denominator. This prevents release surprises while keeping the metric in its proper role.

Next, fail the pipeline on test failures rather than accepting a high percentage with a red test suite. Then add static analysis for Apex and security rules that fit your org. Use code review to inspect new branches, permission-sensitive changes, async behavior, and test assertions. A coverage increase should never compensate for a missing assertion on a critical rule.

Set local expectations by risk instead of declaring 100% as a universal quality score. Some classes can reasonably reach 100% with useful tests. Others contain defensive or platform-specific paths that are hard to trigger. The decision should be explained by behavior and risk. Salesforce code coverage works best as one signal among several release checks.

Coverage can’t represent every layer of risk. In a Salesforce org, Apex tests may need support from integration tests, Flow tests, UI checks, or business-process validation when a change crosses those boundaries. The right mix depends on what the release can break.

Track production escapes as feedback. Classify why the suite missed each bug, then add the missing scenario to the tests and review checklist. Over time, Apex testing best practices should reflect the org’s real failure history.

Conclusion

The 75% requirement does an important job: it prevents Apex from moving to production with little or no automated execution. It was never meant to prove that the software is correct. Apex test coverage can reach the threshold while branches remain untested, assertions stay weak, bulk behavior fails, permissions change results, or integrations break on responses the suite never modeled.

Reliable Salesforce Apex testing uses the number as a starting point. Strong Salesforce Apex testing asks whether each important business rule has a test that would fail when that rule is broken. It uses clear assertions, varied inputs, realistic record sets, controlled integration responses, and user contexts that match production behavior. Those choices make Apex unit tests useful long after the deployment succeeds.

The best signal isn’t a prettier percentage. It is a test suite that catches changes before users do. Keep Salesforce code coverage in the pipeline, inspect uncovered lines, and then spend most of the testing effort on behavior. That is how teams turn a mandatory metric into a useful engineering tool without mistaking it for proof of quality.

FAQs

Is 75% Apex Test Coverage Enough for Production?

It can satisfy the standard minimum coverage rule when the other deployment requirements are met, but it doesn’t prove the release is defect-free. Important branches, weak assertions, bulk failures, permission differences, or integration errors can still be missed. Treat 75% as a deployment floor, not a quality verdict.

Does 100% Apex Test Coverage Mean the Code Is Bug-Free?

No. A test can execute every line without checking the result. Full coverage can also miss input combinations, missing requirements, security differences, and behavior outside the unit. High coverage helps only when the tests themselves check meaningful behavior.

What Is the Difference Between Apex Test Coverage and Test Quality?

Apex test coverage measures which executable lines tests reached. Test quality describes how well the suite detects wrong behavior through meaningful assertions, useful inputs, branch cases, error handling, and realistic execution contexts.

Why Can an Apex Test Pass Without Catching a Bug?

The test may execute broken code without asserting the affected result. It can also use an input that hides the defect, skip the failing branch, or run under the wrong user context. A passing test proves only that its stated checks passed.

Should Every Apex Test Have Assertions?

Tests that verify business behavior should make the expected outcome explicit. In most cases that means assertions on returned values, record state, errors, or another observable result. Calling production code without checking the result can raise coverage without protecting the requirement.

How Many Assertions Should an Apex Unit Test Have?

There is no useful universal count. Use enough assertions to describe the scenario’s contract without mixing unrelated behavior into the same test. A focused test may need 1 assertion or several related checks.

Why Should Apex Tests Include Bulk Records?

Apex can behave differently when many records share a transaction. Bulk tests can expose SOQL or DML inside loops, collection mistakes, limit problems, and code that updates only part of a record set. Single-record tests can’t prove those cases are safe.

What Should I Test Around Governor Limits?

Use Test.startTest() and Test.stopTest() to isolate the production action from setup work. Test record volumes and paths that exercise the queries, DML, or async work the feature depends on so the transaction reflects real usage.

How Should I Test Apex Callouts?

Use controlled mocks rather than live external services. Provide success and failure response shapes your code must handle, then assert the local outcome. Mocks keep tests repeatable and make hard-to-reproduce error cases testable.

How Do I Test Queueable or Other Async Apex?

Enqueue async work after Test.startTest() and use Test.stopTest() so supported work executes in the test context. Put result assertions after the stop call. For longer chains, test each job’s contract separately when needed.

Can Permission Bugs Slip Past High Salesforce Code Coverage?

Yes. The same lines can execute for users with different access and produce different results. Use realistic user contexts when sharing, CRUD, field access, or user-mode execution affects the feature. Coverage alone doesn’t prove the right users can perform the action.

Is `SeeAllData=true` a Good Way to Make Apex Tests More Realistic?

Usually no. Tests that depend on existing org data can become unstable across environments and over time. Create the records the test needs whenever possible. Reserve SeeAllData=true for cases that truly require existing org data.

Should We Aim for 100% Salesforce Code Coverage?

More useful coverage can be a good outcome, but 100% shouldn’t replace risk-based test design. Focus first on business rules, failure paths, bulk behavior, security context, and integrations. Then use coverage to find remaining gaps.

What Else Should CI Check Besides Apex Test Coverage?

CI should run the required tests and fail on test failures. It can also run static analysis, security checks, and policy checks that fit the codebase. Code review should inspect assertions and permission-sensitive or integration changes that coverage can’t prove.

What Is the Best Way to Improve Weak Apex Tests?

Start with the behaviors that would hurt most if they broke. Add assertions for those contracts, then add branch and boundary inputs. Include bulk and user-context scenarios where relevant. After that, inspect uncovered lines and add cases that represent real risk.

Leave a Reply

Your email address will not be published. Required fields are marked *