Part 45: Apex Tests in Salesforce
Welcome back to the Salesforce series. If you have been following along through the Apex sections, you now know how to write classes, triggers, SOQL queries, and DML operations. You can build real functionality. But there is a part of Apex development that separates professionals from hobbyists, and that is testing.
Salesforce requires test classes before you can deploy code to production. Many developers treat this as an annoying checkbox. They write the bare minimum to hit the coverage threshold, deploy, and move on. This is a mistake. Test classes are not a tax on your time. They are the single most important safety net in your codebase.
This is Part 45 of the series (Topic 3, Section 9). We are going to cover everything about Apex testing — from why tests matter, to the minimum requirements, to building production-grade test suites with data factories, assertions, and a clear understanding of when to use unit tests versus integration tests. By the end, you will write tests that actually protect your code.
Why Test Classes are a Developer’s Best Friend
Most developers first encounter Apex test classes because Salesforce forces them to. You try to deploy a trigger to production, the deployment fails because you have zero percent code coverage, and you grudgingly write a test that creates a record and calls your code. You hit 75%, you deploy, and you never look at the test again.
This is the wrong way to think about testing. Let me reframe it.
Catching Bugs Before They Reach Users
The number one reason to write tests is to catch bugs before they reach production. When you write a test, you are simulating what happens when real users interact with your code. If your trigger is supposed to set a field value when a record is created, your test creates that record and checks whether the field was set. If someone later changes the trigger and breaks that behavior, the test fails. You catch the bug in a sandbox, not in production at 2 AM when a VP is trying to close a deal.
Documentation Through Tests
Good test classes serve as living documentation. When a new developer joins the team and needs to understand what the OpportunityTriggerHandler does, they can read the test class. Each test method describes a scenario: “when an opportunity is moved to Closed Won, the related account’s status should update to Active.” The test method name tells you the scenario, and the code shows you exactly how it works. Unlike comments, tests cannot become outdated without failing.
Confidence to Refactor
Without tests, refactoring is terrifying. You want to rewrite a messy method, but you have no way to verify that the new version behaves the same as the old one. With a comprehensive test suite, you refactor the method, run the tests, and if they all pass, you know the behavior is preserved. Tests give you the freedom to improve your code without fear.
Regression Testing
Every time you add a new feature, there is a risk that it breaks an existing feature. This is called a regression. A test suite that covers your existing features catches regressions automatically. You add your new feature, run all the tests, and if an old test fails, you know exactly what broke and where.
The Bottom Line
Test classes are not a deployment gate. They are a development tool. The best developers write tests before they write the code (a practice called Test-Driven Development or TDD). Even if you do not go that far, treat your tests as first-class code that deserves the same attention and care as your triggers and classes.
The Salesforce Minimum Requirements and Why They Aren’t Enough
Salesforce enforces a minimum of 75% overall code coverage before you can deploy Apex code to production. This means that across all your Apex classes and triggers, at least 75% of the executable lines must be exercised by test methods. Additionally, every trigger must have at least some coverage — you cannot have a trigger with zero percent coverage even if your overall average is above 75%.
Why 75% is Dangerously Low
Hitting 75% coverage means that 25% of your code is untested. That untested 25% is where bugs hide. Think about it — if you write a class with an if/else block and your test only covers the if branch, you have no idea whether the else branch works. And the else branch is often where the edge cases live.
public static void processOpportunity(Opportunity opp) {
if (opp.Amount > 10000) {
opp.Priority__c = 'High';
// The test covers this branch. Looks great.
} else {
opp.Priority__c = 'Standard';
// Nobody tested this. Is it even correct?
// What if there's a typo in the field name?
}
}
In this example, a test that only creates opportunities with amounts above 10,000 will cover the first branch and might give you 75% coverage. But you have no idea whether the else branch works.
Aim for 90% or Higher
Professional Salesforce teams aim for 90% coverage or higher. Some teams enforce 95%. The goal is not the number itself — it is the discipline. When you target 90%, you are forced to think about edge cases, error paths, and boundary conditions. These are the scenarios that cause production bugs.
Coverage Does Not Equal Quality
Here is the uncomfortable truth: you can hit 100% code coverage with a completely useless test. Watch:
@isTest
static void uselessTest() {
// This test calls the method but asserts nothing
MyClass.processOpportunity(new Opportunity(Amount = 50000));
// 100% coverage. Zero value.
// If the method stops working tomorrow, this test still passes.
}
This test exercises every line of the method, so Salesforce counts it as covered. But it has no assertions. It does not check whether the method actually did the right thing. If someone changes 'High' to 'Medium', this test will not catch it.
Test Behavior, Not Lines
The real goal is to test behavior. Every test method should answer a specific question: “Given this input, does the code produce the correct output?” If you focus on behavior, the coverage takes care of itself.
Creating a Simple Apex Test Class
Let us start with the basics. Here is a simple Apex class that we want to test:
public class OpportunityHelper {
public static void setPriority(List<Opportunity> opportunities) {
for (Opportunity opp : opportunities) {
if (opp.Amount != null && opp.Amount > 10000) {
opp.Priority__c = 'High';
} else {
opp.Priority__c = 'Standard';
}
}
}
}
And here is a basic test class for it:
@isTest
private class OpportunityHelperTest {
@isTest
static void testSetPriority_HighAmount() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 50000
);
List<Opportunity> opps = new List<Opportunity>{ opp };
OpportunityHelper.setPriority(opps);
System.assertEquals('High', opp.Priority__c,
'Opportunity with amount > 10000 should have High priority');
}
@isTest
static void testSetPriority_StandardAmount() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 5000
);
List<Opportunity> opps = new List<Opportunity>{ opp };
OpportunityHelper.setPriority(opps);
System.assertEquals('Standard', opp.Priority__c,
'Opportunity with amount <= 10000 should have Standard priority');
}
}
Key Elements
The @isTest annotation on the class: This tells Salesforce that this class contains test methods. Test classes do not count against your org’s Apex code storage limits. The class should be private because nothing outside the test framework needs to access it.
The @isTest annotation on each method: Each test method must be annotated with @isTest. The older testMethod keyword still works but is deprecated — do not use it in new code.
Naming conventions: The class name should match the class under test with Test appended: OpportunityHelper becomes OpportunityHelperTest. Test method names should describe the scenario being tested. Use a pattern like testMethodName_Scenario or methodName_givenCondition_expectedResult. The name should tell you what the test does without reading the code.
No @isTest(SeeAllData=true): By default, test methods cannot see existing org data. They start with an empty database. This is intentional and correct. We will discuss this in the next section.
The SeeAllData Decorator and Why You Should Never Use It
By default, Apex test methods run in isolation. They cannot see any records that exist in your org — no accounts, no contacts, no opportunities, nothing. When your test needs data, it must create that data explicitly within the test. This is called the test isolation principle.
You can override this behavior with the @isTest(SeeAllData=true) annotation:
@isTest(SeeAllData=true)
private class BadTestClass {
@isTest
static void testSomething() {
// This test can see all org data
Account acc = [SELECT Id, Name FROM Account LIMIT 1];
// What if there are no accounts? This test fails.
// What if the account has unexpected data? Unpredictable results.
}
}
Why This is Almost Always Wrong
When you use SeeAllData=true, your tests become coupled to the data in your org. Consider the problems:
Tests break when data changes. You write a test that queries for a specific account. Someone deletes that account. Your test fails. You did not change any code — the test just broke because org data changed.
Tests are not portable. You write tests in your developer sandbox where you have certain records. You try to run those tests in a different sandbox or during deployment. The records do not exist there. The tests fail.
Tests produce unpredictable results. Your test queries for accounts and expects one result. Someone loads 50,000 accounts into the sandbox. Your query returns more records than expected, and your test behaves differently.
You cannot control the test scenario. Good tests require precise control over the input data. If you rely on org data, you cannot guarantee the data matches the scenario you want to test.
The Exception
There are a few standard objects that cannot be created in test context without SeeAllData=true. The most common example is the standard Pricebook2. If your code works with price books, you may need SeeAllData=true to access the standard price book — or you can use Test.getStandardPricebookId() as a workaround:
@isTest
static void testWithPriceBook() {
Id standardPbId = Test.getStandardPricebookId();
// Use this ID without needing SeeAllData=true
}
If you find yourself needing SeeAllData=true for anything other than these rare standard object scenarios, step back and rethink your approach. Create your test data explicitly.
When to Use the TestSetup Method and Its Limitations
When multiple test methods in a class need the same data, you can avoid duplicating data creation code by using the @TestSetup method.
@isTest
private class AccountServiceTest {
@TestSetup
static void makeData() {
Account acc = new Account(Name = 'Test Account', Industry = 'Technology');
insert acc;
Contact con = new Contact(
FirstName = 'Test',
LastName = 'Contact',
AccountId = acc.Id
);
insert con;
}
@isTest
static void testGetAccountContacts() {
Account acc = [SELECT Id FROM Account WHERE Name = 'Test Account'];
List<Contact> contacts = AccountService.getContacts(acc.Id);
Assert.areEqual(1, contacts.size(), 'Should return one contact');
}
@isTest
static void testUpdateAccountIndustry() {
Account acc = [SELECT Id, Industry FROM Account WHERE Name = 'Test Account'];
AccountService.updateIndustry(acc.Id, 'Finance');
Account updated = [SELECT Industry FROM Account WHERE Id = :acc.Id];
Assert.areEqual('Finance', updated.Industry, 'Industry should be updated to Finance');
}
}
How It Works
The @TestSetup method runs once before the test methods execute. Salesforce creates the data, takes a snapshot of the database state, and then for each test method, it restores the database to that snapshot. This means each test method gets a fresh copy of the setup data. If testGetAccountContacts modifies the account, that modification is not visible to testUpdateAccountIndustry.
This behavior has two major benefits:
- Performance: The data is inserted once, not once per test method. If you have 20 test methods that all need the same 50 records,
@TestSetupinserts those 50 records once instead of 1,000 times. - Isolation: Each test method operates on its own copy of the data, so tests cannot interfere with each other.
Limitations
Cannot be used with SeeAllData=true. If your test class uses @isTest(SeeAllData=true), you cannot have a @TestSetup method. These two features are mutually exclusive.
Cannot return data. The @TestSetup method must be void. You cannot return a list of record IDs or a map of records. If your test methods need to reference the records created in setup, they must query for them.
Data is isolated per test method. While this is a benefit for isolation, it can be confusing. If you insert a record in @TestSetup, modify it in one test method, and then query it in another test method, the modification will not be there. Each method starts with the original setup data.
Only one per class. You can have exactly one @TestSetup method per test class.
Runs in its own transaction. The setup method runs in its own execution context. If it fails, none of the test methods run.
Test.startTest and Test.stopTest
One of the most important patterns in Apex testing is the use of Test.startTest() and Test.stopTest(). These two methods serve multiple purposes, and understanding them is essential for writing effective tests.
Resetting Governor Limits
Salesforce enforces governor limits on every transaction — limits on the number of SOQL queries, DML statements, CPU time, and more. When you create test data, you consume some of these limits. Test.startTest() resets the governor limits to their starting values, giving your code under test a fresh set of limits.
@isTest
static void testBulkProcessing() {
// Setup: create 200 accounts (this uses SOQL/DML limits)
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < 200; i++) {
accounts.add(new Account(Name = 'Test Account ' + i));
}
insert accounts;
// Reset governor limits
Test.startTest();
// Now the code under test gets a fresh set of limits
AccountProcessor.processAccounts(accounts);
Test.stopTest();
// Assert results here
}
Without Test.startTest(), the DML from your test data setup counts against the same limits as your code under test. This can cause tests to fail with limit exceptions even though the code would work fine in production.
Separating Setup from Execution
Beyond governor limits, Test.startTest() and Test.stopTest() create a clear visual separation in your test code. Everything before Test.startTest() is setup. Everything between startTest and stopTest is the code under test. Everything after stopTest is assertions. This pattern is sometimes called Arrange-Act-Assert:
@isTest
static void testOpportunityCreation() {
// ARRANGE: Set up test data
Account acc = new Account(Name = 'Test Account');
insert acc;
// ACT: Execute the code under test
Test.startTest();
Opportunity opp = OpportunityService.createDefaultOpportunity(acc.Id);
Test.stopTest();
// ASSERT: Verify results
Opportunity result = [SELECT Name, StageName, AccountId FROM Opportunity WHERE Id = :opp.Id];
Assert.areEqual(acc.Id, result.AccountId, 'Opportunity should be linked to the account');
Assert.areEqual('Prospecting', result.StageName, 'Default stage should be Prospecting');
}
Ensuring Asynchronous Code Executes
If the code under test fires asynchronous operations — @future methods, queueable jobs, batch jobs, or scheduled jobs — those operations do not execute immediately. They are placed in a queue. Test.stopTest() forces all asynchronous operations to execute synchronously, so you can assert on their results immediately after:
@isTest
static void testAsyncAccountUpdate() {
Account acc = new Account(Name = 'Test Account', Industry = 'Technology');
insert acc;
Test.startTest();
// This calls a @future method internally
AccountService.updateAccountAsync(acc.Id, 'Finance');
Test.stopTest();
// The @future method has now completed
Account updated = [SELECT Industry FROM Account WHERE Id = :acc.Id];
Assert.areEqual('Finance', updated.Industry, 'Industry should be updated by the async method');
}
Without Test.stopTest(), the @future method would never execute in the test context, and your assertion would fail.
Mixed DML Workaround
Salesforce does not allow you to perform DML on setup objects (like User) and non-setup objects (like Account) in the same transaction. This is called the mixed DML error. In tests, you can use Test.startTest() to create a separate execution context:
@isTest
static void testWithUser() {
Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User'];
User testUser = new User(
Alias = 'tuser',
Email = 'testuser@example.com',
EmailEncodingKey = 'UTF-8',
LastName = 'TestUser',
LanguageLocaleKey = 'en_US',
LocaleSidKey = 'en_US',
ProfileId = p.Id,
TimeZoneSidKey = 'America/New_York',
Username = 'testuser' + DateTime.now().getTime() + '@example.com'
);
insert testUser;
Test.startTest();
System.runAs(testUser) {
Account acc = new Account(Name = 'User Test Account');
insert acc;
Assert.isNotNull(acc.Id, 'Account should be created');
}
Test.stopTest();
}
Platform Events
If your code publishes platform events, Test.stopTest() alone does not deliver them. You need to explicitly call Test.getEventBus().deliver() to force delivery in the test context:
@isTest
static void testPlatformEventPublish() {
Test.startTest();
MyService.publishOrderEvent('ORD-001', 'Completed');
Test.getEventBus().deliver();
Test.stopTest();
// Assert that the platform event trigger processed the event
Order__c order = [SELECT Status__c FROM Order__c WHERE OrderNumber__c = 'ORD-001'];
Assert.areEqual('Completed', order.Status__c, 'Order status should be updated by event handler');
}
What are Assertions?
Assertions are the heart of any test. Without assertions, a test is just code that runs. It does not verify anything. An assertion checks that a condition is true, and if it is not, the test fails with an error message.
The Assert Class
Salesforce introduced the Assert class as the modern way to write assertions. It replaced the older System.assertEquals and System.assertNotEquals methods. Here are the key methods:
Assert.areEqual(expected, actual, message) — Checks that two values are equal.
Assert.areEqual('High', opp.Priority__c, 'Priority should be High for large deals');
Assert.areNotEqual(unexpected, actual, message) — Checks that two values are not equal.
Assert.areNotEqual(null, acc.Id, 'Account should have an Id after insert');
Assert.isTrue(condition, message) — Checks that a condition is true.
Assert.isTrue(contacts.size() > 0, 'There should be at least one contact');
Assert.isFalse(condition, message) — Checks that a condition is false.
Assert.isFalse(result.hasErrors(), 'The save operation should not have errors');
Assert.isNull(value, message) — Checks that a value is null.
Assert.isNull(deletedAccount, 'Account should be null after deletion');
Assert.isNotNull(value, message) — Checks that a value is not null.
Assert.isNotNull(opp.Id, 'Opportunity should have been inserted');
Assert.fail(message) — Forces the test to fail. Useful in exception testing.
try {
MyService.processInvalidData(null);
Assert.fail('Expected an exception but none was thrown');
} catch (MyService.InvalidDataException e) {
Assert.isTrue(e.getMessage().contains('cannot be null'),
'Exception message should mention null data');
}
The Deprecated System Methods
You will see these in older code:
// Old style (still works but deprecated)
System.assertEquals('High', opp.Priority__c);
System.assertNotEquals(null, acc.Id);
System.assert(contacts.size() > 0);
These still compile and run, but the Assert class is the recommended approach going forward. It provides better method names, more options, and clearer intent.
Always Include Custom Messages
Every assertion should include a message parameter. When a test fails, the message tells you what went wrong without having to read through the test code:
// Bad: no message
Assert.areEqual('High', opp.Priority__c);
// When this fails, you see: "Expected: High, Actual: Standard"
// But WHY was it supposed to be High?
// Good: descriptive message
Assert.areEqual('High', opp.Priority__c,
'Opportunities over $10,000 should have High priority');
// When this fails, you immediately understand the context
Asserting on Collections
When testing methods that return collections, assert on the size and the contents:
List<Contact> contacts = ContactService.getActiveContacts(acc.Id);
// Assert the size
Assert.areEqual(3, contacts.size(), 'Should return exactly 3 active contacts');
// Assert specific records are present
Set<String> lastNames = new Set<String>();
for (Contact c : contacts) {
lastNames.add(c.LastName);
}
Assert.isTrue(lastNames.contains('Smith'), 'Results should include Smith');
Assert.isTrue(lastNames.contains('Jones'), 'Results should include Jones');
Asserting Exceptions Were Thrown
When your code is supposed to throw an exception for invalid input, test that the exception actually occurs:
@isTest
static void testInvalidInput_ThrowsException() {
Boolean exceptionThrown = false;
String exceptionMessage;
try {
AccountService.createAccount(null);
Assert.fail('Should have thrown an IllegalArgumentException');
} catch (IllegalArgumentException e) {
exceptionThrown = true;
exceptionMessage = e.getMessage();
}
Assert.isTrue(exceptionThrown, 'An exception should have been thrown for null input');
Assert.isTrue(exceptionMessage.contains('Account name is required'),
'Exception message should describe the validation failure');
}
Using Data Factories to Create Test Data
As your test suite grows, you will find yourself creating the same records over and over. Every test that involves opportunities needs an account. Every test that involves cases needs a contact. Duplicating this setup code across dozens of test classes creates a maintenance nightmare. When a required field is added to the Account object, you have to update every single test class.
The solution is a data factory — a utility class dedicated to creating test data.
The TestDataFactory Pattern
@isTest
public class TestDataFactory {
public static Account createAccount(String name) {
return new Account(
Name = name,
Industry = 'Technology',
BillingCity = 'San Francisco',
BillingState = 'CA',
BillingCountry = 'US'
);
}
public static Account createAndInsertAccount(String name) {
Account acc = createAccount(name);
insert acc;
return acc;
}
public static Contact createContact(Id accountId, String firstName, String lastName) {
return new Contact(
AccountId = accountId,
FirstName = firstName,
LastName = lastName,
Email = firstName.toLowerCase() + '.' + lastName.toLowerCase() + '@example.com'
);
}
public static Contact createAndInsertContact(Id accountId, String firstName, String lastName) {
Contact con = createContact(accountId, firstName, lastName);
insert con;
return con;
}
public static Opportunity createOpportunity(Id accountId, String name, Decimal amount) {
return new Opportunity(
AccountId = accountId,
Name = name,
Amount = amount,
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30)
);
}
public static Opportunity createAndInsertOpportunity(Id accountId, String name, Decimal amount) {
Opportunity opp = createOpportunity(accountId, name, amount);
insert opp;
return opp;
}
public static List<Account> createAndInsertAccounts(Integer count) {
List<Account> accounts = new List<Account>();
for (Integer i = 0; i < count; i++) {
accounts.add(createAccount('Test Account ' + i));
}
insert accounts;
return accounts;
}
public static User createStandardUser() {
Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User' LIMIT 1];
return new User(
Alias = 'tuser',
Email = 'testuser@example.com',
EmailEncodingKey = 'UTF-8',
LastName = 'TestUser',
LanguageLocaleKey = 'en_US',
LocaleSidKey = 'en_US',
ProfileId = p.Id,
TimeZoneSidKey = 'America/New_York',
Username = 'testuser' + DateTime.now().getTime() + '@example.com'
);
}
}
Using the Factory in Tests
Now your test classes become clean and focused:
@isTest
private class OpportunityServiceTest {
@TestSetup
static void makeData() {
Account acc = TestDataFactory.createAndInsertAccount('Acme Corp');
TestDataFactory.createAndInsertOpportunity(acc.Id, 'Big Deal', 50000);
TestDataFactory.createAndInsertOpportunity(acc.Id, 'Small Deal', 5000);
}
@isTest
static void testGetHighValueOpportunities() {
Account acc = [SELECT Id FROM Account WHERE Name = 'Acme Corp'];
Test.startTest();
List<Opportunity> highValue = OpportunityService.getHighValueOpportunities(acc.Id);
Test.stopTest();
Assert.areEqual(1, highValue.size(), 'Should return only the high-value opportunity');
Assert.areEqual('Big Deal', highValue[0].Name, 'Should return Big Deal');
}
}
Default Values with Optional Overrides
A more advanced factory pattern provides sensible defaults while allowing tests to override specific fields:
public static Account createAccount(Map<String, Object> overrides) {
Map<String, Object> defaults = new Map<String, Object>{
'Name' => 'Test Account',
'Industry' => 'Technology',
'BillingCity' => 'San Francisco',
'BillingState' => 'CA',
'BillingCountry' => 'US'
};
if (overrides != null) {
defaults.putAll(overrides);
}
Account acc = new Account();
for (String field : defaults.keySet()) {
acc.put(field, defaults.get(field));
}
return acc;
}
Usage:
// Use all defaults
Account defaultAcc = TestDataFactory.createAccount(null);
// Override just the name and industry
Account customAcc = TestDataFactory.createAccount(new Map<String, Object>{
'Name' => 'Custom Account',
'Industry' => 'Finance'
});
The Builder Pattern
For teams that want maximum readability, the builder pattern is another approach:
public class AccountBuilder {
private Account record;
public AccountBuilder() {
record = new Account(
Name = 'Default Account',
Industry = 'Technology',
BillingCountry = 'US'
);
}
public AccountBuilder withName(String name) {
record.Name = name;
return this;
}
public AccountBuilder withIndustry(String industry) {
record.Industry = industry;
return this;
}
public Account build() {
return record;
}
public Account buildAndInsert() {
insert record;
return record;
}
}
Usage:
Account acc = new AccountBuilder()
.withName('Acme Corp')
.withIndustry('Finance')
.buildAndInsert();
The builder pattern reads like plain English and makes it obvious which fields are being customized.
Refactoring Our Simple Test Class
Let us revisit the simple test class from earlier and evolve it into a production-quality test class. Here is where we started:
Version 1: The Bare Minimum
@isTest
private class OpportunityHelperTest {
@isTest
static void testSetPriority_HighAmount() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 50000
);
List<Opportunity> opps = new List<Opportunity>{ opp };
OpportunityHelper.setPriority(opps);
System.assertEquals('High', opp.Priority__c,
'Opportunity with amount > 10000 should have High priority');
}
@isTest
static void testSetPriority_StandardAmount() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 5000
);
List<Opportunity> opps = new List<Opportunity>{ opp };
OpportunityHelper.setPriority(opps);
System.assertEquals('Standard', opp.Priority__c,
'Opportunity with amount <= 10000 should have Standard priority');
}
}
This works, but it has problems. It uses the deprecated System.assertEquals. It duplicates the opportunity creation logic. It does not test edge cases. It does not use Test.startTest/stopTest. Let us improve it step by step.
Version 2: Using the Assert Class and Adding Edge Cases
@isTest
private class OpportunityHelperTest {
@isTest
static void setPriority_amountAboveThreshold_setsHigh() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 50000
);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('High', opp.Priority__c,
'Amount above 10000 should result in High priority');
}
@isTest
static void setPriority_amountBelowThreshold_setsStandard() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 5000
);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('Standard', opp.Priority__c,
'Amount below 10000 should result in Standard priority');
}
@isTest
static void setPriority_amountExactlyAtThreshold_setsStandard() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = 10000
);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('Standard', opp.Priority__c,
'Amount exactly at 10000 should result in Standard priority (threshold is > not >=)');
}
@isTest
static void setPriority_nullAmount_setsStandard() {
Opportunity opp = new Opportunity(
Name = 'Test Opp',
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Amount = null
);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('Standard', opp.Priority__c,
'Null amount should result in Standard priority');
}
@isTest
static void setPriority_emptyList_noErrors() {
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>());
Test.stopTest();
// If we reach this point without an exception, the test passes
Assert.isTrue(true, 'Empty list should be handled without errors');
}
}
Better. We now test the boundary condition (exactly 10,000), the null case, and the empty list case. We use the modern Assert class. Method names describe the scenario and expected outcome. But we still have duplicate opportunity creation code.
Version 3: The Full Refactor with Data Factory and TestSetup
@isTest
private class OpportunityHelperTest {
@TestSetup
static void makeData() {
// Create opportunities for tests that need persisted records
Account acc = TestDataFactory.createAndInsertAccount('Test Account');
TestDataFactory.createAndInsertOpportunity(acc.Id, 'High Value Opp', 50000);
TestDataFactory.createAndInsertOpportunity(acc.Id, 'Low Value Opp', 5000);
TestDataFactory.createAndInsertOpportunity(acc.Id, 'Boundary Opp', 10000);
}
@isTest
static void setPriority_amountAboveThreshold_setsHigh() {
Opportunity opp = TestDataFactory.createOpportunity(null, 'Test Opp', 50000);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('High', opp.Priority__c,
'Amount above 10000 should result in High priority');
}
@isTest
static void setPriority_amountBelowThreshold_setsStandard() {
Opportunity opp = TestDataFactory.createOpportunity(null, 'Test Opp', 5000);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('Standard', opp.Priority__c,
'Amount at or below 10000 should result in Standard priority');
}
@isTest
static void setPriority_amountExactlyAtThreshold_setsStandard() {
Opportunity opp = TestDataFactory.createOpportunity(null, 'Test Opp', 10000);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('Standard', opp.Priority__c,
'Amount exactly at 10000 should result in Standard priority');
}
@isTest
static void setPriority_nullAmount_setsStandard() {
Opportunity opp = TestDataFactory.createOpportunity(null, 'Test Opp', null);
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Test.stopTest();
Assert.areEqual('Standard', opp.Priority__c,
'Null amount should default to Standard priority');
}
@isTest
static void setPriority_emptyList_handledGracefully() {
Test.startTest();
OpportunityHelper.setPriority(new List<Opportunity>());
Test.stopTest();
Assert.isTrue(true, 'Empty list should not throw an exception');
}
@isTest
static void setPriority_bulkData_allProcessedCorrectly() {
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
Decimal amount = (Math.mod(i, 2) == 0) ? 50000 : 5000;
opps.add(TestDataFactory.createOpportunity(null, 'Bulk Opp ' + i, amount));
}
Test.startTest();
OpportunityHelper.setPriority(opps);
Test.stopTest();
for (Integer i = 0; i < 200; i++) {
String expected = (Math.mod(i, 2) == 0) ? 'High' : 'Standard';
Assert.areEqual(expected, opps[i].Priority__c,
'Opp ' + i + ' should have ' + expected + ' priority');
}
}
}
This is a professional test class. It uses a data factory. It uses @TestSetup for tests that need persisted records. It tests edge cases. It tests bulk behavior with 200 records to ensure the code handles bulkification. Every assertion has a clear message. Method names describe the exact scenario.
What is an Integration Test?
An integration test verifies that multiple components work together correctly. In Salesforce, an integration test typically exercises the full stack: a DML operation fires a trigger, which calls a handler class, which calls a service class, which performs queries and DML, and the test verifies the final result in the database.
@isTest
private class OpportunityIntegrationTest {
@TestSetup
static void makeData() {
Account acc = TestDataFactory.createAndInsertAccount('Integration Test Account');
}
@isTest
static void insertHighValueOpportunity_triggerSetsPriorityAndCreatesTask() {
Account acc = [SELECT Id FROM Account WHERE Name = 'Integration Test Account'];
Opportunity opp = TestDataFactory.createOpportunity(acc.Id, 'Big Deal', 50000);
Test.startTest();
insert opp;
Test.stopTest();
// Verify the trigger set the priority
Opportunity updatedOpp = [
SELECT Priority__c
FROM Opportunity
WHERE Id = :opp.Id
];
Assert.areEqual('High', updatedOpp.Priority__c,
'Trigger should set priority to High for large opportunities');
// Verify the trigger created a follow-up task
List<Task> tasks = [
SELECT Subject, WhatId
FROM Task
WHERE WhatId = :opp.Id
];
Assert.areEqual(1, tasks.size(),
'Trigger should create one follow-up task for high-value opportunities');
Assert.isTrue(tasks[0].Subject.contains('Follow up'),
'Task subject should contain "Follow up"');
}
}
Characteristics of Integration Tests
Integration tests interact with the database. They insert, update, query, and delete real records in the test context. They exercise triggers, validation rules, workflow rules, and process automation. They verify the end-to-end behavior of a feature.
Integration tests are slower because they perform real DML operations. They can be more brittle because they depend on the org’s configuration — if someone adds a required field to Account, every integration test that creates an account without that field will break.
But integration tests catch problems that unit tests cannot. They verify that your trigger, handler, and service classes work together. They catch issues with field mappings, SOQL queries, and record relationships that only surface when code interacts with the real database.
What is a Unit Test?
A unit test verifies a single method or function in isolation. It does not interact with the database. It does not fire triggers. It tests one piece of logic with known inputs and verifies the outputs.
@isTest
private class OpportunityHelperUnitTest {
@isTest
static void setPriority_setsHighForLargeAmounts() {
// No DML. No database. Just testing the method's logic.
Opportunity opp = new Opportunity(Amount = 50000);
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Assert.areEqual('High', opp.Priority__c,
'Amounts above 10000 should be classified as High priority');
}
@isTest
static void setPriority_setsStandardForSmallAmounts() {
Opportunity opp = new Opportunity(Amount = 3000);
OpportunityHelper.setPriority(new List<Opportunity>{ opp });
Assert.areEqual('Standard', opp.Priority__c,
'Amounts at or below 10000 should be classified as Standard priority');
}
@isTest
static void calculateDiscount_goldTier_returns15Percent() {
Decimal discount = PricingService.calculateDiscount('Gold', 10000);
Assert.areEqual(0.15, discount, 'Gold tier should receive a 15% discount');
}
@isTest
static void calculateDiscount_silverTier_returns10Percent() {
Decimal discount = PricingService.calculateDiscount('Silver', 10000);
Assert.areEqual(0.10, discount, 'Silver tier should receive a 10% discount');
}
@isTest
static void calculateDiscount_unknownTier_returnsZero() {
Decimal discount = PricingService.calculateDiscount('Unknown', 10000);
Assert.areEqual(0, discount, 'Unknown tier should receive no discount');
}
}
Characteristics of Unit Tests
Unit tests are fast because they do not perform DML. They are reliable because they do not depend on org configuration or database state. They test one specific behavior and make it easy to pinpoint what broke when a test fails.
Unit tests are ideal for testing pure logic — calculations, string manipulations, data transformations, routing decisions, and validation checks. Anything where you give a method some input and check the output.
The limitation of unit tests is that they cannot verify database interactions. They cannot confirm that a SOQL query returns the right records or that a trigger fires correctly. That is what integration tests are for.
When to Use Unit Tests vs Integration Tests
The answer is simple: use both. They serve different purposes and catch different kinds of bugs.
Comparison Table
| Aspect | Unit Test | Integration Test |
|---|---|---|
| Scope | Single method or function | Multiple components working together |
| Database interaction | None — operates on in-memory objects | Performs real DML (insert, update, delete, query) |
| Speed | Very fast (milliseconds) | Slower (DML, triggers, and automation add time) |
| What it catches | Logic errors in individual methods | Wiring errors, field mapping issues, query problems |
| Reliability | Very stable — no external dependencies | Can break when org config changes (new required fields, validation rules) |
| When to use | Testing calculations, transformations, routing logic, validations | Testing triggers, end-to-end workflows, record creation flows |
| Isolation | Fully isolated from other components | Tests real interactions between components |
| Setup complexity | Minimal — just create in-memory objects | More setup — need to insert records, handle relationships |
| Debugging | Easy — failure points to the exact method | Harder — failure could be in any component in the chain |
| Example | Testing that a discount calculation returns the right percentage | Testing that creating an opportunity fires the trigger, sets the priority, and creates a follow-up task |
The Testing Pyramid
In software engineering, the testing pyramid is a model that recommends having many unit tests at the base, fewer integration tests in the middle, and even fewer end-to-end tests at the top.
/\
/ \
/ E2E\ ← Few: Full UI or API tests
/------\
/ Integ. \ ← Some: Test component interactions
/----------\
/ Unit Tests \ ← Many: Test individual methods
/--------------\
In Salesforce, this translates to:
- Many unit tests for your service classes, utility methods, helper classes, and any method that contains pure logic.
- Some integration tests for your triggers, handler classes, and any workflow that spans multiple classes and DML operations.
- A few end-to-end tests (if any) for critical business processes that need to verify the complete flow.
Both Are Needed
Do not fall into the trap of writing only one type. Teams that write only integration tests end up with slow test suites that are hard to debug. Teams that write only unit tests have good coverage metrics but miss bugs that only appear when components interact.
The best approach: write unit tests for your logic and integration tests for your wiring. When a method performs a calculation, write a unit test. When you need to verify that inserting a record triggers the right chain of events, write an integration test.
Putting It All Together
Here is a checklist for building a comprehensive test suite:
-
Every class has a corresponding test class.
MyServicehasMyServiceTest.MyTriggerHandlerhasMyTriggerHandlerTest. -
Every public method has at least one test. If a method has branching logic, each branch has its own test.
-
Edge cases are covered. Null values, empty lists, boundary conditions, maximum values, and error scenarios all have tests.
-
Bulk behavior is tested. At least one test per trigger-related class creates 200 records to verify bulkification works.
-
A
TestDataFactoryexists and is used consistently. All test data creation goes through the factory. No test class creates records from scratch. -
Test.startTest()andTest.stopTest()are used. Every test method that calls code under test wraps it in these methods. -
Every assertion has a message. No bare assertions. Every
Assert.areEqualcall includes a third parameter explaining what is being checked. -
Negative tests exist. Not just “does this work when everything is correct” but also “does this fail gracefully when the input is wrong.”
-
SeeAllData=trueis not used. If you find it in your codebase, refactor it out. -
Tests are independent. No test relies on the outcome of another test. Each test method can run in any order and produce the same result.
Summary
Test classes are not overhead. They are an investment. A comprehensive test suite lets you deploy with confidence, refactor without fear, and catch bugs before they reach your users.
We covered the Salesforce 75% minimum and why it is not enough. We walked through @isTest, @TestSetup, SeeAllData, Test.startTest/stopTest, and the full Assert class. We built a TestDataFactory to centralize test data creation. We refactored a simple test class into a professional one. And we explored the difference between unit tests and integration tests, understanding that both are essential.
The mark of a senior Salesforce developer is not just that they can write code that works. It is that they write tests that prove it works, and keep proving it works as the codebase evolves.
What is Next?
In Part 46, we will cover Static Resources, Custom Metadata, Custom Settings, and Labels in Apex. These are the configuration tools that let you build flexible, maintainable Apex code without hardcoding values. You will learn when to use each one, how to access them from Apex, and how they differ from each other. See you there.