All posts

@isTest doesn't protect you from Type.forName

Alwin van Dijken · 5 October 2026 · 5 min read

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

  • @isTest blocks 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.