← Back to Field Notes

Using AI to Generate Apex Test Classes: What Works and What Doesn't

·9 min read

Rohit, a contractor we'd brought on for a billing rebuild, handed me a pull request with 94% coverage on a new invoicing class. I was impressed for about ninety seconds. Then I actually read the tests. Every method executed, every branch ran, and not one assertion checked a number. He'd asked an AI to "write tests for this class," and it had done exactly that — code that touched every line and verified almost nothing. We caught it in review, but only because I'd been burned by the same trick before. A younger version of me would have merged it on the coverage figure alone and moved on.

That pull request is the whole problem with AI-generated Apex tests in one artefact. The tooling is genuinely good at the part that's easy to measure and genuinely blind to the part that matters. If you understand which is which, AI takes real work off your plate. If you don't, it hands you a false sense of safety with a green tick on top.

Coverage Is the Metric AI Optimises For, and That's the Problem

Salesforce won't let you deploy to production below 75% coverage. So coverage becomes the number everyone watches, the number in the deployment gate, the number a nervous project manager asks about on the Thursday before go-live. It's the most visible signal of test quality, and it's almost useless as one.

An AI asked to "write tests" reads that implicit objective perfectly. It generates code that runs your methods so the lines light up green. Executing a line and asserting that the line did the right thing are completely different activities, and only one of them is easy to automate from the code alone. The AI can see what your method does. It cannot see what your method was supposed to do, because that lives in an acceptance criterion, a business rule, or your head — not in the syntax.

This is the opinion I'll defend to anyone: a test that runs code without asserting behaviour is worse than no test, because it costs you coverage points that make the gap invisible. A red bar tells you something is missing. A green bar over hollow tests tells you nothing is missing when plenty is. AI, left to its defaults, produces the second kind at speed.

What AI Does Well: Scaffolding, Data, and Bulk

None of that means I write my tests by hand anymore. The plumbing of an Apex test class is repetitive, and repetitive is exactly where AI earns its keep.

Test data factories are the clearest win. Building valid Account, Contact, and Opportunity records with all the required fields populated is tedious and error-prone by hand, and AI gets it right the first time when it can read the object's schema.

@isTest
public class TestDataFactory {
    public static Account newAccount(String name) {
        return new Account(
            Name = name,
            AnnualRevenue = 500000,
            Industry = 'Technology'
        );
    }

    public static List<Account> newAccounts(Integer count) {
        List<Account> accounts = new List<Account>();
        for (Integer i = 0; i < count; i++) {
            accounts.add(newAccount('Test Account ' + i));
        }
        return accounts;
    }
}

The other reliable wins are structural. AI scaffolds the @isTest class, wraps the exercise in Test.startTest() and Test.stopTest(), and produces a bulk case with 200 records almost without being asked. That last one matters more than it looks — the single most common defect in hand-written Apex tests is the developer who tests one record and never checks that the code survives a batch. AI reaches for the bulk case by habit, which is more than I can say for a lot of humans.

When I'm scaffolding tests for a handler built on the trigger framework I use everywhere, the structure is so predictable that AI produces the skeleton in one pass and I spend my time on the logic inside. That's the right division of labour: it does the boilerplate, I do the thinking.

The Assertions Are Where It Falls Apart

Here's the test AI writes when you don't constrain it, and it's the same shape as the one Rohit shipped:

@isTest
static void testCalculateTier() {
    Account a = TestDataFactory.newAccount('Acme');
    insert a;

    Test.startTest();
    AccountService.calculateTier(new List<Id>{ a.Id });
    Test.stopTest();

    Account result = [SELECT Tier__c FROM Account WHERE Id = :a.Id];
    System.assertNotEquals(null, result.Tier__c);
}

Read that assertion carefully. It passes as long as the tier logic writes anything to the field. If calculateTier computes Bronze when the revenue clearly puts the account in Gold, the test is perfectly happy. The bug ships. Coverage says 100% on the method. This is the exact failure mode that makes AI test suites dangerous rather than merely useless — they're confidently wrong.

The version I'd actually merge asserts the specific expected value against a specific input:

@isTest
static void tierIsGoldWhenRevenueAboveThreshold() {
    Account a = TestDataFactory.newAccount('Acme');
    a.AnnualRevenue = 2500000;
    insert a;

    Test.startTest();
    AccountService.calculateTier(new List<Id>{ a.Id });
    Test.stopTest();

    Account result = [SELECT Tier__c FROM Account WHERE Id = :a.Id];
    System.assertEquals('Gold', result.Tier__c,
        'Revenue above 2M should map to the Gold tier');
}

The difference is that the second test knows the business rule. AI can't recover that rule from the method body — if the method has the rule wrong, the AI will faithfully assert the wrong behaviour, because the code is the only specification it has. The test patterns that actually catch bugs are all variations on this one idea: assert the value you expected before you looked at the implementation, not the value the implementation happens to produce.

Give It the Rules or It Invents Them

The fix is not to stop using AI. The fix is to stop asking it to guess the specification. When I give it the actual rules, the output changes completely. Compare "write tests for AccountService.calculateTier" with something that carries the intent:

"Write a test class for AccountService.calculateTier. The tier rules are: revenue under 250k is Bronze, 250k to 2M inclusive is Silver, above 2M is Gold, and null revenue leaves Tier__c blank. Write one method per band and assert the exact tier string. Add a bulk case that inserts 187 accounts and asserts fewer than 5 SOQL queries via Limits.getQueries(). Use TestDataFactory.newAccount."

That prompt produces tests I can ship, because every assertion in it came from me, not from the code under test. The AI is doing the typing; the correctness is still mine. This is the same discipline that runs through the whole AI-assisted Apex workflow I've written about — the model executes well against a specification and hallucinates when you make it supply one.

I keep the rules in the prompt, not in a comment I hope it reads. When the acceptance criteria live in a Jira ticket, I paste the relevant lines straight in. The five minutes it takes to translate a business rule into a sentence is the actual work of testing. Skipping it is how you end up with 94% coverage and a billing bug.

Negative Cases and Governor Limits Stay on Me

AI is optimistic. It writes the happy path enthusiastically and forgets the ways things break, because the breakage lives in production history, not in the code. The negative cases I care about most are the ones I've been paged for — the null that arrives from an integration, the picklist value someone added in production last quarter, the record that trips a validation rule three objects away.

The governor limit assertion is one I always add by hand or demand explicitly, because it's the assertion that protects you from the bulk regression nobody sees coming:

@isTest
static void tierRecalcDoesNotScaleQueriesWithVolume() {
    List<Account> accounts = TestDataFactory.newAccounts(187);
    insert accounts;

    Test.startTest();
    AccountService.calculateTier(new Map<Id, Account>(accounts).keySet());
    Integer queriesUsed = Limits.getQueries();
    Test.stopTest();

    System.assert(queriesUsed < 5,
        'Tier recalc should not run a query per record; used ' + queriesUsed);
}

An AI will happily insert 187 records for the bulk case. It will rarely assert against Limits.getQueries() unless told, because that assertion encodes a fear it doesn't have — the fear of the class that worked fine on one record and blew the SOQL limit the day someone loaded a real data file. I've had that day. The test exists because of it.

The Trade-Off Is Speed Now Against Vigilance Forever

Every hour AI saves me on scaffolding, it charges back in review attention — a trade worth naming plainly, not selling as a free win. On the benefit side, I write no boilerplate, my data factories come out correct first pass, and the bulk case shows up by default — across a recent package rebuild, roughly a 40% cut in the time to a first green run. On the cost side, I can never again glance at a coverage figure and trust it, because I no longer know whether a human reasoned about those assertions or a model pattern-matched them. The suite got faster to produce and slower to trust — and only the first shows on the deployment gate. For code I own and re-read, the trade is easily worth it. For a suite I inherit with an AI's fingerprints on it, the review cost can swallow the saving.

When I Don't Reach for AI at All

There's a category of test where I close the AI window entirely, and legacy code is most of it. When a class was written with no thought to testability — static state everywhere, hard-coded record IDs, a callout buried three methods deep with no seam to mock — the AI cannot write a meaningful test for it. It'll write something that compiles and covers the lines, and that something will be a monument to wasted effort. The honest move is to refactor for testability first, and only then generate. That's a slower loop, and no prompt shortcuts it.

The other exclusion is anything touching a security review. AI will add Security.stripInaccessible and isAccessible() checks that look correct, but the tests it writes to prove CRUD and FLS enforcement tend to assert that the code ran, not that access was actually denied to the profile that should have been blocked. For code headed to a package or a review team, I write those tests myself. The stakes are too specific to delegate to a model that's guessing at my org's permission model. If you want a second pair of eyes on a suite before it goes to review, that's the kind of review I do.

I've stopped writing test boilerplate by hand and I'm not going back. But I've also stopped trusting a coverage number the moment I know an AI produced it unsupervised. Green coverage tells you the code ran. A real assertion tells you it was right. AI gets me to the first in seconds — the second is still the job.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.