@isTest doesn't protect you from Type.forName
Most Apex developers believe that once a class is marked @isTest, production code can’t touch it. I believed it too. I tested it, and it isn’t true.
@isTest stops another class from referencing it in code. It does not stop Type.forName from finding it by name and creating a working instance. In a managed package, that gap matters.
Why it matters
I build second-generation managed packages. Several of them pick an implementation by class name: an admin configures a provider in Custom Metadata, and a factory loads it with Type.forName. It’s a common, clean way to let a customer swap behaviour without changing code.
In a 2GP package, the test classes ship with the package. In my repo, 47 test doubles across 8 packages were installed in every customer org. Each one implemented a real interface and returned a scripted, usually successful, answer.
Now picture a validation provider double configured by name instead of the real one. Every check passes. Nothing calls the outside service. Nobody notices until it matters.
The experiment
The obvious fix was to mark every double @isTest. Before rolling that out across eight packages, I ran a small experiment to prove it would work. Four classes:
| Class | Kind | Role |
|---|---|---|
ProbeApi |
interface | stands in for a package extension point |
ProbeResolver |
normal class | loads an implementation by name, like a production factory |
ProbeDouble |
top-level @IsTest |
a test double implementing the interface |
ProbeResolverTest |
@IsTest |
the tests, plus a public inner double |
The resolver is as plain as it gets:
public with sharing class ProbeResolver {
public static ProbeApi resolve(String className) {
Type t = Type.forName(className);
return t == null ? null : (ProbeApi) t.newInstance();
}
}
Then I ran it twice: once inside a test, and once from anonymous Apex, which runs the way production code does.
The result
Inside a test, both doubles resolved and worked through the interface. No surprise there.
Outside a test, they resolved too:
TOP=chef.ProbeDouble
INNER=chef.ProbeResolverTest.InnerDouble
RESOLVE=ProbeDouble:[]
Type.forName found the top-level @IsTest class and the inner class of a test class. The production-shaped resolver created a working instance of each.
What @isTest actually does. It blocks compile-time references from non-test code: write
new ProbeDouble()in a normal class and it won’t save. It does not hide the class from lookup by name. Good hygiene, but not a security boundary.
The fix: no named double at all
If a class can be found by name, the only safe double is one that has no name. The Stub API gives you exactly that. Test.createStub builds an instance at runtime with no class of its own, so there is nothing for Type.forName to find.
The rule in my packages is now simple: a test double for a production type is always a stub, never a named class that implements or extends it. A small shared MockProvider (a System.StubProvider) does the recording. When several tests need the same double, an @isTest builder wraps it with readable setup methods:
IValidationProvider provider = new ValidationProviderStub()
.withStatus('VALID')
.build();
One gotcha: Test.createStub must run in the same namespace as the type it stubs. Same namespace, not same package, so a shared helper in one package can still stub types from another package in your namespace.
Takeaways
@isTestblocks compile-time references, not lookup by name.- If your package loads classes with
Type.forName, every named double you ship is a class a customer can configure. - Build doubles with the Stub API. A stub has no name, so it can’t be loaded by one.
- Prove an assumption with a small experiment before you roll out a fix across a codebase. This one was wrong.