Salesforce Apex Test Classes: Examples, Assertions and Coverage

For people new to Salesforce or programming: tests, code coverage, and quality explained patiently through analogies and practical examples.

By Monique da Silva · July 23, 2026 · Updated September 29, 2026 · 36 min read

Complete beginner-friendly guide to Apex test classes

This guide is for people arriving in Salesforce, and even for people arriving in programming itself.

If someone has told you, "you need to create the test class," and you were not sure where to begin, that is normal. Technical material often introduces testing with a wall of terms. Here, each idea has a purpose, an analogy, and a working example.

0. Before everything else: why do tests exist?

Have you ever changed one harmless-looking line and discovered later that another process stopped working? That is the problem automated tests help expose before a real user or production record pays the price.

Imagine a factory. Products do not leave the production line and go straight to customers. They pass through quality control, where someone checks that the result matches an expected standard.

An Apex test class is quality control for code. It helps answer:

I changed something here. Did I break behavior that should still work?

Without a useful test, the answer may appear only when a user complains. With a useful test, the feedback appears in a controlled environment before deployment.

Remember this throughout the guide: Salesforce's coverage requirements are a consequence of testing, not the purpose of testing.

1. Quick glossary

TermMeaning in one sentence
ApexSalesforce's server-side programming language, with syntax and concepts similar to Java.
ClassA unit of code that groups related state and behavior.
MethodA block inside a class that performs a specific action or returns a result.
TriggerApex that runs automatically in response to record events such as insert, update, delete, or undelete.
SOQLThe query language used to retrieve Salesforce records.
DMLOperations that write data, such as insert, update, delete, and upsert.
BulkProcessing many records in one transaction instead of assuming one record at a time.
AsynchronousWork scheduled to run separately, such as Future, Queueable, Batch, or Scheduled Apex.
CalloutAn HTTP request from Apex to an external service.
MockA controlled substitute for an external dependency used during a test.
Governor LimitsResource boundaries applied to a Salesforce transaction.

2. What is a test class?

A test class is Apex that does not deliver a user-facing feature. It executes production code and proves that the observed result matches the expected behavior.

Analogy: inspecting a building

  • The production class is the building.
  • Its methods and branches are the rooms.
  • Executing a test is an inspector walking through some rooms.
  • Code coverage is the map showing which rooms were visited.
  • An assertion is the inspector checking whether the outlet works, the door locks, and the pipe does not leak.

An inspector can visit every room with their eyes closed. The map shows 100 percent coverage, but nothing was verified. The same happens when a test executes lines without assertions.

Coverage answers, "where did the test go?" Assertions answer, "was the behavior correct?"

Consider a small shipping-fee service:

public with sharing class ShippingFeeService {
    public static Decimal calculate(Decimal orderTotal, Boolean premiumCustomer) {
        if (orderTotal == null || orderTotal < 0) {
            return null;
        }
        if (premiumCustomer == true || orderTotal >= 200) {
            return 0;
        }
        return 25;
    }
}

The business rules are simple: invalid totals return null; premium customers or orders of at least 200 receive free shipping; every other order pays 25.

This weak test executes code but proves nothing:

@IsTest
static void weakTest() {
    ShippingFeeService.calculate(100, false);
}

If someone changes return 25 to return 2000, the test still passes. A useful test validates the return value:

@IsTest
static void shouldChargeStandardFee() {
    Decimal actualFee = ShippingFeeService.calculate(100, false);

    Assert.areEqual(
        25,
        actualFee,
        'A regular order below 200 should have a shipping fee of 25.'
    );
}

3. Arrange, Act, Assert

Well-structured test methods usually follow three stages, often called AAA:

StageQuestionTypical work
ArrangeWhat data and context does this scenario need?Create variables, records, users, or mocks
ActWhich behavior am I testing?Call the production method
AssertDid the real result match the expected result?Compare values and verify side effects

Think of baking in a laboratory: collect ingredients, bake the cake, then taste and inspect it. The oven timer only proves that time passed. The tasting proves the result.

@IsTest
static void shouldChargeStandardFee() {
    // Arrange
    Decimal orderTotal = 100;
    Boolean premiumCustomer = false;
    Decimal expectedFee = 25;

    // Act
    Test.startTest();
    Decimal actualFee = ShippingFeeService.calculate(orderTotal, premiumCustomer);
    Test.stopTest();

    // Assert
    Assert.areEqual(
        expectedFee,
        actualFee,
        'A regular order below 200 should have a shipping fee of 25.'
    );
}

What Test.startTest() and Test.stopTest() actually do

The pair is not only a visual marker. Arrange can consume queries, DML, and other resources. Test.startTest() separates the behavior under test and provides a fresh limit context appropriate to what runs next.

Test.stopTest() causes asynchronous work enqueued after startTest() to execute synchronously in the test context before assertions continue. This lets you verify Future, Queueable, Batch, or Scheduled behavior after it has completed.

Use one start/stop pair per test method and keep the exact action under test inside it. Read Using Limits, startTest, and stopTest for the official details.

4. A complete test class

A serious test class covers the meaningful branches of the shipping rule, not only the happy path:

@IsTest
private with sharing class ShippingFeeServiceTest {
    @IsTest
    static void shouldChargeStandardFee() {
        Test.startTest();
        Decimal actualFee = ShippingFeeService.calculate(100, false);
        Test.stopTest();
        Assert.areEqual(25, actualFee, 'A regular order should pay shipping.');
    }

    @IsTest
    static void shouldGiveFreeShippingAtBoundary() {
        Test.startTest();
        Decimal actualFee = ShippingFeeService.calculate(200, false);
        Test.stopTest();
        Assert.areEqual(0, actualFee, 'Exactly 200 should qualify for free shipping.');
    }

    @IsTest
    static void shouldGiveFreeShippingToPremiumCustomer() {
        Test.startTest();
        Decimal actualFee = ShippingFeeService.calculate(50, true);
        Test.stopTest();
        Assert.areEqual(0, actualFee, 'Premium customers should receive free shipping.');
    }

    @IsTest
    static void shouldReturnNullForNullTotal() {
        Test.startTest();
        Decimal actualFee = ShippingFeeService.calculate(null, false);
        Test.stopTest();
        Assert.isNull(actualFee, 'A null total is invalid.');
    }

    @IsTest
    static void shouldReturnNullForNegativeTotal() {
        Test.startTest();
        Decimal actualFee = ShippingFeeService.calculate(-1, false);
        Test.stopTest();
        Assert.isNull(actualFee, 'A negative total is invalid.');
    }
}

This is a useful model because each method proves one behavior, method names communicate intent, valid and invalid inputs are covered, the exact boundary is tested, assertions contain meaningful messages, and no scenario depends on organization data.

5. Testing code that uses Account, Case, Contact, and other records

By default, Apex tests do not see most real organization data. That isolation is intentional. A test that depends on one existing Account can pass in one sandbox and fail in another.

A test creates the data it needs.
@IsTest
private class AccountExampleTest {
    @TestSetup
    static void createTestData() {
        insert new Account(Name = 'Account created by the test');
    }

    @IsTest
    static void shouldFindCreatedAccount() {
        Account accountRecord = [
            SELECT Id, Name
            FROM Account
            WHERE Name = 'Account created by the test'
            LIMIT 1
        ];
        Assert.areEqual('Account created by the test', accountRecord.Name);
    }
}

How @TestSetup protects each test method

@TestSetup creates shared baseline data once for the class. Every test method runs in isolation with access to that baseline. Changes made by one method are rolled back and do not contaminate another test method.

All test data changes are rolled back after execution, so the test does not leave records in the org.

Avoid SeeAllData=true as a default

@IsTest(SeeAllData=true)

This grants access to organization data, which may appear convenient but makes tests fragile. Use it only for the rare scenarios where Salesforce explicitly requires access to special data that cannot be created in a test.

Centralize repeated test data with a factory

As a project grows, dozens of classes may repeat the same Account or User setup. A Test Data Factory centralizes that creation:

@IsTest
public class TestDataFactory {
    public static Account createAccount(String accountName, Boolean doInsert) {
        Account acc = new Account(Name = accountName);
        if (doInsert) {
            insert acc;
        }
        return acc;
    }
}

If a new required field appears, you update the factory once instead of repairing many test classes. See Trailhead's Get Data for Your Tests.

6. Code coverage: what it is and what it is not

The formula

Coverage = covered lines / (covered lines + uncovered lines) * 100

If a class has eight executable lines and tests execute six, coverage is 75 percent. Comments, blank lines, and structural braces are not executable lines. Test classes themselves do not contribute to production-code coverage.

The production requirement

For a production deployment, Salesforce generally requires at least 75 percent overall Apex coverage, every executed test to pass, and each trigger to have some coverage. Coverage is necessary, but a high number without meaningful assertions does not protect behavior.

Test levels do not calculate deployment coverage in exactly the same way

Test levelCoverage behavior
RunLocalTestsRuns local tests and evaluates overall organization coverage.
RunAllTestsInOrgAlso runs tests from installed managed packages.
RunSpecifiedTestsEach deployed Apex class and trigger must meet the required coverage from the specified tests; an overall average cannot hide an under-tested deployed component.

Example: Class A has 90 percent coverage, Class B has 60 percent, and the overall average is 75 percent. An overall calculation may pass, but a specified-test deployment that includes Class B can fail because Class B does not meet the per-component requirement.

7. Does every production class need a matching test class?

Technically, the platform does not require ShippingFeeService to have a class named ShippingFeeServiceTest. Coverage is produced when any test executes the relevant lines.

However, a dedicated test class is valuable when a component owns important business rules, has several branches, is reused in many places, handles asynchronous work or callouts, or would otherwise have no obvious test owner.

The risk of "incidental coverage" is that another end-to-end test happens to pass through the component today. If a refactor removes that call path tomorrow, the component loses coverage and may never have had direct assertions.

Do not ask only, "does a test pass through this class?" Also ask, "which test proves this class's own behavior?"

8. Beyond the basics

8.1 Prefer the Assert class for new code

System.assert, System.assertEquals, and System.assertNotEquals still work. The dedicated Assert class provides expressive methods such as areEqual, isTrue, isNull, and fail.

// Older style, still supported
System.assertEquals(25, actualFee, 'Error message');

// More expressive style for new code
Assert.areEqual(25, actualFee, 'Error message');

8.2 Test expected exceptions

Protection rules should fail when invalid input is provided. Prove that the expected exception occurs, and fail explicitly if it does not:

@IsTest
static void shouldBlockDiscountsAboveLimitWithoutApproval() {
    try {
        Test.startTest();
        DiscountService.applyDiscount(50, false);
        Test.stopTest();

        Assert.fail('A discount above the limit should require approval.');
    } catch (DiscountService.DiscountNotAllowedException e) {
        Assert.isTrue(
            e.getMessage().contains('40%'),
            'The message should mention the 40% limit.'
        );
    }
}

Assert.fail() protects against a future change that accidentally removes the expected validation.

8.3 Test HTTP callouts with HttpCalloutMock

Apex tests cannot depend on a real external server. Implement HttpCalloutMock and return a controlled response:

@IsTest
global class ShippingApiMock implements HttpCalloutMock {
    global HttpResponse respond(HttpRequest request) {
        HttpResponse response = new HttpResponse();
        response.setHeader('Content-Type', 'application/json');
        response.setBody('{"status":"ok"}');
        response.setStatusCode(200);
        return response;
    }
}

@IsTest
static void shouldHandleSuccessfulApiResponse() {
    Test.setMock(HttpCalloutMock.class, new ShippingApiMock());
    Test.startTest();
    // Call the production code that performs the request.
    Test.stopTest();
    // Assert the resulting behavior.
}

Also simulate timeouts or error responses where the production behavior must handle them.

8.4 Test record sharing with System.runAs()

@IsTest
static void shouldRespectSharingForRestrictedUser() {
    User restrictedUser = TestDataFactory.createUser('Standard User');

    System.runAs(restrictedUser) {
        Test.startTest();
        // Execute production behavior as the restricted user.
        Test.stopTest();
        // Assert the expected sharing behavior.
    }
}

runAs helps enforce record-sharing behavior for the selected user. It does not automatically simulate every object and field permission. Test security deliberately according to the behavior you need to prove.

8.5 Avoid changing business behavior with Test.isRunningTest()

// Red flag: the test validates different business logic.
if (Test.isRunningTest()) {
    result = simplifiedCalculation();
} else {
    result = fullProductionCalculation();
}

When the test executes a simplified branch that production never uses, the green result creates false confidence. Use dependency injection, interfaces, and supported mocking patterns instead of hiding real behavior from tests.

8.6 Unit tests and integration-style tests

A unit test isolates one method or class, often replacing dependencies with mocks. An integration-style test validates several layers together, including database behavior. Apex tests frequently include integration characteristics because Salesforce business logic is closely connected to platform data, transactions, and automation. That makes test-data isolation especially important.

9. Common beginner mistakes

MistakeWhy it is a problemBetter approach
Writing tests only to reach 75 percentExecutes lines without proving behaviorAdd assertions for the expected result
Using a hardcoded ID from the orgPasses in one environment and fails elsewhereCreate test data
Forgetting startTest/stopTestAsynchronous work may not complete before assertionsPlace the behavior under test inside the pair
Testing only the happy pathBoundary and invalid-input bugs remain unprotectedTest null, empty, negative, exact limits, and failure paths
Using SeeAllData=true without needCreates dependencies outside the testUse @TestSetup and factories
Expecting an exception without Assert.fail()The test can pass if the exception disappearsFail explicitly after the call
Using Test.isRunningTest() to bypass logicTests a behavior that production does not useDesign testable dependencies
Testing a trigger with only one recordDoes not represent bulk platform behaviorTest meaningful multi-record sets

10. Write your first test step by step

  1. List the business rules in the production class.
  2. Turn each meaningful rule into a scenario.
  3. Name each test after the behavior it proves.
  4. Arrange only the data and context that scenario needs.
  5. Act by calling the target behavior inside Test.startTest() and Test.stopTest().
  6. Assert the real output or side effect with a clear failure message.
  7. Run the test and inspect the result.
  8. Break the production behavior temporarily and confirm the test fails.
  9. Restore the code and repeat for invalid, boundary, bulk, and failure scenarios.

The deliberate-break step is powerful. A test that still passes when the protected behavior is wrong is not protecting that behavior.

11. Run tests and inspect coverage

Developer Console

Run the test class, then review the Tests and Overall Code Coverage panels. Open the production class to see which executable lines were reached.

Developer Console test panel showing five passing tests and 100 percent coverage for ShippingFeeService
Five passing test methods and overall coverage for the production class.
ShippingFeeService with all executable lines highlighted as covered
With full coverage, every executable line is shown as visited.
ShippingFeeService with one return line highlighted as uncovered at 83 percent coverage
The uncovered return line reveals which business branch has no test scenario.

A red uncovered line does not tell you to create a meaningless execution. It tells you to identify the behavior behind that branch and write a scenario with a real assertion.

VS Code

  1. Enable test code coverage retrieval in the Salesforce extensions.
  2. Open the test class.
  3. Run one method or all tests in the class.
  4. Review the output and uncovered lines.

Salesforce CLI

Run one class:

sf apex run test --class-names ShippingFeeServiceTest --code-coverage --detailed-coverage --result-format human --wait 20 --target-org MySandbox

Run all local tests:

sf apex run test --test-level RunLocalTests --code-coverage --result-format human --wait 30 --target-org MySandbox

Validate a deployment without releasing it:

sf project deploy validate --manifest manifest/package.xml --test-level RunLocalTests --target-org Production --wait 60

12. Final checklist

Coverage shows where the test went. An assertion proves that what happened there was correct. A good test class delivers both and is designed around protected behavior, not only a 75 percent number.

Official sources used in this guide

Want to keep learning Salesforce and OmniStudio through practical content?

Explore the blog, OmniStudio 365, the course, and my English OmniStudio lessons on YouTube.

Explore OmniStudio 365

About the author

I am Monique da Silva, a Salesforce professional focused on OmniStudio and Vlocity. I work across technical-functional delivery, declarative configuration, testing, QA collaboration, documentation, training, and problem solving, while continuing to expand my Apex, LWC, Agentforce, and Revenue Cloud skills.

I share practical content because explaining what I study helps me learn more deeply and makes the path easier for the next person.