Salesforce Development
Hands-onIntermediate 40 min· Developer · 11 of 17

Testing Apex

Write Apex test classes that create their own data, cover happy, error and 200-record bulk cases, and meet the 75% deployment rule with assertions that actually prove behaviour.

Salesforce will not let you deploy Apex to production unless your tests cover at least 75% of your Apex lines and all tests pass. But coverage is only a number. The real purpose of a test is to prove your code does what you think, for one record and for 200, so you can change it later without fear.

In this tutorial you will write a test class for the Opportunity trigger from the previous lesson, covering the happy path, the error path and a bulk case. You will then run it and read the coverage.

Prerequisites: *Apex triggers and governor limits* (you need OpportunityTrigger and OpportunityTriggerHandler saved in your org) and *Apex basics*.

The rules of Apex tests

RuleWhat it means
Mark with @IsTestTest classes and test methods carry the @IsTest annotation. They do not count toward your org's Apex code size limit
Tests see no org data by defaultYour test creates its own records. The default is isolated data, which is what you want (avoid SeeAllData=true)
Changes are rolled backRecords created in a test vanish when it ends, so tests never pollute the org
75% to deployProduction needs overall coverage of at least 75% and every trigger needs some coverage; aim far higher, and assert real behaviour
All tests must passA single failing test blocks the deployment

Step 1: the test class skeleton

Create the class in the Developer Console with File > New > Apex Class, name it OpportunityTriggerTest. A common layout is: a @TestSetup method that creates shared data once, a small helper to build records, and one test method per behaviour.

OpportunityTriggerTest.cls (skeleton)
@IsTest
private class OpportunityTriggerTest {

    @TestSetup
    static void setup() {
        Account acc = new Account(Name = 'Test Cafe');
        insert acc;
    }

    // Builds (but does not save) opportunities for the test account
    private static List<Opportunity> buildOpps(Integer howMany) {
        Account acc = [SELECT Id FROM Account WHERE Name = 'Test Cafe' LIMIT 1];
        List<Opportunity> opps = new List<Opportunity>();
        for (Integer i = 0; i < howMany; i++) {
            opps.add(new Opportunity(
                Name = 'Deal ' + i,
                AccountId = acc.Id,
                StageName = 'Prospecting',
                CloseDate = Date.today().addDays(30),
                Amount = 1000
            ));
        }
        return opps;
    }

    // test methods go here
}

The class is private because only the test runner uses it. @TestSetup runs once and its data is available to every test method; each method gets a fresh copy because changes are rolled back between methods.

Note the required fields: an Opportunity needs a Name, a StageName and a CloseDate. Missing one makes your test fail before the trigger even runs. Add other required fields if your org has custom validation rules or required fields.

Step 2: the happy path

A good test follows Arrange, Act, Assert: set up data, do the thing, check the result. Use the Assert class (Assert.areEqual, Assert.isTrue, Assert.isNull and others) with a message that says what went wrong. Older code uses System.assertEquals; it still works but Assert gives clearer failures.

Happy path
    @IsTest
    static void createsFollowUpTaskWhenWon() {
        List<Opportunity> opps = buildOpps(1);
        insert opps;

        Test.startTest();
        opps[0].StageName = 'Closed Won';
        update opps;
        Test.stopTest();

        Id oppId = opps[0].Id;
        List<Task> tasks = [
            SELECT Id, Subject FROM Task WHERE WhatId = :oppId
        ];
        Assert.areEqual(1, tasks.size(), 'One task should be created');
        Assert.areEqual('Send onboarding email', tasks[0].Subject);
    }

Creates an open opportunity, moves it to Closed Won, and checks exactly one Task was created with the right subject.

Test.startTest() and Test.stopTest() wrap the code you are actually testing. They give that block its own fresh set of governor limits, so the data setup before it does not eat into the limits you are measuring. Test.stopTest() also waits for any asynchronous Apex you started (Queueable, future methods) to finish, so you can assert on its results afterwards.

Step 3: the error path

Good tests also prove that bad input is rejected. To check that addError blocks a save, use Database.insert(record, false) so the test does not crash, then inspect the result.

Error path
    @IsTest
    static void blocksClosedWonWithoutAmount() {
        Opportunity opp = buildOpps(1)[0];
        opp.StageName = 'Closed Won';
        opp.Amount = null;

        Test.startTest();
        Database.SaveResult result = Database.insert(opp, false);
        Test.stopTest();

        Assert.isFalse(result.isSuccess(), 'Save should be blocked');
        String message = result.getErrors()[0].getMessage();
        Assert.isTrue(message.contains('greater than zero'), 'Unexpected message: ' + message);
    }

    @IsTest
    static void dmlExceptionIsThrownWhenNotUsingDatabaseClass() {
        Opportunity opp = buildOpps(1)[0];
        opp.StageName = 'Closed Won';
        opp.Amount = 0;

        try {
            insert opp;
            Assert.fail('Expected a DmlException');
        } catch (DmlException e) {
            Assert.isTrue(e.getMessage().contains('greater than zero'));
        }
    }

Two ways to test a failure: look at the SaveResult, or expect a DmlException. Assert.fail makes the test fail if no exception was thrown.

A passing test is not always a good test

Beware of a test that catches an exception and asserts nothing. It always passes and proves nothing. Every catch block in a test should assert something, and the line after the risky call should call Assert.fail if no exception occurs.

Step 4: the bulk case

This is the test that proves bulkification. Insert 200 opportunities, move all to Closed Won, and confirm 200 tasks. If your handler had a query or DML inside the loop, this test would fail with a limit error.

Bulk and negative cases
    @IsTest
    static void handlesTwoHundredRecords() {
        List<Opportunity> opps = buildOpps(200);
        insert opps;

        for (Opportunity opp : opps) {
            opp.StageName = 'Closed Won';
        }

        Test.startTest();
        update opps;
        Test.stopTest();

        Set<Id> oppIds = new Map<Id, Opportunity>(opps).keySet();
        Integer taskCount = [SELECT COUNT() FROM Task WHERE WhatId IN :oppIds];
        Assert.areEqual(200, taskCount, 'Each opportunity should get one task');
    }

    @IsTest
    static void doesNotCreateTasksForOtherStageChanges() {
        List<Opportunity> opps = buildOpps(2);
        insert opps;

        opps[0].StageName = 'Qualification';
        update opps;

        Set<Id> oppIds = new Map<Id, Opportunity>(opps).keySet();
        Integer taskCount = [SELECT COUNT() FROM Task WHERE WhatId IN :oppIds];
        Assert.areEqual(0, taskCount, 'Only Closed Won should create tasks');
    }

The first test checks 200 records in one transaction. The second checks that a different stage change creates nothing, which guards against the logic firing too often.

Running as another user. System.runAs(user) runs a block as a specific user, so you can test sharing and permissions. Create a user with a profile query and unique values, and wrap only the code you need in runAs. Remember that test data you create is still rolled back.

Step 5: run the tests and read coverage

Run the tests

  1. 1Save the test class in the Developer Console.
  2. 2Choose Test > New Run. Select OpportunityTriggerTest under Test Classes and move the methods across to Selected Tests.
  3. 3Click Run. A Tests panel appears at the bottom. A green tick means passed, a red cross means failed; click a failed test to see its message.
  4. 4Look at the Overall Code Coverage panel in the Tests tab. It lists each class and trigger with its percentage.
  5. 5Double-click OpportunityTriggerHandler in that list to open it with covered lines highlighted in blue and uncovered lines in red.
  6. 6In Setup you can also open Apex Test Execution (Quick Find) to run all tests and see results. In VS Code use the SFDX: Run Apex Tests command or sf apex run test --class-names OpportunityTriggerTest --result-format human --code-coverage --wait 10.
What you should see
The Tests panel shows 5 of 5 passed for OpportunityTriggerTest. Coverage for OpportunityTriggerHandler is 100%, and OpportunityTrigger is covered too.

If a line shows red, ask: which behaviour does that line implement? Write a test for the behaviour rather than just touching the line. Chasing 75% by calling methods with no assertions satisfies the rule but not the purpose.

Test data factories

When many test classes need accounts and opportunities, repeat yourself less with one shared @IsTest utility class, often named TestDataFactory. Keep it small: methods that return built (and optionally inserted) records with valid defaults. When a required field is added to an object later, you fix it in one place.

TestDataFactory.cls
@IsTest
public class TestDataFactory {
    public static Account createAccount(String name, Boolean doInsert) {
        Account a = new Account(Name = name);
        if (doInsert) {
            insert a;
        }
        return a;
    }
}

Because the class is marked @IsTest it cannot be used by production code, which is intended.

Never use @IsTest(SeeAllData=true) unless you truly must (a few Salesforce features need it). Tests that depend on whatever is in an org pass in one sandbox and fail in another.

Testing callouts. Tests cannot make real HTTP calls. You write a class implementing HttpCalloutMock that returns a fake response, and tell the test about it with Test.setMock(HttpCalloutMock.class, new MyMock());. You will use this in *Integration: REST APIs and platform events*.

How this comes up in interviews. Common questions: what is the minimum coverage and what else is required (75% overall, all tests pass, triggers need some coverage); what @TestSetup does; why use startTest and stopTest; what SeeAllData does and why avoid it; how to test a trigger with 200 records; how to test a callout; and how System.runAs works.

Prompt · Write tests for my class
You are a Salesforce developer who writes high-quality Apex tests. Write a test class for the Apex class below. Use @TestSetup for shared data, Assert class methods with messages, one happy path, one negative path and one bulk test with 200 records, and Test.startTest/stopTest around the code under test. Do not use SeeAllData.

[paste Apex class or trigger]

What to expect: A good answer builds its own data with all required fields, asserts actual outcomes (not only that no error occurred) and includes the 200-record test. Run it in your org: AI-written tests often miss a required field or a validation rule.

Try it: Test a new rule

Add a test for the exercise in the triggers lesson (Closed Lost needs a Description). Write one test that proves a Closed Lost opportunity with a blank Description is rejected, and one that proves it saves when Description is filled in. Then run the whole class and check coverage of the handler.

Key takeaways

  1. Production deployment needs at least 75% Apex coverage, every trigger covered, and all tests passing; aim for meaningful assertions, not just coverage.
  2. Tests create their own data (@TestSetup, helper methods, a factory) and never rely on org data; changes roll back automatically.
  3. Arrange, act, assert: wrap the code under test in Test.startTest and Test.stopTest and check results with the Assert class.
  4. Test the error path (Database.insert with false, or try/catch with Assert.fail) and a 200-record bulk case.
  5. Run tests from the Developer Console, Setup or the CLI, and use the coverage view to find behaviour you have not tested.

Check yourself

Finishing the quiz marks the lesson as done.

1. Which combination is required to deploy Apex to production?

2. What do Test.startTest() and Test.stopTest() do?

3. Why is a test with a try/catch that asserts nothing a problem?

4. How should a test get its data?

5. What does the 200-record test prove about a trigger?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free