Apex Triggers — Part 3
Salesforce Apex Trigger Test Classes
Interview Questions
Writing real test classes for the triggers from Part 2 — mocking, bulk testing, assertions, and coverage strategy explained with working code.
12
Questions
4
Sections
Part 3
of 4
Free
No Paywall
🧪
Apex Trigger Test Classes
@isTest, bulk testing, mocking, and coverage strategy
Q1–Q12
Q1What is the @isTest annotation and why is it required? Basic
✅@isTest marks a class or method as test-only code — it never counts against your org's Apex code storage limit and can only be executed by the testing framework, never by regular application logic.
@isTest
private class ContactDuplicateTriggerTest {
@isTest
static void testDuplicateEmailBlocked() {
Contact con1 = new Contact(LastName='Smith', Email='a@test.com');
insert con1;
Contact con2 = new Contact(LastName='Jones', Email='a@test.com');
Database.SaveResult result = Database.insert(con2, false);
System.assert(!result.isSuccess(), 'Duplicate email should be blocked');
System.assert(result.getErrors()[0].getMessage().contains('Duplicate'),
'Error message should mention duplicate');
}
}🎯 Key Points for Interviewer
- 🔥 Test methods must be static and are typically void
- 💡 The class itself should also be marked private class unless it needs to be referenced elsewhere
- 💡 Test classes run in a completely isolated data context — no access to real org data unless explicitly created in the test
Say This in Interview
"@isTest marks a class or method as test-only code that doesn't count against Apex storage limits and can only run via the testing framework — test methods must be static, and the class is typically declared private."
Q2What is the minimum code coverage required to deploy Apex to Production, and how is it calculated? Basic
✅Salesforce requires a minimum of 75% code coverage org-wide (not per-class) before Apex can be deployed to Production. Coverage = (lines executed by tests) / (total executable lines) × 100.
🎯 Key Points for Interviewer
- 🔥 75% is org-wide average, but every individual trigger and class should still be well-tested — a few classes with 100% and others with 0% can still average 75%, but that's a bad practice
- 🔥 Coverage percentage alone proves nothing — a test can execute every line without a single meaningful assertion and still show 100% coverage
- 💡 Managed packages installed from AppExchange require their OWN 75%+ coverage set by the publisher — doesn't affect your org's number
Say This in Interview
"Salesforce requires 75% code coverage org-wide before deploying to Production — but I treat that as a floor, not a target, since coverage percentage alone doesn't prove the code actually works correctly."
Q3What do Test.startTest() and Test.stopTest() actually do, and why are they important? Intermediate
🎯Test.startTest() resets Governor Limits, giving the code under test a fresh limit budget separate from your setup code. Test.stopTest() forces any asynchronous Apex (Future, Queueable, Batch) enqueued inside the block to execute synchronously before the test continues.
@isTest
static void testOpportunityFollowUpTask() {
// Setup — doesn't count toward the fresh limits below
Account acc = new Account(Name='Test Account');
insert acc;
Test.startTest();
Opportunity opp = new Opportunity(
Name='Test Deal', AccountId=acc.Id,
StageName='Prospecting', CloseDate=Date.today().addMonths(1)
);
insert opp; // fires the after insert trigger
Test.stopTest(); // any @future/Queueable enqueued above runs NOW
List<Task> tasks = [SELECT Id, Subject FROM Task WHERE WhatId = :opp.Id];
System.assertEquals(1, tasks.size(), 'Follow-up task should be created');
}🎯 Key Points for Interviewer
- 🔥 Without Test.stopTest(), a @future or Queueable job enqueued during the test would NOT actually execute before your assertions run — so testing async code without it gives false results
- 💡 Everything before startTest() is treated as setup and doesn't consume the fresh governor limit budget
Say This in Interview
"Test.startTest() resets governor limits for the code under test, and Test.stopTest() forces any enqueued asynchronous Apex to run synchronously — without stopTest(), you can't properly assert on the results of Future or Queueable jobs."
Q4What is @testSetup and why use it instead of creating test data in every test method? Intermediate
🎯@testSetup marks a method that runs once before EVERY test method in the class, creating shared test data — each test method then gets its own independent copy since Salesforce rolls back the database between test methods.
@isTest
private class OpportunityLineItemHelperTest {
@testSetup
static void setupData() {
Account acc = new Account(Name='XYZ Test Account');
insert acc;
Opportunity opp = new Opportunity(
Name='XYZ Deal', AccountId=acc.Id,
StageName='Prospecting', CloseDate=Date.today().addMonths(1)
);
insert opp;
}
@isTest
static void testDefaultLineItemCreated() {
Opportunity opp = [SELECT Id FROM Opportunity LIMIT 1];
List<OpportunityLineItem> olis = [SELECT Id FROM OpportunityLineItem
WHERE OpportunityId = :opp.Id];
System.assertEquals(1, olis.size());
}
@isTest
static void testLineItemHasCorrectQuantity() {
Opportunity opp = [SELECT Id FROM Opportunity LIMIT 1];
OpportunityLineItem oli = [SELECT Quantity FROM OpportunityLineItem
WHERE OpportunityId = :opp.Id LIMIT 1];
System.assertEquals(1, oli.Quantity);
}
}🎯 Key Points for Interviewer
- 🔥 Runs once per test method, not once per class — each test method gets a fresh, isolated copy of the data since Salesforce automatically rolls back between methods
- 💡 Avoids duplicating setup code across every test method — keeps tests DRY and easier to maintain
Say This in Interview
"@testSetup creates shared test data that runs fresh before each individual test method, since Salesforce rolls back the database between methods — it keeps test classes DRY instead of duplicating setup code everywhere."
Q5Write a complete test class for the Contact duplicate-prevention trigger (checks Email and Phone). Intermediate
🎯Test both the positive case (duplicate correctly blocked) AND the negative case (unique contact saves fine) — plus the update scenario where a Contact shouldn't flag itself as its own duplicate.
@isTest
private class ContactDuplicateTriggerTest {
@isTest
static void testUniqueContactInsertsSuccessfully() {
Test.startTest();
Contact con = new Contact(LastName='Unique', Email='unique@test.com');
insert con;
Test.stopTest();
System.assertNotEquals(null, con.Id, 'Contact should insert successfully');
}
@isTest
static void testDuplicateEmailIsBlocked() {
Contact original = new Contact(LastName='Original', Email='dup@test.com');
insert original;
Test.startTest();
Contact duplicate = new Contact(LastName='Duplicate', Email='dup@test.com');
Database.SaveResult result = Database.insert(duplicate, false);
Test.stopTest();
System.assert(!result.isSuccess(), 'Duplicate email must be blocked');
}
@isTest
static void testUpdateDoesNotFlagItself() {
Contact con = new Contact(LastName='SelfTest', Email='self@test.com');
insert con;
Test.startTest();
con.Phone = '1234567890'; // Email unchanged — should NOT flag itself
update con;
Test.stopTest();
Contact updated = [SELECT Phone FROM Contact WHERE Id = :con.Id];
System.assertEquals('1234567890', updated.Phone,
'Update should succeed without falsely flagging itself as duplicate');
}
}🎯 Key Points for Interviewer
- 🔥 Three scenarios covered — positive (unique passes), negative (duplicate blocked), and the edge case (update doesn't self-flag) — this is what "fully tested" actually means, not just one happy-path test
- 💡 Using Database.insert(record, false) instead of plain insert lets the test catch the failure gracefully via SaveResult instead of throwing an unhandled DmlException
Say This in Interview
"I write three test methods for a duplicate-check trigger: unique record succeeds, duplicate record is blocked using Database.insert(record,false) to catch the error gracefully, and an update-without-changing-the-key-field scenario to confirm a record never falsely flags itself."
Q6How do you test that a trigger is properly bulkified and won't hit governor limits with 200 records? Advanced
⚠Insert exactly 200 records (Salesforce's trigger batch size) in a single DML statement inside Test.startTest()/stopTest() — if the trigger has SOQL or DML inside a loop, this will throw a LimitException that a 1-record test would never catch.
@isTest
private class OpportunityStageChangeTriggerTest {
@isTest
static void testBulkStageChangeDoesNotHitLimits() {
Account acc = new Account(Name='Bulk Test Account');
insert acc;
List<Opportunity> opps = new List<Opportunity>();
for (Integer i = 0; i < 200; i++) {
opps.add(new Opportunity(
Name = 'Bulk Opp ' + i,
AccountId = acc.Id,
StageName = 'Prospecting',
CloseDate = Date.today().addMonths(1)
));
}
insert opps;
Test.startTest();
for (Opportunity opp : opps) {
opp.StageName = 'Negotiation'; // triggers the after update logic
}
update opps; // all 200 update in ONE DML — this is the actual bulk test
Test.stopTest();
// If the trigger has SOQL/DML inside a loop, this update above
// would have already thrown a LimitException before reaching here.
System.assert(true, 'Bulk update of 200 records completed without hitting limits');
}
}🎯 Key Points for Interviewer
- 🔥 200 is the exact trigger batch size — a test with only 1-5 records will NEVER catch a non-bulkified trigger, since the bug only manifests at scale
- 💡 This is one of the most commonly skipped tests by junior developers — and one of the most commonly asked "gotcha" interview questions
Say This in Interview
"I test bulkification by inserting or updating exactly 200 records — Salesforce's trigger batch size — in a single DML statement. If the trigger has SOQL or DML inside a loop, this test throws a LimitException that a small 1-record test would never catch."
Q7How do you test that a trigger correctly throws an error and blocks a bad record (like the Closed Won lock trigger)? Intermediate
🎯Wrap the DML in a try-catch, assert that a DmlException was actually thrown, AND assert the error message contains the expected text — proving both that it failed and that it failed for the RIGHT reason.
@isTest
private class OpportunityLockTriggerTest {
@isTest
static void testClosedWonOpportunityCannotBeEdited() {
Account acc = new Account(Name='Lock Test');
insert acc;
Opportunity opp = new Opportunity(
Name='Locked Deal', AccountId=acc.Id,
StageName='Closed Won', CloseDate=Date.today()
);
insert opp;
Test.startTest();
Boolean exceptionThrown = false;
try {
opp.Amount = 99999;
update opp;
} catch (DmlException e) {
exceptionThrown = true;
System.assert(e.getMessage().contains('locked'),
'Error message should mention the record is locked');
}
Test.stopTest();
System.assert(exceptionThrown, 'Editing a Closed Won Opportunity should throw an error');
}
}🎯 Key Points for Interviewer
- 🔥 Never just check "an exception was thrown" — also assert on the message content, otherwise a completely unrelated error would make the test falsely pass
- 💡 A boolean flag pattern (
exceptionThrown = trueinside catch) confirms the exception actually fired, since an empty catch block with no assertion after it can silently hide a bug where the trigger never even ran
Say This in Interview
"I test negative scenarios by wrapping the DML in try-catch, setting a boolean flag inside the catch block, and asserting both that the exception was thrown AND that its message matches what I expect — never just checking that some exception occurred."
Q8How do you test a trigger that makes an HTTP callout (like the @future ERP notification from Part 2)? Advanced
⚠You can't make a real HTTP callout during a test — Salesforce blocks it. Implement the HttpCalloutMock interface to simulate the external system's response, then register it with Test.setMock() before triggering the callout.
@isTest
global class MockERPResponse implements HttpCalloutMock {
global HTTPResponse respond(HTTPRequest req) {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'application/json');
res.setBody('{"status":"success"}');
res.setStatusCode(200);
return res;
}
}
@isTest
private class AccountCalloutServiceTest {
@isTest
static void testAccountCalloutSendsSuccessfully() {
Test.setMock(HttpCalloutMock.class, new MockERPResponse());
Test.startTest();
Account acc = new Account(Name='Callout Test Account');
insert acc; // fires after insert trigger -> @future(callout=true)
Test.stopTest(); // forces the @future method to actually execute
// No assertion possible on the external system itself,
// but the test proves the callout code path runs without error.
System.assert(true, 'Future callout executed without exception');
}
}🎯 Key Points for Interviewer
- 🔥 HttpCalloutMock is Salesforce's built-in interface specifically for simulating external HTTP responses in tests — real callouts are always blocked in test context
- 💡 Test.setMock() must be called BEFORE Test.startTest() and the actual DML that triggers the callout
- 💡 Test.stopTest() is essential here — without it, the @future(callout=true) method never actually executes during the test
Say This in Interview
"I implement HttpCalloutMock to simulate the external system's response, register it with Test.setMock() before triggering the DML, and use Test.stopTest() to force the @future callout method to actually execute so I can verify the code path runs cleanly."
Q9How do you test a trigger that enqueues a Queueable Apex job? Advanced
⚠Same pattern as testing any async Apex — trigger the DML that enqueues the job inside Test.startTest()/stopTest(), and stopTest() forces the Queueable's execute() method to run synchronously before your assertions.
@isTest
private class AccountSyncQueueableTest {
@isTest
static void testQueueableJobSyncsAccount() {
Test.setMock(HttpCalloutMock.class, new MockERPResponse());
Account acc = new Account(Name='Queueable Test Account');
insert acc;
Test.startTest();
acc.Name = 'Updated Name';
update acc; // fires after update trigger -> System.enqueueJob(new AccountSyncQueueable)
Test.stopTest(); // Queueable.execute() runs now
// Verify AsyncApexJob completed (optional but useful)
AsyncApexJob job = [SELECT Status, NumberOfErrors FROM AsyncApexJob
WHERE JobType = 'Queueable' ORDER BY CreatedDate DESC LIMIT 1];
System.assertEquals('Completed', job.Status);
System.assertEquals(0, job.NumberOfErrors);
}
}🎯 Key Points for Interviewer
- 🔥 Querying AsyncApexJob after stopTest() is a great way to explicitly verify the job completed successfully rather than just assuming it did
- 💡 Only ONE level of Queueable chaining is allowed to execute within a single test's Test.stopTest() call — chained jobs beyond that won't run in test context
Say This in Interview
"I test Queueable-enqueuing triggers the same way as any async Apex — trigger the DML inside startTest/stopTest so the job executes synchronously — and I often query AsyncApexJob afterward to explicitly confirm it completed with zero errors."
Q10What is Test.isRunningTest() used for, and when should you use it in trigger/handler code? Intermediate
🎯Test.isRunningTest() returns true only when code is executing inside a test context — used sparingly to work around test-specific limitations, most commonly when querying the Standard Pricebook, which behaves differently in tests.
public class OpportunityLineItemHelper {
public static void insertDefaultLineItem(List<Opportunity> newOpps) {
// Standard Pricebook can't be queried directly in test context
Id stdPBId = Test.isRunningTest()
? Test.getStandardPricebookId()
: [SELECT Id FROM Pricebook2 WHERE IsStandard = true LIMIT 1].Id;
// ... rest of the logic
}
}🎯 Key Points for Interviewer
- 🔥 Use sparingly — if your production code has too many Test.isRunningTest() branches, it means your test is exercising DIFFERENT logic than production actually runs, which defeats the purpose of testing
- 💡 The Standard Pricebook scenario is the most legitimate, common use case — it's a genuine Salesforce platform limitation, not a workaround for lazy test data setup
Say This in Interview
"Test.isRunningTest() detects if code is running inside a test — I use it sparingly, mainly for legitimate platform limitations like accessing the Standard Pricebook, since overusing it means my tests aren't actually testing the real production code path."
Q11How do you test that a static boolean recursion guard actually prevents infinite trigger loops? Advanced
⚠Trigger the scenario that would normally cause recursion (like the duplicate Lead trigger from Part 2) and assert that ONLY the expected number of records were created — not more, which would indicate the guard failed and recursion happened.
@isTest
private class LeadDuplicateTriggerTest {
@isTest
static void testRecursionGuardPreventsInfiniteLoop() {
Test.startTest();
Lead original = new Lead(
LastName='Original', Company='Test Co', Status='Open'
);
insert original;
Test.stopTest();
// If the recursion guard failed, this would be much higher than 2
// (or the test would simply time out / hit CPU limit exception)
List<Lead> allLeads = [SELECT Id, LastName FROM Lead];
System.assertEquals(2, allLeads.size(),
'Should have exactly the original + one duplicate, not infinite copies');
}
}🎯 Key Points for Interviewer
- 🔥 If the recursion guard is broken, this test doesn't just fail an assertion — it typically fails with a CPU timeout or "Maximum trigger depth exceeded" error, which is itself proof of the bug
- 💡 Asserting an exact count (not just "greater than 1") is what catches this — a vague assertion like "at least 2 leads exist" wouldn't catch runaway recursion
Say This in Interview
"I test recursion guards by triggering the scenario and asserting an EXACT record count — if the guard is broken, either the assertion fails with too many records, or the test itself fails with a CPU timeout or max trigger depth error, both of which expose the bug."
Q12What's the difference between code coverage and meaningful test assertions — and why does it matter? Basic
✅Code coverage only measures which LINES executed during a test — it says nothing about whether the code produced the CORRECT result. A test with zero assertions can still show 100% coverage while proving absolutely nothing.
// ❌ "Passes" and shows coverage, but proves NOTHING
@isTest
static void testUselessCoverage() {
Account acc = new Account(Name='Test');
insert acc; // line executes = "covered", but no verification of behavior
}
// ✅ Actually verifies the trigger's behavior
@isTest
static void testMeaningfulAssertion() {
Account acc = new Account(Name='Test', BillingCity='Mumbai');
insert acc;
Account inserted = [SELECT ShippingCity FROM Account WHERE Id = :acc.Id];
System.assertEquals('Mumbai', inserted.ShippingCity,
'ShippingCity should auto-copy from BillingCity on insert');
}🎯 Key Points for Interviewer
- 🔥 This is a favorite senior-level interview question — interviewers want to hear you distinguish between "line executed" and "behavior verified"
- 💡 Good practice: every test method should have at least one System.assertEquals() or System.assert() checking the ACTUAL outcome against the EXPECTED outcome
Say This in Interview
"Code coverage only proves a line executed, not that it did the right thing — a test with zero assertions can hit 100% coverage while verifying nothing at all, which is why I always pair coverage with explicit System.assertEquals() checks against expected outcomes."
Want More Free Salesforce Interview Prep?
sfinterviewpro.com covers everything from Admin basics to Advanced Apex, LWC, Integration, Agentforce, Data Cloud, DevOps and scenario-based questions — all completely free, no signup required.
Visit sfinterviewpro.comTest yourself on this topic
2,244 practice MCQs across 27 quizzes — 5 quizzes free, no signup
RK
Written by
Rajnish Kumar
Salesforce Developer · Apex, LWC, Data Cloud & AI · Building SF Interview Pro
Keep Preparing
Practice with real people
Join the free Mock Interview Community — practice with peers, get honest feedback, and walk into your real interview confident.
Join the Community ↗