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.
Leave a Comment