If you are learning Apex tests and sometimes feel like Salesforce is throwing random annotations at you, I understand.
Really.
You open a test class and suddenly there is:
@IsTest
@TestSetup
@TestVisible
Then a few lines later:
Test.startTest();
Test.stopTest();
Then somebody talks about TestDataFactory, mocks,
System.runAs(), SeeAllData, code
coverage...
And at some point you are probably thinking:
Wait. I just wanted to test one Apex class. Why did this become a whole architecture lesson?
That feeling is normal.
The problem is that Apex testing is often taught as a list of rules.
“Use this annotation.”
“Create this method.”
“Reach 75% coverage.”
“Do not use SeeAllData.”
And when we learn like that, everything feels disconnected.
So I want to try something different here.
Instead of memorizing annotations, let’s build the idea together.
Because once you understand why these pieces exist, Apex testing becomes much easier to remember.
And honestly, much less scary.
Imagine that you just finished an Apex class.
Maybe something simple.
Maybe something like this:
public class AccountService {
public static void activateAccount(Id accountId) {
Account acc = [
SELECT Id, Active__c
FROM Account
WHERE Id = :accountId
];
acc.Active__c = true;
update acc;
}
}
You look at it and think:
Okay. This should work.
Salesforce looks back at you and says:
Great. Prove it.
And this is where testing begins.
But think about what we do next.
We create an Account that is not real.
We give it fake data.
We run the real code against that fake record.
Then we check if the result is correct.
So, in a way, we build a small fake world to test the real world.
I like to think about Apex tests this way because it makes many things easier to understand.
Your production code is the real city.
Your tests are the movie set.
Everything looks real enough for the scene to happen, but nobody actually lives there.
And now we can start talking about @IsTest.
“Okay,
@IsTest means this is a test class, right?”
Yes.
But there is one detail that surprises a lot of people.
A class marked with @IsTest does not need to contain an
actual test method.
Read that again because this is usually where the confusion starts.
You can have something like this:
@IsTest
public class TestUtils {
public static Account createAccount() {
Account acc = new Account(
Name = 'Test Account'
);
insert acc;
return acc;
}
}
And maybe your first reaction is:
But where is the test?
There is no test here.
And that is completely fine.
This class is not responsible for executing a test scenario.
It is responsible for helping another test build its scenario.
Think about the movie set again.
The actor is the test method.
The TestUtils class is the crew building the room before
the actor enters.
The crew is not the scene.
But without the crew, the scene may not happen.
That is the idea.
Now let’s look at a normal test without any helper.
@IsTest
private class AccountServiceTest {
@IsTest
static void shouldActivateAccount() {
Account acc = new Account(
Name = 'Test Account'
);
insert acc;
AccountService.activateAccount(acc.Id);
Account result = [
SELECT Active__c
FROM Account
WHERE Id = :acc.Id
];
System.assertEquals(
true,
result.Active__c
);
}
}
Nothing wrong here.
Actually, for a small project, this is perfectly fine.
But now imagine this is not your only test.
You have another test that needs an Account.
And another.
And another.
Then Contacts.
Then Opportunities.
Then Products.
Then Users.
Soon you start seeing the same code everywhere:
Account acc = new Account(
Name = 'Test Account'
);
insert acc;
And you think:
Fine. It is only three lines.
Until somebody changes something.
A new required field.
A Validation Rule.
A new business requirement.
Now twenty test classes fail for the same reason.
And suddenly those three lines do not feel so small anymore.
This is where TestDataFactory starts to make sense.
Why developers create a Test Data Factory
Instead of teaching every test how to create an Account, we can move that responsibility to one place.
For example:
@IsTest
public class TestDataFactory {
public static Account createAccount(
String name
) {
Account acc = new Account(
Name = name
);
insert acc;
return acc;
}
}
Now the test can say:
Account acc =
TestDataFactory.createAccount(
'Acme Test'
);
Much cleaner.
But more importantly, now the rule for creating a valid test Account is in one place.
If tomorrow something changes, you know where to look.
And this is where @IsTest on the class becomes really
useful.
You are telling Salesforce:
This code only exists to support tests.
That is different from normal production code.
And this separation is intentional.
Test code is not part of the real application behavior.
It is part of the structure we use to validate that behavior.
But there is a small trap here.
And if you work in a big org, you may have already seen it.
Somebody creates:
TestUtils
Then adds:
createAccount()
Then:
createContact()
createOpportunity()
createCase()
createProduct()
createOrder()
createQuote()
createUser()
createContract()
And six months later, TestUtils has 3,000 lines.
Now every test depends on it.
And changing one small thing feels dangerous.
So yes, reuse is good.
But one giant class for the whole org is not always the best idea.
Sometimes it is much healthier to have:
AccountTestFactory
ContactTestFactory
OpportunityTestFactory
UserTestFactory
Same idea.
Less chaos.
That is the part I wish people explained earlier when teaching Test Data Factory.
The goal is not:
Put everything in TestUtils.
The goal is:
Stop repeating test setup everywhere, but keep your test architecture maintainable.
“Wait. If I
have a Factory, why do I need @TestSetup?”
This question makes perfect sense.
Because both seem to solve the same problem.
Repeated setup.
But they solve it in different places.
Let me show you.
Imagine this test class:
@IsTest
private class AccountServiceTest {
@TestSetup
static void makeData() {
Account acc = new Account(
Name = 'Setup Account'
);
insert acc;
}
@IsTest
static void shouldActivateAccount() {
Account acc = [
SELECT Id
FROM Account
LIMIT 1
];
// test
}
@IsTest
static void shouldHandleAnotherScenario() {
Account acc = [
SELECT Id
FROM Account
LIMIT 1
];
// another test
}
}
Both test methods need an Account.
Instead of creating the same Account twice, we prepare it once using
@TestSetup.
So here is the easiest way I know to remember the difference.
Test Data Factory helps different test classes reuse data creation logic.
@TestSetup helps methods inside
the same test class share common starting data.
That is it.
They are not competitors.
Actually, they work very well together.
You can write:
@TestSetup
static void makeData() {
TestDataFactory.createAccount(
'Setup Account'
);
}
Now the Factory knows how to create the Account.
And @TestSetup decides when this test class
needs that Account.
Once you see it this way, it becomes much easier.
Now
let’s talk about @TestVisible, because this one causes a
different kind of confusion
Everything we talked about until now belongs to the test world.
TestUtils.
Factories.
@TestSetup.
Now imagine this production class:
public class PriceCalculator {
private static Decimal applyDiscount(
Decimal price,
Decimal percentage
) {
return price -
(price * percentage);
}
}
The method is private.
So another class cannot normally access it.
But maybe your test really needs direct access to this member.
Apex gives us:
@TestVisible
So we can write:
public class PriceCalculator {
@TestVisible
private static Decimal applyDiscount(
Decimal price,
Decimal percentage
) {
return price -
(price * percentage);
}
}
Now your test can access it.
But please keep one thing very clear.
This method is still production code.
@TestVisible does not move it into the test world.
It only gives tests special access.
Think about a backstage door.
The public cannot enter.
The technical team has a pass.
That is all.
And here is where I want you to be a little suspicious.
Not of @TestVisible itself.
It is a valid feature.
Be suspicious when you start needing it everywhere.
Imagine your production class looks like this:
public class AccountController {
@TestVisible
private static Account createFakeAccountForTests() {
Account acc = new Account(
Name = 'Fake'
);
insert acc;
return acc;
}
}
Look at that method name:
createFakeAccountForTests
That method exists only because the test needs data.
So why is it living inside production code?
This is probably a better place:
@IsTest
public class AccountTestFactory {
}
A question that helps a lot is:
If my tests disappeared tomorrow, would this method still have a real business reason to exist?
If the answer is no, it probably belongs in test code.
Simple question.
Very useful answer.
“But what if I just want to test a private method?”
You can use @TestVisible.
But before doing that, ask yourself something else.
Do I really need to test this private method directly?
Look at this:
public class PriceCalculator {
public static Decimal calculateFinalPrice(
Decimal price
) {
return applyDiscount(
price,
0.10
);
}
private static Decimal applyDiscount(
Decimal price,
Decimal percentage
) {
return price -
(price * percentage);
}
}
You could simply test:
Decimal result =
PriceCalculator.calculateFinalPrice(
100
);
System.assertEquals(
90,
result
);
You did not call applyDiscount() directly.
But its behavior was still tested through the public method.
And this is often better.
Why?
Because tomorrow you may completely change the private implementation.
Maybe applyDiscount() disappears.
Maybe you create DiscountService.
Maybe the calculation moves somewhere else.
If the public result is still correct, your test should probably still pass.
That is one of the differences between testing behavior and testing implementation details.
And that small difference becomes very important in large projects.
Now we have data. We have helpers. We have our scenario. Time to run the real code.
This is where:
Test.startTest();
Test.stopTest();
enters the conversation.
If you are new to Apex testing, you may have copied these lines many times without really knowing why.
I did not want to leave them as “just put this around your method”.
There is a real reason.
Imagine you need to create a lot of data before your actual test begins.
Maybe:
Account
Contacts
Product
Pricebook
Opportunity
User
All of that work uses Salesforce resources.
Queries happen.
DML happens.
Governor Limits are being used.
But that is not really the code you want to measure.
It is just the setup.
So we can organize the test like this:
Account acc =
TestDataFactory.createAccount(
'Test Account'
);
Test.startTest();
AccountService.activateAccount(
acc.Id
);
Test.stopTest();
Now we have a clear line.
Before startTest():
I am preparing the world.
Between startTest() and stopTest():
This is the behavior I actually want to evaluate.
After stopTest():
Now I check what happened.
That is a much easier way to remember it.
You may also know this structure as:
Arrange
Act
Assert
In Apex, it often looks like this:
@IsTest
static void shouldActivateAccount() {
// Arrange
Account acc =
TestDataFactory.createAccount(
'Inactive Account'
);
// Act
Test.startTest();
AccountService.activateAccount(
acc.Id
);
Test.stopTest();
// Assert
Account result = [
SELECT Active__c
FROM Account
WHERE Id = :acc.Id
];
System.assertEquals(
true,
result.Active__c
);
}
Now the test reads like a small story.
And good tests usually do.
Test.stopTest()
becomes even more important with asynchronous Apex
Imagine your code does this:
System.enqueueJob(
new AccountProcessor(
accountId
)
);
That Queueable is asynchronous.
In normal execution, it runs later.
But your test cannot just say:
Okay, maybe the Queueable will finish someday.
You need a result now.
So:
Test.startTest();
System.enqueueJob(
new AccountProcessor(
acc.Id
)
);
Test.stopTest();
Then you can check the result.
Account result = [
SELECT Processed__c
FROM Account
WHERE Id = :acc.Id
];
System.assertEquals(
true,
result.Processed__c
);
I like to think about Test.stopTest() here as the moment
when you tell the test environment:
Okay, finish the async work I need for this scenario. I want to see the result.
It is not exactly “time travel”, but it feels a little like it.
And it makes testing asynchronous code possible in a controlled way.
Then we have integrations. And this is where things get fun.
Imagine your Apex class calls an external API.
Maybe something like:
Http http = new Http();
HttpResponse response =
http.send(request);
Now think about what would happen if every test called the real API.
The API is down today?
Your test fails.
The password changed?
Your test fails.
The external team changed the response?
Your test fails.
The endpoint is slow?
Your test becomes slow.
And none of these problems necessarily mean your Apex code is wrong.
So we do something smarter.
We fake the external system.
For example:
@IsTest
global class CustomerApiMock
implements HttpCalloutMock {
global HTTPResponse respond(
HTTPRequest request
) {
HttpResponse response =
new HttpResponse();
response.setStatusCode(200);
response.setBody(
'{"status":"ok"}'
);
return response;
}
}
Then:
Test.setMock(
HttpCalloutMock.class,
new CustomerApiMock()
);
Now your code thinks it received an HTTP response.
But no real internet connection was needed.
We built a fake internet inside our test.
And that is actually very powerful.
Because now you can decide what the internet does.
Today:
200 OK
Next test:
401 Unauthorized
Another:
404 Not Found
Another:
500 Internal Server Error
Another with broken JSON.
Another with missing fields.
And that is important because your integration does not exist only when everything works perfectly.
The error path is part of the behavior too.
Testing only HTTP 200 is like testing an umbrella only when the weather is good.
It proves very little.
And what about different users?
This is where System.runAs() appears.
Maybe your code behaves differently depending on who is executing it.
Maybe record access matters.
Maybe sharing matters.
In that case, you can create a user and run part of the test like this:
System.runAs(restrictedUser) {
List<Account> accounts = [
SELECT Id
FROM Account
];
// assertions
}
Now the code runs under another user context.
This is useful, especially when your scenario depends on record sharing.
But I want to stop here for one second because this is another place where tutorials sometimes go too fast.
System.runAs() does not mean:
Test every security rule in Salesforce automatically.
Sharing, CRUD, FLS, user context, and security enforcement are related, but they are not exactly the same thing.
So ask yourself:
What exactly am I trying to prove in this test?
If the answer is record access or behavior under another user,
runAs() may be exactly what you need.
If the answer is field-level security, that is another conversation.
Do not let one tool carry responsibilities it does not have.
At this point you may be thinking: wow, this is much bigger than “write a test class”
Exactly.
Look at what we already have:
@IsTest
TestUtils
TestDataFactory
@TestSetup
@TestVisible
Test.startTest()
Test.stopTest()
HttpCalloutMock
Test.setMock()
System.runAs()
This is not just “coverage code”.
We are building a controlled environment where we can:
create data,
simulate users,
simulate APIs,
control the test execution context,
force errors,
test asynchronous behavior,
and validate results.
That is why I like the idea of a parallel world so much.
The real application is one world.
The tests build another world around it, just to ask:
Are you really working the way you should?
And now we need to talk about the number everybody remembers: 75%
If you work with Salesforce, sooner or later you hear:
Apex needs 75% coverage.
And yes, coverage requirements matter for deployments.
But this is where a lot of teams make a mistake.
They start writing tests for the number.
Not for the behavior.
Look at this:
@IsTest
static void coverageTest() {
Account acc = new Account(
Name = 'Test'
);
insert acc;
AccountService.activateAccount(
acc.Id
);
}
Maybe this executes many lines.
Coverage goes up.
Great.
But what did we prove?
Did the Account become active?
We do not know.
Did the method update the right record?
We do not know.
Did something else break?
We do not know.
The test only executed the code.
Now compare it with:
Account result = [
SELECT Active__c
FROM Account
WHERE Id = :acc.Id
];
System.assertEquals(
true,
result.Active__c
);
Now we are saying something.
We are saying:
After this action, I expect this result.
That is testing.
Coverage is important.
But coverage is a side effect of executing meaningful scenarios.
It should not be the whole purpose of the test.
I like to explain coverage with one question
Coverage asks:
Did the test pass through this line?
It does not automatically ask:
Was the result correct?
That is your assertion’s job.
Imagine:
if (customerType == 'Premium') {
applyPremiumDiscount();
} else {
applyStandardDiscount();
}
Maybe you covered both branches.
Good.
But was the Premium discount correct?
Was the Standard discount correct?
Those are different questions.
And this is why even 100% coverage does not automatically mean great tests.
You can execute everything and validate almost nothing.
“So what should I actually test?”
This depends on the business rule.
There is no universal number of test methods.
But I usually start thinking about questions like:
What happens when everything is correct?
That is your happy path.
Then:
What happens when the input is wrong?
Maybe null.
Maybe missing data.
Maybe an invalid status.
Then:
Can this code receive many records?
If yes, think about bulk behavior.
Then:
Does it call another system?
Test success and failure.
Then:
Does user context matter?
Maybe you need runAs().
Then:
Does it enqueue asynchronous processing?
Now startTest() and stopTest() become
important.
See what changed?
We are not asking:
How do I reach 75%?
We are asking:
What could happen in real life?
Coverage usually follows.
And please, let’s
talk about SeeAllData=true
Because this one is tempting.
Very tempting.
You see this:
@IsTest(SeeAllData=true)
and realize that the test can access data already inside the org.
And your brain immediately says:
Wait. Then why am I creating all these fake Accounts?
Fair question.
The org already has Accounts.
Contacts.
Products.
Users.
Why not use them?
Because now your test depends on a world that you do not control.
Imagine:
Account acc = [
SELECT Id
FROM Account
WHERE Name = 'Premium Customer'
LIMIT 1
];
Today, that Account exists.
The test passes.
Tomorrow, somebody changes the name.
The test fails.
Nothing changed in your Apex class.
Nothing changed in the business rule.
The test failed because the environment changed.
That is not the kind of confidence we want from automated tests.
Remember our movie set?
If the scene needs a red car, you do not say:
I hope there is a red car parked near the studio tomorrow.
You bring the car.
Same idea.
Create the data your test needs.
Control the scenario.
Keep it predictable.
A good test should feel like a conversation too
This is something I think is often ignored.
Test code is also code that people read.
Maybe you will read it again in six months.
Maybe another developer will.
Maybe somebody is debugging production and opens your test to understand the expected behavior.
Look at these method names:
test1()
test2()
testMethod()
They tell you nothing.
Now look at:
shouldActivateInactiveAccount()
or:
shouldThrowExceptionWhenAccountDoesNotExist()
Immediately, you understand something about the system.
Good tests are also documentation.
They tell the next developer:
This is what we expect to happen.
And honestly, future you will appreciate that too.
Do not test only when everything is beautiful
Real systems are not always beautiful.
Sometimes the Account does not exist.
Sometimes the API returns 500.
Sometimes the value is null.
Sometimes a list is empty.
Sometimes 200 records arrive at the same time.
Sometimes a user cannot access the record.
Sometimes the asynchronous job fails.
Those scenarios matter.
If your code contains:
if (accountId == null) {
throw new IllegalArgumentException(
'Account Id is required.'
);
}
that behavior deserves a test too.
Because the exception is not an accident.
It is part of the contract.
The system is saying:
If you give me invalid input, this is how I respond.
That is behavior.
And behavior is what we test.
Bulk testing is where Apex reminds us that Salesforce is Salesforce
This one matters a lot.
Code can work perfectly with one record and fail completely with two hundred.
For example:
for (Account acc : accounts) {
List<Contact> contacts = [
SELECT Id
FROM Contact
WHERE AccountId = :acc.Id
];
}
One Account?
Looks fine.
Many Accounts?
Now we have a problem.
This is why, when your code can process collections, you should ask:
Does this only work for my cute little one-record test?
Or:
Does it work when Salesforce sends real volume?
That question can save you from some very painful production bugs.
And sometimes the test is difficult because the production code is difficult
This is one of my favorite lessons from testing.
Imagine that to test one simple behavior you need to:
create fifteen records,
change private variables,
add @TestVisible everywhere,
mock three dependencies,
use Test.isRunningTest() in production,
and execute things in one very specific order.
At some point, you should ask:
Is my test difficult, or is my class doing too much?
Maybe the class is:
querying,
validating,
transforming,
saving,
calling APIs,
creating payloads,
checking permissions,
and enqueueing jobs.
All in one place.
Of course testing becomes painful.
Testability can tell you a lot about design.
Not always.
But often.
If a class is very hard to test, it may be telling you:
I have too many responsibilities.
That is worth listening to.
Test code can also become technical debt
Yes.
Test code too.
You may open an old project and find:
SeeAllData=true everywhere
One giant TestUtils.
Methods called test1, test2,
test3.
Mocks that only know how to return 200.
@TestVisible on half the class.
No bulk scenarios.
No meaningful assertions.
Copied setup everywhere.
Coverage may still look good.
But nobody trusts the tests.
And that is a serious problem.
Because the real value of a test suite is trust.
You change the code.
The tests pass.
You want to believe:
Good. The important behavior is still working.
If the test suite is fragile or meaningless, that confidence disappears.
So let’s put all the pieces together
If you see:
@IsTest
public class TestUtils {
}
think:
This class belongs to the test world.
If you see:
@IsTest
static void shouldDoSomething() {
}
think:
This is an actual test scenario.
If you see:
@TestSetup
think:
These are common starting data for this test class.
If you see:
@TestVisible
think:
This is still production code, but tests have special access.
If you see:
Test.startTest();
think:
Setup is done. Now the real action begins.
If you see:
Test.stopTest();
think:
The action is finished. Now I can validate the result, including relevant asynchronous work.
If you see:
Test.setMock(...)
think:
We are replacing a real external dependency with a controlled fake one.
If you see:
System.runAs(user)
think:
This scenario needs another user context.
And if you see:
@IsTest(SeeAllData=true)
maybe stop for a second and ask:
Why does this test need real org data?
Not “this is always wrong”.
Just:
Why?
That question can prevent many fragile tests.
Before you close the test class, ask yourself a few things
You do not need a giant checklist.
Just have a small conversation with your own test.
Does the method name explain what I am testing?
Do I control the data?
Am I checking the real result or only executing lines?
What happens when the input is wrong?
Could this code receive many records?
What happens if the external system fails?
Does user context matter?
Is async processing involved?
Am I using @TestVisible because I need it, or because
the design is fighting me?
Would this test still work if somebody deleted all normal business data from the sandbox?
Those questions are much more useful than:
Am I at 75% yet?
And now let’s go back to where we started
You wrote an Apex class.
Salesforce asked you to prove that it works.
So you built another world.
A world with fake Accounts.
Fake Contacts.
Fake Opportunities.
Fake API responses.
Temporary users.
Controlled errors.
Async jobs running inside a test scenario.
Data that disappears when the test ends.
No customer will ever see this world.
No business user will open Salesforce and say:
Wow, what a beautiful Test Data Factory.
They will never know it exists.
But that invisible structure gives you something very important.
Confidence.
Confidence to change a class.
Confidence to refactor.
Confidence to deploy.
Confidence that when one thing changes, another important behavior did not silently break.
And this is the part I wish somebody had explained to me before simply saying:
Salesforce requires 75% coverage.
Because yes, coverage matters.
Deployment rules matter.
But tests are bigger than that.
A good test suite keeps asking your application:
Are you still doing what you promised to do?
And every time the answer is yes, you gain a little more confidence to change the system again.
That is why Apex tests are not just code we write because Salesforce forces us to.
They are the invisible structure behind the code everyone else sees.
And when that invisible structure is strong, the rest of the application becomes much safer to change.
