All insights

Platform

Apex integration tests: testing a real callout instead of a mock of one

For twenty years an Apex test could not make an HTTP callout, so everyone mocked. Winter '27's @IntegrationTest annotation lets a test hit a real endpoint. Here is what it changes, what it costs you in return, and where mocks still win.

Apex integration tests: testing a real callout instead of a mock of one, article illustration

Every Salesforce developer has written a test that proves nothing. You mock the callout, feed your code the response you already decided it should get, assert that it did what you told it to do, and the test goes green. The integration could be broken at the other end and the test would never know. It was testing your parsing, not your integration.

That was not laziness. Until Winter ‘27, an Apex test physically could not make an HTTP callout. The runtime threw You have uncommitted work pending or refused the callout outright, so Test.setMock was the only game in town. Now there is a second option, and knowing when to reach for it is the point of this post.

My take up front: keep mocking for your unit tests, all of them, and add a small number of @IntegrationTest classes that exercise the real endpoint in a scratch org, off the critical deploy path. The new feature is a scalpel, not a replacement for how you already test.

Why the mock was the only way

The platform rolls back everything a test does. That rollback is what lets thousands of tests run on every deploy without polluting your data. Callouts were banned for the same reason: a callout leaves the transaction, and the platform can’t roll back something that already left the building and hit someone’s server.

So you built a mock. You implement HttpCalloutMock, hand it to Test.setMock, and the runtime returns your canned HttpResponse instead of making the call.

@IsTest
private class OrderSyncTest {
    private class OrderSyncMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            res.setStatusCode(200);
            res.setBody('{"status":"accepted"}');
            return res;
        }
    }

    @IsTest
    static void pushesOrder() {
        Test.setMock(HttpCalloutMock.class, new OrderSyncMock());
        Test.startTest();
        HttpResponse res = OrderSyncService.push('001XXXXXXXXXXXX');
        Test.stopTest();
        Assert.areEqual(200, res.getStatusCode());
    }
}

The mocked test is fine as far as it goes. It proves OrderSyncService.push builds the request, reads a 200, and parses the body.

What it cannot prove is that the endpoint still exists, that its auth still works, that its contract still matches the JSON you hardcoded, or that a real 500 is handled. You wrote both sides of the conversation, and the mock will happily agree with a stale assumption forever.

What @IntegrationTest changes

Winter ‘27 introduces the @IntegrationTest annotation, in developer preview. A class or method carrying it becomes an integration test, in the same place a unit test would carry @IsTest, and it is allowed to make real HTTP callouts, including External Services and callouts through Named Credentials.

The trade for that ability is the rollback. Integration tests do not roll back. Data they commit stays committed, which is the only way a real endpoint can see it. So the lifecycle changes: you set up data in a @BeforeClass method that runs once and commits, and you clean it up in a @TearDown method that runs after every test whether it passed or failed, and also commits.

@IntegrationTest
private class OrderSyncIntegrationTest {
    @BeforeClass
    static void makeData() {
        insert new Account(Name = 'Integration Test Co', AccountNumber = 'IT-001');
    }

    @IntegrationTest
    static void syncsOrderToErp() {
        Account a = [SELECT Id FROM Account WHERE AccountNumber = 'IT-001' LIMIT 1];
        HttpResponse res = OrderSyncService.push(a.Id);   // real callout, no mock
        Assert.areEqual(200, res.getStatusCode());
    }

    @TearDown
    static void cleanUp() {
        delete [SELECT Id FROM Account WHERE AccountNumber = 'IT-001'];
    }
}

There is no Test.setMock and no Test.startTest. The callout in OrderSyncService.push leaves your org and hits the real system. The assertion is now meaningful: a broken endpoint, a changed contract, or expired credentials fails the test the way production would fail.

The @TearDown matters more than it looks. Because nothing rolls back, a test that inserts records and doesn’t delete them leaves them in the org. Forget the teardown and your scratch org fills with Integration Test Co accounts, and worse, a later test that assumes a clean slate starts finding rows it didn’t create.

The constraints that keep this off your deploy path

The annotation is not a drop-in for @IsTest, and the constraints are the reason.

It only runs where you enable it. Integration tests need the ApexIntegrationTests feature, which you add to the features array in your project-scratch-def.json. That feature is scratch-org and developer-preview scope, not something you switch on in production.

Only one integration test runs in an org at a time, and it runs asynchronously. There is no synchronous mode. So you cannot fan out a thousand integration tests the way you run unit tests in parallel on every deploy. The single-file limit is deliberate: real callouts are slow, rate-limited, and have side effects, and running them concurrently against one endpoint would be its own outage.

They don’t count as your unit-test coverage. Coverage still comes from your mocked @IsTest classes. Integration tests answer a different question, “does the real thing still work,” not “is this line exercised.”

Put together, the shape is clear. Unit tests with mocks stay as your fast, parallel, every-deploy safety net. Integration tests are a handful of slow checks you run deliberately, from Setup, the Developer Console, or the CLI, against a scratch org pointed at a sandbox endpoint.

Where this earns its keep

Two places, in my experience, are worth the setup.

The first is agent actions. An Agentforce Apex action that calls an external system is exactly the kind of code a mock lies about, because the whole risk is the live contract with the other system.

Summer ‘26 first allowed integration-test callouts scoped to Agentforce and Data 360, and Winter ‘27 widens that to External Services and Named Credential callouts generally. An integration test that runs the action’s Apex against the real endpoint catches the auth break or the schema drift a mocked agent test sails past.

The second is any integration you don’t own the other end of. A partner API, a payment gateway, an ERP that a different team ships on their own schedule. A weekly or pre-release integration-test run tells you their change broke your contract before a customer does. That check is the closest the platform has come to a real contract test, and it belongs in a scheduled pipeline, not in the gate that blocks every deploy.

What I would not do with it

I would not convert working mocked tests to integration tests. They are faster, they run in parallel, and they still prove your code’s logic. Rewriting them buys nothing and costs coverage stability.

I would not point an integration test at a production endpoint that has side effects, because there is no rollback and a payment gateway does not care that you were “just testing.” Use a sandbox endpoint or a provider test mode, and design the @TearDown before the test body.

And I would not treat this as GA. The feature is developer preview, which means the shape can change and it should not be load-bearing in your release process yet. Pilot it in a scratch org, learn the lifecycle, and keep an eye on the Winter ‘27 notes as it matures. The async execution model it runs under is the same one you already reason about for Queueables, so the mental model is not new.

Understanding the basics

Can Apex test classes make HTTP callouts now?

Yes, in a class or method annotated @IntegrationTest, introduced in Winter ‘27 as a developer preview. Regular @IsTest methods still cannot make callouts and still require Test.setMock. The integration-test path trades away automatic rollback to allow the real call.

What is the difference between @IsTest and @IntegrationTest?

@IsTest marks a unit test: it can’t make callouts, its data rolls back automatically, and it runs fast and in parallel for code coverage. @IntegrationTest marks an integration test: it can make real callouts, its data commits and must be cleaned up in @TearDown, and only one runs at a time, asynchronously.

How do I enable Apex integration tests?

Add ApexIntegrationTests to the features array in your scratch org’s project-scratch-def.json, on a Winter ‘27 Developer scratch org. The feature is developer-preview and scratch-org scoped, so it is not something you enable in production.

Do integration tests replace mocking?

No. Keep your mocked @IsTest classes for coverage and fast feedback. Add a small number of integration tests for the cases where the live contract is the actual risk, like an agent’s external action or a third-party API you don’t control.

Where to start

Pick the one integration whose breakage would page you at 2 a.m., the one where a partner can change their API without telling you. Write a single @IntegrationTest for it against a sandbox endpoint, get the @BeforeClass and @TearDown right, and schedule it to run before each release. That one test will earn its keep the first time someone else’s deploy breaks your contract and your pipeline catches it instead of your customer.


If your CI mocks everything and you’ve been burned by an integration that passed every test and still broke, we build test pipelines that check the real contract. Talk to us.

Keep reading

All insights