Salesforce Development: Apex

Salesforce Developer Tutorial – The Complete Guide to Apex Tests in 2024

5 min read

Subscribe on YouTube

Why Bother Testing At All?

Let’s deal with the obvious objection first, because everybody has it: I already wrote the Apex, it took ages, why do I now have to write more code to test the code?

Fair. Here’s the honest answer.

Yes, Salesforce forces you to. You need 75% code coverage to deploy to production, so at some level this isn’t optional. But coverage is the least interesting reason.

The real reason is that good tests are the most freeing thing a developer can have. On any platform, not just this one.

Think about what it’s like to change code you don’t trust. You’re careful. You’re slow. You test manually. You leave the ugly bits alone because you can’t be sure what depends on them, and the org gets worse over time as everybody routes around the scary parts.

Now think about changing code with a test suite you trust. You change it, run the tests, and you know within a minute whether you broke anything. That’s not a safety net, it’s permission to move fast.

That’s what you’re buying. The coverage number is just the receipt.


Your First Test Class

Apex
@IsTest
private class AccountServiceTest
{
    @IsTest
    static void itSetsTheDescriptionOnInsert()
    {
        //Arrange
        Account acct = new Account(Name = 'Test Account');

        //Act
        Test.startTest();
        insert acct;
        Test.stopTest();

        //Assert
        Account result = [SELECT Id, Description FROM Account WHERE Id = :acct.Id];
        System.assertEquals('Processed', result.Description,
                            'Description should have been set by the trigger');
    }
}

Three things to note.

@IsTest on the class and on each method. Test classes should be private and don’t count against your org’s code limits.

Arrange, Act, Assert. Set up your data, do the thing, check the result. Structure every test this way and they stay readable.

The assert has a message. Please do this. When a test fails in a CI run six months from now, “Assertion Failed” tells you nothing. “Description should have been set by the trigger” tells you everything.


Test.startTest() And Test.stopTest()

These do two genuinely important things and most people only know about one.

1. They reset your governor limits. Everything between them gets a fresh set. So your test data setup doesn’t eat into the limits available to the code you’re actually testing.

2. Async work executes at stopTest(). Queueable jobs, future methods, batch jobs. They queue up and only run when you call stopTest().

That second one catches everybody. Forget the wrapper and your async code never runs, your test passes, and you have zero coverage of it. Silently.


Unit Tests vs Integration Tests

Worth understanding, because most “unit tests” in Salesforce aren’t.

A unit test tests one piece of logic in isolation. No database, no callouts, fast.

An integration test tests several pieces working together, usually including the database. Slower, but proves the whole thing actually works.

Nearly every Salesforce test that does DML is an integration test. That’s not wrong, and it’s often what you want, but you should know which you’re writing. A test suite made entirely of integration tests takes twenty minutes to run and nobody runs it locally.

Writing genuine unit tests means structuring code so the database is swappable, which brings us straight back to the Dependency Inversion Principle. That’s the actual payoff of all that interface work.


Test Data Setup

Apex
@IsTest
private class AccountServiceTest
{
    @TestSetup
    static void setupData()
    {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < 200; i++)
        {
            accounts.add(new Account(Name = 'Test Account ' + i));
        }
        insert accounts;
    }

    @IsTest
    static void itHandlesBulkRecords()
    {
        List<Account> accounts = [SELECT Id, Name FROM Account];
        //...
    }
}

@TestSetup runs once, and its data is rolled back and restored fresh for every test method. Much faster than recreating data in each one.

Note the 200 records. Test with realistic volume. Your trigger works fine with one Account and falls over the limits at 200, and the whole point of bulkification is to catch that before production does.


Testing As A Specific User

Code
System.runAs(testUser)
{
    Test.startTest();
    new AccountService().doSomething();
    Test.stopTest();
}

By default tests run in system context and ignore sharing entirely. Which means your with sharing class isn’t actually being tested for sharing at all.

System.runAs() fixes that, and it’s how you prove your security model works rather than assuming it does.


Testing Exceptions

Apex
@IsTest
static void itRejectsNegativeAmounts()
{
    Boolean threw = false;

    try
    {
        new CommissionCalculator().calculate(-100);
    }
    catch (IllegalArgumentException e)
    {
        threw = true;
        System.assertEquals('Amount must be positive', e.getMessage(),
                            'Wrong error message');
    }

    System.assert(threw, 'An exception should have been thrown');
}

That final assert matters. Without it, if the exception is never thrown, your test passes happily having tested nothing at all.

And please catch the specific exception type, not Exception. Catching everything means a completely unrelated NullPointerException would make this test pass.


Testing Callouts

Tests cannot make real HTTP callouts. You mock them:

Apex
private class MockResponse implements HttpCalloutMock
{
    public HttpResponse respond(HttpRequest req)
    {
        HttpResponse res = new HttpResponse();
        res.setStatusCode(200);
        res.setBody('{"success":true}');
        return res;
    }
}

@IsTest
static void itHandlesTheCallout()
{
    Test.setMock(HttpCalloutMock.class, new MockResponse());
    //...
}

Write a mock for the failure case too. Everybody tests the 200 and nobody tests the 500, which is exactly the path that breaks in production.


Coverage Is A Terrible Goal

Time for the uncomfortable bit.

This gets you 100% coverage:

Apex
@IsTest
static void terribleTest()
{
    new AccountService().processEverything();
    //No assertions. Passes forever. Tests nothing.
}

It executes every line. It verifies nothing. It will pass even if the method starts deleting your entire database.

There is a genuinely alarming amount of Apex in production orgs that looks exactly like this, written to clear the 75% gate.

A test without an assertion is not a test. It’s a very slow way of confirming your code compiles.

What actually makes a good test:

Assert real outcomes. Not that it ran, but that it did the right thing.
Test the edges. Nulls, empty lists, zero, 200 records, invalid input.
Test failure paths. What happens when the callout fails?
One concept per test. A failing test should point at one thing.
Name them descriptively. itRejectsNegativeAmounts beats testMethod2.


A Few More Habits

Use @IsTest(SeeAllData=false), which is the default. Never turn it on. Tests that depend on org data break the moment somebody changes that data.

Create your own test data. A shared TestDataFactory class saves enormous duplication.

When you fix a bug, write the test that would have caught it. You already understand the problem completely. Ten minutes locks it in permanently.

Full details in the Apex testing documentation. For LWC, I’ve got a complete guide to Jest testing as well.


Get Coding With The Force Merch!!

We now have a redbubble store setup so you can buy cool Coding With The Force merchandise! Please check it out! Every purchase goes to supporting the blog and YouTube channel.

Get Shirts Here!
Get Cups, Artwork, Coffee Cups, Bags, Masks and more here!


Check Out More Coding With The Force Stuff!

If you liked this post make sure to follow us on all our social media outlets to stay as up to date as possible with everything!

Youtube
Patreon
Github
Facebook
Twitter
Instagram


Salesforce Development Books I Recommend

Advanced Apex Programming
Salesforce Lightning Platform Enterprise Architecture
Mastering Salesforce DevOps

Good Non-SF Specific Development Books:

Clean Code
Clean Architecture