LearnFrenzy
Sign in Take a free test

Testing Apex · Lesson 33 of 41

Apex Test Classes: Writing Real Unit Tests

6 min read Updated Nov 8, 2026 Saurabh Samir

Salesforce requires at least 75% code coverage to deploy Apex to production. That rule is why every developer writes tests – but coverage only proves that lines ran, not that they did the right thing. Good tests check behaviour: the right values, the right errors, the right results with 200 records. This lesson shows how to write those tests.

In this lesson

  • Anatomy of a test class
  • The Assert class
  • Test data, @TestSetup and isolation
  • startTest/stopTest, runAs
  • Positive, negative and bulk tests – and coverage

The code under test

// DiscountService.cls
public with sharing class DiscountService {
    public class DiscountException extends Exception {}

    public static final Decimal MAX_DISCOUNT = 0.30;

    // applies a discount percentage to open opportunities; returns the number changed
    public static Integer applyDiscount(List<Opportunity> opps, Decimal percent) {
        if (percent == null || percent <= 0 || percent > MAX_DISCOUNT) {
            throw new DiscountException('Discount must be between 0 and ' + (MAX_DISCOUNT * 100) + '%');
        }
        List<Opportunity> changed = new List<Opportunity>();
        for (Opportunity opp : opps) {
            if (opp.IsClosed || opp.Amount == null) continue;
            opp.Amount = (opp.Amount * (1 - percent)).setScale(2);
            changed.add(opp);
        }
        update changed;
        return changed.size();
    }
}

The test class

// DiscountServiceTest.cls
@IsTest
private class DiscountServiceTest {

    @TestSetup                                             // runs once; each test gets a fresh copy
    static void makeData() {
        insert new List<Opportunity>{
            new Opportunity(Name = 'Open', StageName = 'Prospecting', CloseDate = Date.today().addDays(30), Amount = 1000),
            new Opportunity(Name = 'Won', StageName = 'Closed Won', CloseDate = Date.today(), Amount = 1000),
            new Opportunity(Name = 'No amount', StageName = 'Prospecting', CloseDate = Date.today().addDays(30))
        };
    }

    @IsTest
    static void discountsOnlyOpenOpportunitiesWithAnAmount() {
        // Arrange
        List<Opportunity> opps = [SELECT Name, Amount, IsClosed FROM Opportunity];
        // Act
        Test.startTest();
        Integer changed = DiscountService.applyDiscount(opps, 0.10);
        Test.stopTest();
        // Assert
        Assert.areEqual(1, changed, 'only the open opportunity with an amount changes');
        Assert.areEqual(900.00, [SELECT Amount FROM Opportunity WHERE Name = 'Open'].Amount);
        Assert.areEqual(1000.00, [SELECT Amount FROM Opportunity WHERE Name = 'Won'].Amount, 'closed deals keep their amount');
    }

    @IsTest
    static void rejectsTooLargeDiscount() {
        try {
            DiscountService.applyDiscount([SELECT Amount, IsClosed FROM Opportunity], 0.5);
            Assert.fail('expected a DiscountException');
        } catch (DiscountService.DiscountException e) {
            Assert.isTrue(e.getMessage().contains('between 0 and 30'), e.getMessage());
        }
    }

    @IsTest
    static void handlesTwoHundredRecords() {
        List<Opportunity> many = new List<Opportunity>();
        for (Integer i = 0; i < 200; i++) {
            many.add(new Opportunity(Name = 'Bulk ' + i, StageName = 'Prospecting', CloseDate = Date.today().addDays(10), Amount = 100));
        }
        insert many;

        Test.startTest();
        Integer changed = DiscountService.applyDiscount([SELECT Amount, IsClosed FROM Opportunity WHERE Name LIKE 'Bulk%'], 0.25);
        Test.stopTest();

        Assert.areEqual(200, changed);
        Assert.areEqual(200, [SELECT COUNT() FROM Opportunity WHERE Name LIKE 'Bulk%' AND Amount = 75]);
    }
}

Test run

✓ DiscountServiceTest.discountsOnlyOpenOpportunitiesWithAnAmount
✓ DiscountServiceTest.handlesTwoHundredRecords
✓ DiscountServiceTest.rejectsTooLargeDiscount

Tests: 3 passed, 0 failed, 3 total
Coverage DiscountService: 100% (11/11 lines)
  • @IsTest on the class and each test method. Test classes are usually private and don't count toward your org's code size limit.
  • Structure every test as Arrange – Act – Assert.
  • Each test runs in its own transaction and everything it does is rolled back afterwards.
  • Three kinds of test cover most logic: the happy path, the error path, and a bulk test with 200 records.

The Assert class

MethodChecks
Assert.areEqual(expected, actual, msg)values are equal
Assert.areNotEqual(a, b, msg)values differ
Assert.isTrue(cond, msg) / isFalsea condition
Assert.isNull(v, msg) / isNotNullnull or not
Assert.isInstanceOfType(obj, Type, msg)runtime type
Assert.fail(msg)always fails – put it after code that should have thrown

The older System.assertEquals still works, but the Assert class reads better and puts the expected value first. Always add a message: when a test fails at 2 AM in a deployment, "expected 1 but got 0: only the open opportunity with an amount changes" saves real time.

Test data and isolation

  • Tests don't see org data by default (except setup data like users and profiles). Create what you need – this makes tests reliable in any org.
  • @TestSetup creates common records once; every test method gets its own fresh copy.
  • Never use @IsTest(SeeAllData=true) unless you truly must (a few objects can't be created in tests).
  • Don't hard-code record Ids – they differ between orgs.

startTest, stopTest and runAs

// LimitsAndUsersTest.cls
@IsTest
private class LimitsAndUsersTest {
    @IsTest
    static void startTestGivesFreshLimits() {
        for (Integer i = 0; i < 90; i++) Integer n = [SELECT COUNT() FROM Account];   // setup uses 90 queries
        Test.startTest();
        Assert.areEqual(0, Limits.getQueries(), 'the code under test gets a fresh set of limits');
        Test.stopTest();
    }

    @IsTest
    static void runAsChecksAnotherUsersAccess() {
        Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User'];
        User u = new User(Alias = 'stdu', Email = 'stdu@example.com', EmailEncodingKey = 'UTF-8', LastName = 'Std',
            LanguageLocaleKey = 'en_US', LocaleSidKey = 'en_US', ProfileId = p.Id, TimeZoneSidKey = 'Asia/Kolkata',
            Username = 'stdu' + System.now().getTime() + '@lf-example.com');
        insert u;
        System.runAs(u) {
            Assert.areEqual(u.Id, UserInfo.getUserId());
            Assert.isTrue(Schema.SObjectType.Account.isCreateable(), 'standard users can create accounts');
            Assert.isFalse(Schema.SObjectType.User.fields.ProfileId.isUpdateable(), 'but cannot change profiles');
        }
    }
}

Test run

✓ LimitsAndUsersTest.runAsChecksAnotherUsersAccess
✓ LimitsAndUsersTest.startTestGivesFreshLimits

Tests: 2 passed, 0 failed, 2 total
  • Test.startTest() gives the code between it and stopTest() its own fresh governor limits, separate from your setup code – so the test measures the code, not the setup.
  • Test.stopTest() also runs any queued async work (future, Queueable, Batch, Schedulable) synchronously.
  • System.runAs(user) runs code as another user – sharing and the user's permissions apply to with sharing and user-mode code inside.

Code coverage

The run above reports 100% coverage of DiscountService. Coverage is required (75% org-wide, and every trigger must have some), but treat it as a side effect of testing behaviour, not the goal. A test with no assertions can reach 100% coverage and prove nothing. In VS Code, "SFDX: Run Apex Tests" with coverage highlights uncovered lines in the editor; sf apex run test --code-coverage reports it in the terminal.

Practice

Try it yourself

  1. Add a test that a null percent throws a DiscountException.

    Show answer

    @IsTest static void rejectsNullPercent() { try { DiscountService.applyDiscount(new List<Opportunity>(), null); Assert.fail('expected exception'); } catch (DiscountService.DiscountException e) { Assert.isNotNull(e.getMessage()); } }

  2. A test passes in the sandbox but fails in production with "List has no rows". What's the likely cause?

    Show answer

    It relies on existing org data (SeeAllData or a hard-coded record) that isn't in production. Create all test data inside the test.

Quick check

What coverage does production deployment require?

75% overall, and every trigger must be covered.

Do tests see existing org records?

No (unless SeeAllData=true) – create test data in the test.

What two things does Test.stopTest() do?

Ends the fresh-limits block and runs queued asynchronous work.

Summary

  • Write tests that assert behaviour: happy path, error path, bulk.
  • Use the Assert class with messages; Arrange – Act – Assert.
  • Create data in @TestSetup or the test; never depend on org data.
  • Use startTest/stopTest for limits and async, runAs for users; coverage follows.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular