Skip to main content
New tool CRON Expression Builder — preview next run times before you schedule Apex. Open the builder →
A 3D shield illustration representing automated regression testing for secure and stable Salesforce deployments.
DevOps

Salesforce regression testing: what Winter '27 breaks

A passing regression suite proves your code works under the conditions the suite builds for it: an admin user, seeded data, an already-upgraded sandbox. Here is the general suite worth keeping, and the four Winter '27 changes it cannot see before the 10 to 12 October 2026 production wave.

The short answer

A green Salesforce regression suite usually runs as an admin, on seeded data, in a sandbox already on Winter '27, so it cannot see profile filtering, the Use Any API Auth requirement for SOAP login(), Flow's 10 second lock retry and the raised Apex heap ceiling. Retest as a standard user, with production-shaped data, and record elapsed time as well as pass or fail.

Key takeaways Assert record state in Apex tests and build the data in a TestFactory. A coverage percentage only tells you that the deployment will be accepted. Run the suite from CI on every pull request with sf apex run test, fail the build on the first failure, and keep a small Playwright or Cypress set for the Lightning journeys Apex cannot reach. Re-run critical paths under System.runAs as a standard user before your upgrade slot, because eight permissions bypass Profile Filtering and admins hold several of them. Inventory SOAP login() callers with a GROUP BY query on LoginHistory, then grant Use Any API Auth (PermissionsUseAnyApiAuth, which is a different permission from Use Any API Client) to named integration users. Keep the Summer '26 heap enforcement checkbox on in sandboxes until production takes the release, and record elapsed time on integration tests so Flow's 10 second lock retry surfaces before a caller times out.

Your Winter '27 preview sandbox is green. The Apex tests pass and the Playwright run is clean, which proves your code behaves as asserted under the conditions the suite builds for it: an admin user, seeded data, and an org that has already taken the release. Four Winter '27 changes sit outside all three. Production upgrades for the confirmed wave run from 10 to 12 October 2026, so there is still time to close the gap.

The suite you should already have

Everything in the Winter '27 sections below assumes this much is already running.

Assert the state the record ends in

Coverage percentage is a deployment gate and a weak proxy for anything else. A test should assert the record state after the DML lands: the status moved, the counter incremented, the second call changed nothing. Contract renewal is a good example because it touches a date, a picklist and a rollup counter.

@IsTest
private class ContractRenewalServiceTest {

    @TestSetup
    static void seedData() {
        Account customer = TestFactory.account('Northwind Logistics');
        insert customer;
        insert TestFactory.contract(customer.Id, Date.today().addMonths(1));
    }

    @IsTest
    static void renewalExtendsTermAndIncrementsCounter() {
        Contract expiring = [SELECT Id FROM Contract LIMIT 1];

        Test.startTest();
        ContractRenewalService.renew(new List<Id>{ expiring.Id }, 12);
        Test.stopTest();

        Contract renewed = [
            SELECT Status, ContractTerm, Renewal_Count__c
            FROM Contract
            WHERE Id = :expiring.Id
        ];
        Assert.areEqual('Activated', renewed.Status, 'Renewal should activate the contract');
        Assert.areEqual(12, renewed.ContractTerm, 'Term should extend to 12 months');
        Assert.areEqual(1, Integer.valueOf(renewed.Renewal_Count__c), 'Counter increments once');
    }

    @IsTest
    static void repeatRenewalForTheSamePeriodChangesNothing() {
        Contract expiring = [SELECT Id FROM Contract LIMIT 1];

        Test.startTest();
        ContractRenewalService.renew(new List<Id>{ expiring.Id }, 12);
        ContractRenewalService.renew(new List<Id>{ expiring.Id }, 12);
        Test.stopTest();

        Contract renewed = [SELECT Renewal_Count__c FROM Contract WHERE Id = :expiring.Id];
        Assert.areEqual(1, Integer.valueOf(renewed.Renewal_Count__c), 'No double counting');
    }
}

Keep the data setup in a TestFactory rather than querying whatever happens to exist in the org. It lets the same class run in a scratch org, a Partial Copy sandbox and CI without edits.

Cover the journeys Apex cannot reach

An Apex test cannot see a Lightning record page that renders an empty related list because a field became inaccessible. That needs a browser. Playwright and Cypress both drive Lightning Experience; I use Playwright for the trace viewer, because a failed nightly run with a trace is a five minute diagnosis and the same failure with one screenshot is an hour. The cost I have not solved is maintenance, so I keep the browser set to the few journeys where a silent failure costs money (quote to order, case escalation) and let Apex carry the rest.

Run it on every pull request

sf apex run test --test-level RunLocalTests --code-coverage --result-format junit --output-dir build/test-results --wait 45 --target-org ci

sf apex run test is the current command; the sfdx force:apex:test:run form still sitting in older pipelines should go the next time you touch them. Fail the build on the first failure rather than producing a report nobody reads, and when the pipeline gets slow, trim the browser suite before you trim assertions.

Match the environment to the claim you are making

Sandbox type What it gives you Where it misleads you
Developer, Developer Pro fast refresh, metadata parity no production volumes, so no lock contention and no slow queries
Partial Copy a data sample plus a real metadata baseline sampled relationships, so parents arrive without the child rows that break your logic
Full production volumes and configuration slow to refresh, so the baseline drifts between cycles

Whichever you pick, sync the metadata baseline before the cycle starts. A regression run against a sandbox three deployments behind production tests a version of the org nobody is going to ship.

What a green run cannot see this release

The preview wave completed between 29 and 31 August 2026 on instances including USA1088S, SWE96S and JPN6S. The confirmed production entries in the Salesforce maintenance feed run from 10 to 12 October 2026, with the first day's start times clustered between 04:00 and 06:30 UTC, across instances such as USA1250S, USA1224 and BRA60S. Roughly 72 days separate the two windows, and instances hold their own slots, so read yours off Salesforce Trust. For most of those 72 days your sandbox runs Winter '27 and production does not.

Winter '27 change Why the suite stays green What actually breaks
Profile Filtering enabled by default tests execute as an admin holding a bypass permission non-admin sessions read a blank profile name and branch the wrong way
Use Any API Auth required for SOAP login() no test authenticates over SOAP; CI uses an authorised user server to server logins fail on upgrade weekend with no UI symptom
Flow pauses 10 seconds and retries on a locked record the assertion still passes, just later an external caller times out first, retries, and commits twice
Heap ceiling 6 MB to 10 MB sync, 12 MB to 25 MB async the preview sandbox already grants the higher ceiling production enforces the old ceiling until its own slot

Log in as the people who actually use the org

Profile Filtering is enabled by default in Winter '27, and a user without View All Profiles can see only their own profile name. Eight permissions bypass it: View All Profiles, Customize Application, Manage Users, Manage Profiles and Permission Sets, Create and Set Up Experiences, Manage Customer Users, Manage External Users, and Delegated External User Administrator. Your admin holds several, your CI user probably holds Customize Application, and that is the whole reason the suite stays green: nobody in the run is subject to the change. The exposure is any logic that reads someone else's profile name.

public with sharing class HandoffRules {

    public static String queueFor(Id ownerId) {
        User owner = [SELECT Id, Profile.Name FROM User WHERE Id = :ownerId];
        if (owner.Profile.Name == 'Partner Sales Rep') {
            return 'Channel_Review';
        }
        return 'Direct_Review';
    }
}

I cannot tell you what that query returns once filtering applies, and I have not found a source that states it. Confirm in your own preview sandbox whether owner.Profile.Name comes back null, comes back empty, or throws. Confirm it in the execution mode the real code runs in too: default Apex runs in system mode, so a class that queries WITH USER_MODE or strips fields with Security.stripInaccessible needs its own check. The outcome I would plan for is a blank value and a branch taking the wrong exit with nothing in the error log.

@IsTest
static void handoffQueueResolvesWhenTheCallerIsNotAnAdmin() {
    User rep;
    User partnerOwner;

    System.runAs(new User(Id = UserInfo.getUserId())) {
        rep = TestFactory.userWithProfile('Standard User');
        partnerOwner = TestFactory.userWithProfile('Partner Sales Rep');
        insert new List<User>{ rep, partnerOwner };
    }

    String queue;
    System.runAs(rep) {
        queue = HandoffRules.queueFor(partnerOwner.Id);
    }

    Assert.areEqual('Channel_Review', queue,
        'Routing must not depend on the running user holding a Profile Filtering bypass');
}

Start from a grep to find the call sites.

rg -n -g '*.cls' -g '*.trigger' -g '*.flow-meta.xml' -e 'Profile.Name' -e 'FROM Profile' -e 'ProfileId' force-app/

Where someone genuinely needs profile names (a support console showing the assigned owner's role), grant View All Profiles through a permission set and record why. Putting it back on the profile returns you to where you started.

The two failures that happen outside your own code

Who your integrations log in as

Winter '27 enforces a Release Update: a user without the Use Any API Auth permission can no longer authenticate through SOAP API login(). The API name is PermissionsUseAnyApiAuth, available on both Profile and PermissionSet. It is a different permission from Use Any API Client, which governs API Access Control and connected app self-authorization, and several published write-ups conflate the two. I have seen a team grant the client permission, close the task, and still lose a nightly ERP sync on upgrade weekend, which finance discovered as a missing day of invoices. The failure is server to server, at login, invisible in the UI.

Inventory the callers now. The trap is that Application, Status and ApiType on LoginHistory are groupable and not filterable, so a WHERE ApiType IN (...) clause will not run. Group first, then filter the rows yourself.

SELECT UserId, Application, Status, COUNT(Id) logins
FROM LoginHistory
WHERE LoginTime = LAST_N_DAYS:90
GROUP BY UserId, Application, Status
ORDER BY COUNT(Id) DESC

The filterable fields are LoginTime, UserId, LoginType, SourceIp and LoginUrl. In the UI, Login History filtered on Login Type of Other Apex API, or Login Subtype of SOAP API, reaches the same list, and API Total Usage event log rows with an empty CONNECTED_APP_ID show traffic arriving outside a connected app.

The requirement lands on your org's own upgrade weekend, somewhere between 29 August and 10 October 2026 depending on instance. My call: grant the permission through a dedicated permission set assigned to named integration users before that date, and schedule the OAuth work separately. login() retires in Summer '27 for API versions 31.0 through 64.0, while SOAP API itself keeps working once authentication moves to OAuth. The permission gets you through the weekend; it does not get you through the year.

Flow waits ten seconds, and your caller probably does not

When a flow meets a locked record during transaction initialization, Winter '27 pauses it for 10 seconds and retries rather than failing with UNABLE_TO_LOCK_ROW. For a schedule-triggered flow that is a clear gain. For a synchronous inbound integration it relocates the failure: a caller with a 5 second HTTP timeout gives up while Salesforce is still waiting, its retry policy fires a second request, and that request commits. You get two records and a passing test. Salesforce has not published how many retries happen, so do not size a timeout budget against an assumed count.

Test with contention rather than in isolation, sending the same payload concurrently.

for i in $(seq 1 20); do
  curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 30 -X POST "$SF_URL/services/apexrest/shipments/" -H "Authorization: Bearer $SF_TOKEN" -H 'Content-Type: application/json' -d '{"externalOrderId":"EXT-4471","lines":3}' &
done
wait

Any row where time_total crosses ten seconds is the retry, and any duplicate Shipment record is the bug. Make the endpoint idempotent so a repeated request updates instead of inserting.

@RestResource(urlMapping='/shipments/*')
global with sharing class ShipmentIntakeResource {

    @HttpPost
    global static void receive() {
        ShipmentPayload payload = (ShipmentPayload) JSON.deserialize(
            RestContext.request.requestBody.toString(),
            ShipmentPayload.class
        );

        Shipment__c inbound = new Shipment__c(
            External_Order_Id__c = payload.externalOrderId,
            Line_Count__c = payload.lines
        );

        Database.upsert(inbound, Shipment__c.External_Order_Id__c, true);
    }

    global class ShipmentPayload {
        public String externalOrderId;
        public Integer lines;
    }
}

External_Order_Id__c has to be a unique External ID field for that upsert to collapse the retry into an update. Put the upsert in place first, then raise the caller's timeout above 10 seconds.

The heap ceiling moved, and the sandbox is the permissive side now

Apex context Summer '26 ceiling Winter '27 ceiling
Synchronous 6 MB 10 MB
Asynchronous 12 MB 25 MB

Between the sandbox upgrade in late August and the production slot in October, code that allocates 8 MB synchronously passes every sandbox run and throws a heap error the first time a user touches it in production. Setup, Apex Settings has a checkbox, Enforce the Summer '26 Apex heap limit, which puts a sandbox, Developer Edition org or scratch org back on the old ceilings. I leave it on from the moment the sandbox takes the release until production has taken it too, then turn it off and measure again. Leaving it on means you are not exercising the headroom you are about to gain; leaving it off means you can ship something production cannot run.

@IsTest
static void invoiceRollupStaysUnderLegacyHeapCeiling() {
    Integer legacySyncCeiling = 6 * 1024 * 1024;
    List<Invoice__c> invoices = TestFactory.invoicesWithLines(200, 50);

    Test.startTest();
    InvoiceRollupService.recalculate(invoices);
    Integer heapAfterRollup = Limits.getHeapSize();
    Assert.isTrue(
        heapAfterRollup < legacySyncCeiling * 0.75,
        'Rollup consumed ' + heapAfterRollup + ' bytes against a 6291456 byte ceiling'
    );
    System.debug(LoggingLevel.INFO, 'Ceiling granted: ' + Limits.getLimitHeapSize());
    Test.stopTest();
}

Limits.getHeapSize() reports consumption at the moment you call it, so read it immediately after the operation under test and before Test.stopTest() flushes the async queue. Limits.getLimitHeapSize() returns the ceiling granted to the transaction, which is how the log tells you which release the run executed under.

What to watch for

  • The Flow retry count is undocumented. Salesforce describes a 10 second pause and retry and names schedule-triggered flows as a beneficiary; confirm the behaviour for the flow types you run.
  • Use Any API Auth (PermissionsUseAnyApiAuth) and Use Any API Client are different permissions with different jobs. Granting the second leaves the first problem in place.
  • Profile Filtering behaviour inside Apex needs confirming in your org and in the execution mode your code uses. Treat any claim about null versus empty string versus exception as unverified until you have run it yourself.
  • The Summer '26 heap enforcement checkbox exists only for sandboxes, Developer Edition orgs and scratch orgs. Production takes the new ceilings when it takes the release.
  • Instance dates vary. Confirmed production entries cover 10 to 12 October 2026, and your slot is whatever Salesforce Trust reports for your instance.
  • Summer '27 retires the login() authentication call on API versions 31.0 through 64.0. The rest of SOAP API keeps working once authentication moves.

Record elapsed time on every integration test, store it per build alongside the Limits.getLimitHeapSize() value the run reported, and alert when either number moves. A duration that jumps from 400 ms to 10.4 seconds names the Flow lock retry for you, and a ceiling that changes from 6291456 to 10485760 tells you which release the run executed on.

Originally reported by reddit.com

Frequently asked questions

When does Salesforce Winter '27 go live in production orgs?

Confirmed production entries in the Salesforce maintenance feed run from 10 to 12 October 2026, with the first day's wave starting between 04:00 and 06:30 UTC. Instances hold their own slots, so read your own date off Salesforce Trust.

Will my integration break if it still uses SOAP API login()?

From Winter '27 a user without the Use Any API Auth permission (PermissionsUseAnyApiAuth) can no longer authenticate through SOAP API login(), and that lands on your org's own upgrade weekend. The login() call itself retires in Summer '27 for API versions 31.0 through 64.0, though SOAP API keeps working once authentication moves to a supported OAuth mechanism.

Why does my Apex code hit the heap size limit in production but not in the sandbox?

Winter '27 raises the heap ceiling from 6 MB to 10 MB synchronous and 12 MB to 25 MB asynchronous, and your preview sandbox took the release weeks before production does. During that window the sandbox is the more permissive org, so switch on Enforce the Summer '26 Apex heap limit in Setup, Apex Settings until production upgrades.

What is the difference between with sharing and user mode in Apex?

The with sharing keyword applies sharing rules, while default Apex still runs in system mode for object and field permissions, which is a separate thing from WITH USER_MODE or Security.stripInaccessible. Test Profile Filtering behaviour in the execution mode the real call path uses, and as a non-admin user.

How do I find every user that authenticates through SOAP login()?

Query LoginHistory for the last 90 days with a GROUP BY on UserId, Application and Status, because those fields are groupable and not filterable, then read the SOAP rows off the result. API Total Usage event log rows with an empty CONNECTED_APP_ID show the same traffic from the other direction.

Newsletter

One email every Tuesday

New guides, tool updates, and the release-note changes that break things.

No spam. Unsubscribe in one click.

Comments

Loading comments...

Leave a Comment