Apex Test Classes Tutorial

Salesforce Apex Test Classes —
Complete Tutorial & Best Practices

Everything you need to write great Apex tests: @isTest, test data factories, coverage requirements, assert methods, mocking callouts, testing async code, and the patterns that lead to passing deployments every time.

🕐 ~20 min read 💻 Apex Testing 👥 All experience levels
Advertisement

1 Why Test Classes Exist in Salesforce

Salesforce enforces automated testing as a platform-level gate. Before you can deploy any Apex code to a production org, the platform runs every test class in that org and verifies a minimum code-coverage threshold. This is not optional or advisory — the deployment pipeline literally refuses to proceed if the tests fail or coverage falls short.

Beyond the deployment gate, well-written tests serve as a safety net for future changes: they catch regressions before they reach users and document the intended behaviour of your code with executable precision. A test class that only chases coverage numbers without asserting anything meaningful is worse than no test at all — it gives a false sense of security.

75%
Minimum org-wide coverage to deploy to production
>0%
Every trigger must have at least one covering test
90%+
Recommended target for a comfortable buffer
💡
Key insight: The 75% is calculated across all Apex classes and triggers in your org combined, not per file. However, every trigger file must have at least some coverage — a trigger with zero lines covered will block your deployment even if the org average is 80%.

What counts as a covered line?

A line is counted as covered when a test execution causes that line to be evaluated — meaning the JVM actually executes it. Comments, blank lines, and class/method declaration lines themselves do not count toward the denominator. Only executable statements matter. You can view per-class coverage in Setup → Apex Test Execution → View Test Results, or via the Salesforce CLI with sf apex run test --code-coverage.

Scenario Coverage Required? Notes
Deploy Apex class to production 75% org-wide Combined average across all classes and triggers
Deploy Apex trigger to production Must have coverage At least one test must execute the trigger body
Deploy to sandbox Not enforced Tests still run but coverage gate does not block deployment
Scratch org / CI pipeline Configurable Use --code-coverage flag to enforce a threshold in CI
Managed package release 75% per class ISV packaging enforces 75% individually per class

2 The @isTest Annotation and Class Structure

The @isTest annotation tells the Apex compiler that the class (or method) is only for testing purposes. Test classes do not count against your org's Apex code size limit of 6 MB. They cannot be called from production code — the compiler enforces this. Individual test methods are identified by applying @isTest to the method itself (historically you could also use the testMethod keyword, but the annotation is the modern standard).

Apex — Minimal test class skeletonBest Practice
@isTest
private class AccountServiceTest {

    // Runs once, data is rolled back + re-snapshotted between each test method
    @testSetup
    static void setup() {
        Account acc = new Account(Name = 'Test Corp', Industry = 'Technology');
        insert acc;
    }

    @isTest
    static void testGetAccountName_returnsCorrectName() {
        Account acc = [SELECT Id, Name FROM Account WHERE Name = 'Test Corp' LIMIT 1];

        Test.startTest();
        String result = AccountService.getAccountName(acc.Id);
        Test.stopTest();

        System.assertEquals('Test Corp', result, 'Expected account name to match');
    }

    @isTest
    static void testGetAccountName_nullId_returnsNull() {
        Test.startTest();
        String result = AccountService.getAccountName(null);
        Test.stopTest();

        System.assertNull(result, 'Null Id should return null');
    }
}

Key rules for test classes

  • Test classes must be declared private (or public — both are allowed, but private is idiomatic since they are never called externally).
  • Every test method must be static void. They cannot accept parameters or return values.
  • A single test class can contain up to 500 test methods (practical limit imposed by the runner).
  • Test classes do not require a constructor. The test runner instantiates nothing — it calls each @isTest static void method directly.
  • You can nest private inner test helper classes inside a test class for grouping, but they cannot be @isTest themselves.
  • The @isTest(SeeAllData=true) parameter disables test data isolation — avoid it except for very specific legacy scenarios (e.g., testing against custom metadata or certain setup objects that cannot be created in test context).
⚠️
Avoid SeeAllData=true: Tests marked @isTest(SeeAllData=true) depend on real org data. They fail in orgs where that data does not exist (scratch orgs, new sandboxes), making CI unreliable. Always create explicit test data instead.

Naming conventions

Salesforce does not enforce a naming convention, but the community has converged on the following patterns:

  • Class name: ClassUnderTestTest or ClassUnderTest_Test — suffix with Test.
  • Method name: testMethodName_scenario_expectedOutcome — include the method being tested, the input scenario, and what you expect. For example: testGetAccountName_nullId_returnsNull.

Descriptive method names make it immediately obvious which scenario failed when you look at a test run report, without reading the code.

3 @testSetup — Shared Test Data

The @testSetup annotation marks a static void method that runs once before any @isTest methods execute. All records inserted in this method are committed to the test database, and then a snapshot is taken. Before each individual test method runs, the database is rolled back to that snapshot — so every test starts with an identical, clean set of data, without re-running the setup code.

This is a significant performance win for classes with many test methods that share common prerequisites (e.g., a standard Account, a set of Contacts, a price book). Without @testSetup, each test method would re-insert all that data, multiplying DML operations by the number of methods.

Apex — @testSetup with a Test Data FactoryRecommended Pattern
@isTest
private class OpportunityServiceTest {

    @testSetup
    static void setup() {
        // Use a factory so test data creation is centralised
        Account acc = TestDataFactory.createAccount('Acme Corp', true);
        List<Opportunity> opps = TestDataFactory.createOpportunities(acc.Id, 5, true);
    }

    @isTest
    static void testCloseOpportunity_setsStageToClosedWon() {
        // Query from the snapshot — fresh for this test
        Opportunity opp = [SELECT Id, StageName FROM Opportunity LIMIT 1];

        Test.startTest();
        OpportunityService.closeOpportunity(opp.Id);
        Test.stopTest();

        opp = [SELECT StageName FROM Opportunity WHERE Id = :opp.Id];
        System.assertEquals('Closed Won', opp.StageName, 'Stage should be Closed Won');
    }

    @isTest
    static void testGetOpenOpportunities_returnsOnlyOpen() {
        // This test also starts with the same fresh 5 Opportunities from @testSetup
        Test.startTest();
        List<Opportunity> results = OpportunityService.getOpenOpportunities();
        Test.stopTest();

        System.assertEquals(5, results.size(), 'Should return all 5 open opps');
    }
}
💡
@testSetup limitation: You cannot call Test.startTest() or Test.stopTest() inside a @testSetup method — that pair is only valid within individual @isTest test methods. Also, @testSetup cannot be used if the class has @isTest(SeeAllData=true).

When to use @testSetup vs. inline data creation

Use @testSetup when multiple test methods in the same class share the same prerequisite records. Use inline data creation (inside the test method itself) when the data setup is specific to that one test, or when you intentionally want different data for different tests. Mixing both is fine — @testSetup handles the shared baseline and each test method creates only what is unique to it.

Performance tip: A class with 20 test methods that all share a base Account and 100 Contacts will execute 1 DML setup instead of 20. On large test suites this compounds dramatically and can cut overall test execution time by 40-60%.

4 System.assert, assertEquals, assertNotEquals, and assertThrows

Assertions are the reason test classes exist. A test that inserts records and runs code but never asserts anything does not verify correctness — it only verifies that the code does not throw an unhandled exception. Every meaningful test method should contain at least one assertion that verifies the expected outcome.

In API version 58.0 (Summer '23), Salesforce introduced a modern assertion library under the Assert class. The classic System.assert* methods still work but the Assert class methods produce more readable failure messages.

Method (Classic) Method (Modern Assert class) What it checks
System.assert(condition) Assert.isTrue(condition) Condition is true
System.assert(!condition) Assert.isFalse(condition) Condition is false
System.assertEquals(expected, actual) Assert.areEqual(expected, actual) Expected equals actual
System.assertNotEquals(notExpected, actual) Assert.areNotEqual(notExpected, actual) Values are not equal
System.assertEquals(null, actual) Assert.isNull(actual) Value is null
System.assertNotEquals(null, actual) Assert.isNotNull(actual) Value is not null
n/a — use try/catch Assert.fail(message) Unconditionally fail the test
⚠️
Parameter order matters: For System.assertEquals, the first parameter is the expected value and the second is the actual value. Getting these backwards does not change whether the test passes or fails, but it makes the failure message misleading: it will say "Expected: [actual value], Actual: [expected value]" — confusing everyone who reads the report.

Testing for expected exceptions

When you expect your code to throw a specific exception under certain conditions, you need to verify that the exception is thrown — and that it carries the right message or type. The classic pattern uses a try/catch with a System.assert(false) call that only runs if no exception was thrown:

Apex — Testing for expected exceptionCorrect Pattern
@isTest
static void testProcessOrder_invalidAmount_throwsException() {
    Boolean exceptionThrown = false;
    String exceptionMessage = '';

    Test.startTest();
    try {
        OrderService.processOrder(null, -100);
    } catch (OrderService.InvalidOrderException e) {
        exceptionThrown = true;
        exceptionMessage = e.getMessage();
    }
    Test.stopTest();

    System.assert(exceptionThrown, 'Expected InvalidOrderException to be thrown');
    System.assert(
        exceptionMessage.contains('Amount must be positive'),
        'Exception message should describe the problem'
    );
}

Always include assertion messages

Every System.assertEquals and System.assert call accepts an optional third (or second) string message parameter. Always provide it. When a test fails in a CI pipeline at 2 AM, a message like 'Contact count should equal number of inserted records after trigger' is vastly more useful than the default 'Assertion failed'.

Advertisement

5 Test.startTest() and Test.stopTest() — Governor Limits & Async Flush

Test.startTest() and Test.stopTest() are two of the most important — and most misunderstood — methods in Apex testing. They serve two distinct, independent purposes that happen to be delivered by the same pair of calls.

Purpose 1: Governor limit reset

Salesforce governor limits apply to the entire test method execution. Code that runs before Test.startTest() — such as data setup that queries or performs DML — consumes governor limits. By calling Test.startTest(), you get a fresh, clean set of limits for the code under test. This means your setup code and your test subject each get their own bucket of SOQL queries, DML statements, CPU time, and so on.

100
SOQL queries per transaction (synchronous)
150
DML statements per transaction
10,000
DML rows per transaction
10 MB
Heap size limit (synchronous)
60 sec
CPU time limit (synchronous)
1
Allowed startTest/stopTest pair per test method

Purpose 2: Synchronous flush of async operations

When Test.stopTest() is called, the platform forces all asynchronous work that was enqueued after Test.startTest() to execute synchronously before stopTest() returns. This includes:

  • @future methods — normally run after the transaction completes
  • Queueable jobs — normally run in a separate asynchronous context
  • Database.executeBatch() — the batch job executes synchronously
  • System.schedule() — the scheduled job fires immediately

This is why you must always put your assertions after Test.stopTest() when you are testing async code. Before stopTest returns, the async work has not happened yet.

Apex — Correct structure with startTest/stopTestCorrect
@isTest
static void testSendWelcomeEmail_futureSentAfterInsert() {
    // Setup code — runs before startTest, consumes its own governor limit bucket
    Account acc = new Account(Name = 'Test Inc');
    insert acc;
    Contact con = new Contact(
        FirstName = 'Test', LastName = 'User',
        AccountId = acc.Id, Email = 'test@example.com'
    );
    insert con;

    Test.startTest();
    // Code under test — gets a fresh governor limit bucket
    // This method internally calls a @future method to send email
    ContactService.sendWelcomeEmail(con.Id);
    Test.stopTest();
    // stopTest() flushes the @future method synchronously before returning

    // Safe to assert here — the future method has already run
    Contact result = [SELECT Welcome_Email_Sent__c FROM Contact WHERE Id = :con.Id];
    System.assertEquals(true, result.Welcome_Email_Sent__c, 'Email sent flag should be set');
}
Apex — WRONG: asserting before stopTestBug
@isTest
static void testSendWelcomeEmail_WRONG() {
    Contact con = [SELECT Id FROM Contact LIMIT 1];

    Test.startTest();
    ContactService.sendWelcomeEmail(con.Id);

    // BUG: asserting HERE means the @future method has NOT run yet!
    // This assertion will always see the old value and may produce a false pass or false failure.
    Contact result = [SELECT Welcome_Email_Sent__c FROM Contact WHERE Id = :con.Id];
    System.assertEquals(true, result.Welcome_Email_Sent__c); // wrong placement!

    Test.stopTest();
}
🛑
Critical rule: You can only use one Test.startTest() / Test.stopTest() pair per test method. Calling startTest() a second time throws a runtime error: System.AsyncException: startTest/stopTest may only be used once per test method.

6 Test Data Factory Pattern

The Test Data Factory (also called a Test Utility class) is a dedicated Apex class, annotated with @isTest, that provides static methods for creating standard SObjects. Instead of duplicating Account or Contact creation code across hundreds of test methods, every test class calls the factory. When field requirements change (a new required field is added to Account), you update the factory in one place.

Factory methods typically accept a boolean doInsert parameter — callers that need the record in the database pass true; callers who want to set additional fields before inserting pass false and call insert themselves.

Apex — TestDataFactory utility classBest Practice
@isTest
public class TestDataFactory {

    public static Account createAccount(String name, Boolean doInsert) {
        Account acc = new Account(
            Name        = name,
            Industry    = 'Technology',
            Phone       = '555-0100',
            BillingCity = 'San Francisco'
        );
        if (doInsert) insert acc;
        return acc;
    }

    public static Contact createContact(Id accountId, String lastName, Boolean doInsert) {
        Contact con = new Contact(
            FirstName = 'Test',
            LastName  = lastName,
            AccountId = accountId,
            Email     = lastName.toLowerCase() + '@testexample.com'
        );
        if (doInsert) insert con;
        return con;
    }

    public static List<Opportunity> createOpportunities(
        Id accountId, Integer count, Boolean doInsert
    ) {
        List<Opportunity> opps = new List<Opportunity>();
        for (Integer i = 0; i < count; i++) {
            opps.add(new Opportunity(
                Name        = 'Test Opp ' + i,
                AccountId   = accountId,
                StageName   = 'Prospecting',
                CloseDate   = Date.today().addDays(30),
                Amount      = 1000 * (i + 1)
            ));
        }
        if (doInsert) insert opps;
        return opps;
    }

    public static User createStandardUser(String lastName) {
        Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
        User u = new User(
            LastName       = lastName,
            FirstName      = 'Test',
            Alias          = lastName.left(8),
            Email          = lastName.toLowerCase() + '@testexample.com',
            Username       = lastName.toLowerCase() + '@testexample.com.test',
            ProfileId      = p.Id,
            TimeZoneSidKey = 'America/Los_Angeles',
            LocaleSidKey   = 'en_US',
            EmailEncodingKey = 'UTF-8',
            LanguageLocaleKey = 'en_US'
        );
        insert u;
        return u;
    }
}

Benefits of the factory pattern

  • Single point of change: When a new required field is added to Account in your org, you update TestDataFactory.createAccount() once and every test class benefits.
  • Readability: Test methods focus on the scenario they are testing, not on the plumbing of building valid SObjects with all required fields populated.
  • Bulk testing: Factory methods that accept a count parameter make it easy to create large datasets for governor limit testing — insert 200 Contacts with a single call and verify that your trigger handles bulk operations correctly.
  • Flexibility: The doInsert flag lets callers customise the object before committing it to the database, while still benefiting from the factory's field defaults.
💡
Using @isTest on the factory class means it will not count toward your org's Apex code size limit, just like any other test class. Even though it is public (so other test classes can reference it), non-test code cannot call it — the compiler enforces this.

7 Mocking HTTP Callouts with HttpCalloutMock

Salesforce blocks real outbound HTTP callouts during test execution. If your Apex code calls an external REST API and you run a test without setting up a mock, you will get a runtime error: System.CalloutException: You have uncommitted work pending. Please commit or rollback before calling out. — or in many cases the more explicit: Callouts are not allowed from within test methods.

The solution is to implement the HttpCalloutMock interface and register it with Test.setMock(). The platform intercepts any HttpRequest sent during the test and routes it to your mock's respond() method instead of the real network.

Apex — HttpCalloutMock implementationCorrect Pattern
@isTest
global class MockWeatherApiResponse implements HttpCalloutMock {

    global HTTPResponse respond(HTTPRequest req) {
        // Inspect the request if needed — validate the endpoint, method, headers
        System.assert(
            req.getEndpoint().contains('api.weather.example.com'),
            'Wrong endpoint called'
        );
        System.assertEquals('GET', req.getMethod(), 'Expected GET request');

        // Build the fake response
        HttpResponse res = new HttpResponse();
        res.setHeader('Content-Type', 'application/json');
        res.setBody('{"temperature": 22, "condition": "Sunny", "city": "London"}');
        res.setStatusCode(200);
        return res;
    }
}
Apex — Registering the mock in a test methodCorrect
@isTest
static void testGetWeather_returnsTemperature() {
    // Register the mock BEFORE calling Test.startTest()
    Test.setMock(HttpCalloutMock.class, new MockWeatherApiResponse());

    Test.startTest();
    WeatherService.WeatherResult result = WeatherService.getWeather('London');
    Test.stopTest();

    System.assertEquals(22, result.temperature, 'Temperature should be 22');
    System.assertEquals('Sunny', result.condition, 'Condition should be Sunny');
}

Multi-callout mocking

If your code makes multiple callouts to different endpoints within a single transaction, you can register multiple mocks by using a single dispatcher mock that inspects the request URL and delegates to specialised inner mock instances:

Apex — Dispatcher mock for multiple endpoints
@isTest
global class MultiEndpointMock implements HttpCalloutMock {

    global HTTPResponse respond(HTTPRequest req) {
        String endpoint = req.getEndpoint();

        if (endpoint.contains('/weather')) {
            HTTPResponse res = new HTTPResponse();
            res.setStatusCode(200);
            res.setBody('{"temperature":18}');
            return res;
        } else if (endpoint.contains('/currency')) {
            HTTPResponse res = new HTTPResponse();
            res.setStatusCode(200);
            res.setBody('{"rate":1.25}');
            return res;
        }

        // Fail loudly for unexpected endpoints
        HTTPResponse err = new HTTPResponse();
        err.setStatusCode(404);
        err.setBody('Unexpected endpoint: ' + endpoint);
        return err;
    }
}

StaticResourceCalloutMock

For large or complex JSON/XML response bodies, you can store the payload in a Static Resource and use StaticResourceCalloutMock instead of embedding the string in Apex code. Create a Static Resource with your JSON body, then:

Apex — StaticResourceCalloutMock
@isTest
static void testGetProductCatalog_parsesAllProducts() {
    StaticResourceCalloutMock mock = new StaticResourceCalloutMock();
    mock.setStaticResource('MockProductCatalogResponse'); // Name of your Static Resource
    mock.setStatusCode(200);
    mock.setHeader('Content-Type', 'application/json');
    Test.setMock(HttpCalloutMock.class, mock);

    Test.startTest();
    List<Product2> products = ProductSyncService.syncFromApi();
    Test.stopTest();

    System.assert(products.size() > 0, 'Products should have been synced');
}
Mocking error responses: Always test your error-handling paths too. Create a separate mock that returns statusCode = 500 or a malformed body, and verify that your service layer handles the failure gracefully rather than throwing an uncaught exception.

8 Testing Async Apex — Future, Queueable, Batch, Scheduled

Async Apex — future methods, Queueable jobs, Batch Apex, and Scheduled Apex — all run outside the synchronous transaction context. In production, the platform queues them and executes them later. In test context, Test.stopTest() forces them to run synchronously so your assertions can verify their effects.

Testing @future methods

Apex — Testing a @future method
@isTest
static void testUpdateAccountRating_futureRunsOnStopTest() {
    Account acc = TestDataFactory.createAccount('Async Test Corp', true);

    Test.startTest();
    // AccountRatingService.recalculate() is a @future method
    AccountRatingService.recalculate(acc.Id);
    Test.stopTest();
    // By the time we reach here, the @future has executed

    Account updated = [SELECT Rating FROM Account WHERE Id = :acc.Id];
    System.assertEquals('Hot', updated.Rating, 'Rating should be recalculated to Hot');
}

Testing Queueable jobs

Apex — Testing a Queueable class
@isTest
static void testSyncContactsQueueable_processesBatch() {
    List<Contact> contacts = TestDataFactory.createContacts(50, true);
    List<Id> contactIds = new List<Id>();
    for (Contact c : contacts) contactIds.add(c.Id);

    Test.startTest();
    System.enqueueJob(new SyncContactsQueueable(contactIds));
    Test.stopTest();
    // stopTest() runs the queueable synchronously

    List<Contact> synced = [
        SELECT Sync_Status__c FROM Contact WHERE Id IN :contactIds
    ];
    for (Contact c : synced) {
        System.assertEquals('Synced', c.Sync_Status__c, 'Each contact should be marked Synced');
    }
}

Testing Batch Apex

Batch Apex is tested the same way — wrap Database.executeBatch() in the startTest/stopTest pair. One important constraint: the default batch size in test execution is 200 records regardless of the size you specify. You can override it by passing a second parameter to Database.executeBatch() during the test — but you cannot exceed 200 in test context.

Apex — Testing a Batch Apex class
@isTest
static void testContactCleanupBatch_archivesOldContacts() {
    // Create 100 contacts that are more than 2 years old
    List<Contact> oldContacts = new List<Contact>();
    for (Integer i = 0; i < 100; i++) {
        oldContacts.add(new Contact(
            LastName        = 'Old' + i,
            Last_Active__c  = Date.today().addDays(-800)
        ));
    }
    insert oldContacts;

    Test.startTest();
    Database.executeBatch(new ContactCleanupBatch(), 200);
    Test.stopTest();
    // All three methods (start, execute, finish) have run by here

    Integer archivedCount = [
        SELECT COUNT() FROM Contact WHERE Is_Archived__c = true
    ];
    System.assertEquals(100, archivedCount, 'All 100 old contacts should be archived');
}

Testing Scheduled Apex

Apex — Testing a Schedulable class
@isTest
static void testDailyReportScheduler_runsExecute() {
    Test.startTest();
    // Schedule the job — it will fire when stopTest() is called
    String cronExp = '0 0 8 * * ?'; // 8 AM every day
    String jobId = System.schedule(
        'Test Daily Report',
        cronExp,
        new DailyReportScheduler()
    );
    Test.stopTest();

    // Verify the job was scheduled (and executed)
    List<CronTrigger> triggers = [
        SELECT Id, CronExpression, State
        FROM CronTrigger
        WHERE Id = :jobId
    ];
    System.assertEquals(1, triggers.size(), 'Scheduled job should exist');
}
Async Type How to invoke in test Flushed by stopTest()? Special considerations
@future Call the static method normally Yes Cannot be called from another future method
Queueable System.enqueueJob(new MyJob()) Yes Chained queueables require separate test methods
Batch Apex Database.executeBatch(new MyBatch()) Yes Max batch size 200 in test; all 3 methods run synchronously
Scheduled Apex System.schedule('name', cron, new MyScheduler()) Yes CronTrigger records are queryable; always abort test jobs in cleanup
Platform Event subscriber Publish the event with EventBus.publish() Partial Use Test.enableChangeDataCapture() for CDC; delivery is not always synchronous
⚠️
Chained Queueable jobs in tests: When your Queueable enqueues another Queueable from within its execute() method, only the first job runs during Test.stopTest(). Chained jobs are not flushed. Test each job class independently, or restructure your code to allow testing without chaining.

Testing triggers

Triggers are exercised implicitly — you do not call the trigger directly. You perform DML operations (insert, update, delete, upsert) on the SObject the trigger fires on, and the platform fires the trigger as part of that DML. Your test should verify the state of records after the DML to confirm the trigger logic ran correctly.

Apex — Testing an Account trigger via DML
@isTest
static void testAccountTrigger_setsDefaultIndustryOnInsert() {
    // Account with no Industry set — trigger should default it
    Account acc = new Account(Name = 'No Industry Corp');

    Test.startTest();
    insert acc; // This fires the before insert trigger
    Test.stopTest();

    Account result = [SELECT Industry FROM Account WHERE Id = :acc.Id];
    System.assertEquals('Other', result.Industry, 'Trigger should default Industry to Other');
}

@isTest
static void testAccountTrigger_bulkInsert_200records() {
    // Always test bulk scenarios — triggers must handle 200 records
    List<Account> accs = new List<Account>();
    for (Integer i = 0; i < 200; i++) {
        accs.add(new Account(Name = 'Bulk Account ' + i));
    }

    Test.startTest();
    insert accs; // Fires trigger once with all 200 records in Trigger.new
    Test.stopTest();

    Integer countWithIndustry = [
        SELECT COUNT() FROM Account WHERE Industry = 'Other' AND Id IN :accs
    ];
    System.assertEquals(200, countWithIndustry, 'All 200 accounts should have default industry');
}
Always test bulk scenarios: Salesforce can fire a trigger with up to 200 records in a single batch (e.g., from a data loader operation or an API bulk insert). A trigger that works for 1 record but throws a governor limit exception for 200 is broken. Include a bulk test in every trigger test class.
Advertisement

Practice Writing Apex Test Classes

Sharpen your testing skills on real Apex problems. ApexArena gives you immediate feedback on your code, code coverage metrics, and best-practice hints — exactly like a real Salesforce development workflow.

Frequently Asked Questions

What is the minimum code coverage required to deploy Apex in Salesforce?
Salesforce requires at least 75% code coverage across all Apex classes and triggers in your org before you can deploy to production. Each individual class or trigger does not need to hit 75% — it is a combined average across your entire codebase. However, every trigger must have at least some coverage (at least one test must exercise each trigger). Best practice is to aim for 90%+ so you have a buffer against future changes.
What is the difference between Test.startTest() and Test.stopTest()?
Test.startTest() resets all governor limits — code after this call gets a fresh set of limits (100 SOQL queries, 150 DML statements, etc.). Test.stopTest() has two effects: it restores the original limits context, and it forces all asynchronous operations that were enqueued between startTest and stopTest (future methods, queueable jobs, batch jobs, scheduled jobs) to execute synchronously before the method returns. Always put your assertions after Test.stopTest() so async work has completed.
Should you use @testSetup in Apex test classes?
@testSetup marks a static void method that runs once before all @isTest methods in the class. Records created in @testSetup are rolled back and re-inserted (from a snapshot) for each test method, so each test gets a clean copy without re-running setup code. This dramatically speeds up tests that share common data. One constraint: @testSetup cannot call Test.startTest() — that is only valid inside @isTest methods.
How do you test Apex code that makes HTTP callouts?
You cannot make real HTTP callouts in a test — Salesforce blocks them. Instead, implement the HttpCalloutMock interface with a respond(HttpRequest req) method that returns a crafted HttpResponse. Register it with Test.setMock(HttpCalloutMock.class, new YourMock()) before calling Test.startTest(). The platform intercepts every callout your code makes during the test and routes it to your mock instead.
How do you test Apex code that queries data — should you use SeeAllData?
Avoid @isTest(SeeAllData=true). It makes tests depend on live org data, which means tests can pass in one org and fail in another, and they cannot run in scratch orgs. Instead, create your own test data inside the test method or in a @testSetup method. Use a Test Data Factory pattern — a utility class with static methods like createAccount() — to keep test data creation DRY and maintainable across your entire test suite.

More Salesforce Tutorials