3

I'm wondering if there is a way to get multiple transactions (with different contexts as far as static variables are concerned) within one test.

I have a Trigger handler class where there is some caching of a query to get data from a related object. This is done with a static member Map holding the data through the transaction. This all works well with Users interacting with the system, but it falls over when I'm trying to write an integration test to test edits, as there is no new transaction so the static Map remains cached with the old data.

As a simplified version of my actual code, there are two custom objects, Employee and Personal, and a trigger on Personal that updates Personal.Email__c to have the same value as Employee.Email__c (I can't just use a formula field because those can't be used in alerts).

public class PersonalTriggerHandler { private static Map<Id, Employee__c> employeeDetails; private void updateEmails(List<Personal__c> personals) { Set<Id> employeeIds = new Set<Id>(); for (Personal__c personal : personals) { employeeIds.add(personal.Employee__c); } if (employeeDetails == null) { employeeDetails = [SELECT Email__c FROM Employee__c WHERE Id IN :employeeIds]; } for (Personal__c personal : personals) { personal.Email = employeeDetails.get(personal.Employee__c).Email__c; } } public void onBeforeInsert(List<Personal__c> personals) { updateEmails(personals); } public void onBeforeUpdate(List<Personal__c> personals) { updateEmails(personals); } } 
@IsTest private class PersonalIntegrationTest { @IsTest static void whenPersonalUpdated_thenEmailIsSet { Employee__c employee = new Employee__c(Email__c='[email protected]'); insert employee; Personal__c personal = new Personal__c(Employee__c=employee.Id); insert personal; employee.Email__c = '[email protected]'; update employee; Test.startTest(); update personal; Test.stopTest(); Personal__c checkPersonal = [SELECT Email__c FROM Personal__c WHERE Id = :personal.Id]; System.assertEquals('[email protected]', checkPersonal.Email__c, 'Email should be updated from Employee'); } } 

(Note: if I've mis-typed anything here, let me know; I've basically reconstructed a much larger code-base to strip it down to basics. Also, the trigger is set up to call the appropriate methods, but I didn't think anyone would need to see that)

My assert fails - the personal record still has an email of [email protected] because when I first inserted the personal record it populated the static map and the update is then happening within the same transaction. I'm wondering if there is any way to create a separate transaction within one test method - I had hoped that Test.startTest() would do so, but apparently not.

Having seen discussions here Is a single APEX test case considered a transaction? and here Error: MIXED_DML_OPERATION on setup and non-setup objects I tried using System.runAs(), but that does not work. Seems it creates a separate enough context to get around mixed DML, but not an actually separate transaction :(

1 Answer 1

2

Short answer: there is a way to clear static variables, but it's not a separate transaction, and not really inside of an individual test method per se

Salesforce recommends doing test setup in a static void method with the @TestSetup annotation.

If you do have an @TestSetup method, then all static variables (in the test class as well as in non-test code) get wiped out before each test method is run.

One of the quirks of @TestSetup (because this is Salesforce after all, and there always seems to be a quirk or two), is that the limits aren't automatically reset. You can force a limit reset by using Test.startTest() and Test.stopTest() inside of your @TestSetup method. Another quirk is that we can still use Test.startTest() and Test.stopTest() as normal in individual test methods.

So

  • @TestSetup clears static variables, but not limits
  • Test.startTest()/Test.stopTest() give us a fresh set of limits, but doesn't clear static variables

Even when async code (@future, queueables, etc...) is executed synchronously as part of the work that Test.stopTest() does, static variables are not cleared (but it does give us an additional fresh set of limits)

A quick and dirty test, with commented execution log

public class QueueablePlayground implements Queueable { public Map<String, Integer> intByName; public static List<Integer> intList; public QueueablePlayground() { intByName = new Map<String, Integer>(); } public void execute(QueueableContext ctx) { system.debug(intByName); // line 11 system.debug(QueueablePlayground.intList); // line 12 system.debug(Limits.getQueries()); // line 13 } } 
@isTest public class testQueueablePlayground { @TestSetup static void someSetup() { // We can use Test.startTest() and Test.stopTest() in this setup method // and still use it again in individual @isTest methods // Odd that Salesforce doesn't clear limits without that, but I digress... QueueablePlayground.intList = new List<Integer> {7, 7, 7}; QueueablePlayground qp = new QueueablePlayground(); List<Account> accts = [SELECT Id FROM Account LIMIT 1]; qp.execute(null); } @isTest static void staticState() { QueueablePlayground qp = new QueueablePlayground(); qp.execute(null); QueueablePlayground.intList = new List<Integer> {2, 3, 1}; qp.execute(null); Test.startTest(); List<Account> accts = [SELECT Id FROM Account LIMIT 1]; qp.execute(null); System.enqueueJob(qp); Test.stopTest(); } } 

08:02:27.1 (1541659)|EXECUTION_STARTED

// test setup method being run

08:02:27.1 (1549543)|CODE_UNIT_STARTED|[EXTERNAL]|xxxxxxxxxxxxxxx|testQueueablePlayground.someSetup()
08:02:27.1 (6390841)|SOQL_EXECUTE_BEGIN|[7]|Aggregations:0|SELECT Id FROM Account LIMIT 1
08:02:27.1 (15346088)|SOQL_EXECUTE_END|[7]|Rows:0
08:02:27.1 (15747783)|USER_DEBUG|[11]|DEBUG|{}
08:02:27.1 (15864679)|USER_DEBUG|[12]|DEBUG|(7, 7, 7)
08:02:27.1 (15899396)|USER_DEBUG|[13]|DEBUG|1
08:02:27.15 (15918316)|CUMULATIVE_LIMIT_USAGE
// details removed
08:02:27.15 (15918316)|CUMULATIVE_LIMIT_USAGE_END

08:02:27.1 (15945207)|CODE_UNIT_FINISHED|testQueueablePlayground.someSetup()
08:02:27.1 (15952791)|EXECUTION_FINISHED 08:02:27.35 (35590011)|USER_INFO|[EXTERNAL]|[redacted]

// start of test method being run

08:02:27.35 (35610259)|EXECUTION_STARTED
08:02:27.35 (35617407)|CODE_UNIT_STARTED|[EXTERNAL]|xxxxxxxxxxxxxxx|testQueueablePlayground.staticState()

// First in-test initialization, showing that Test Setup wipes statics
// but not limits

08:02:27.35 (36010184)|USER_DEBUG|[11]|DEBUG|{}
08:02:27.35 (36020915)|USER_DEBUG|[12]|DEBUG|null
08:02:27.35 (36035768)|USER_DEBUG|[13]|DEBUG|1

// Second run, after explicitly assigning to the static list

08:02:27.35 (36072155)|USER_DEBUG|[11]|DEBUG|{}
08:02:27.35 (36113378)|USER_DEBUG|[12]|DEBUG|(2, 3, 1)
08:02:27.35 (36119305)|USER_DEBUG|[13]|DEBUG|1

// Test.startTest() called here
// Statics not cleared, but limits are

08:02:27.35 (37891785)|SOQL_EXECUTE_BEGIN|[19]|Aggregations:0|SELECT Id FROM Account LIMIT 1
08:02:27.35 (44757037)|SOQL_EXECUTE_END|[19]|Rows:0
08:02:27.35 (44942115)|USER_DEBUG|[11]|DEBUG|{}
08:02:27.35 (44957206)|USER_DEBUG|[12]|DEBUG|(2, 3, 1)
08:02:27.35 (45028512)|USER_DEBUG|[13]|DEBUG|1

// Async code run synchronously as part of Test.stopTest()
// Limits cleared, but statics not cleared

08:02:27.35 (75380393)|CODE_UNIT_STARTED|[EXTERNAL]|xxxxxxxxxxxxxxx|QueueablePlayground
08:02:27.35 (171193176)|USER_DEBUG|[11]|DEBUG|{}
08:02:27.35 (171225923)|USER_DEBUG|[12]|DEBUG|(2, 3, 1)
08:02:27.35 (171240242)|USER_DEBUG|[13]|DEBUG|0
08:02:27.35 (171260774)|CODE_UNIT_FINISHED|QueueablePlayground

Any options beyond that?

I think we're limited to 3 real alternatives here

  1. Give your static variable the @TestVisible annotation
    • Not the prettiest, but it would allow you to manually clear out your static variable inside your tests while still keeping the private visibility in non-test contexts
  2. If your trigger framework allows for programmatic disabling, disable it when you're doing your test setup
    • If your trigger(s) don't run, the static map won't be populated
  3. Alter your trigger handler code so you can inject your own empty map inside of your test
    • Kinda similar to alternative #1, but senior devs/leads may give you less side-eye

Personally, I mostly use @TestSetup combined with alternative #2 in my tests. Preventing triggers from running in setup means my tests are depending on/running less code (so fewer things can go wrong). It also gives you more precise control over the state of the data that your test is working with (and may allow you to cut some corners in setting up test data).

Being able to inject data/dependencies can also give you a lot of control within tests, but the code being tested needs to be designed specifically to allow that.

6
  • You state that "senior devs/leads may give...less side-eye" to the use of injection over @TestVisible annotation. I guess that I'm not as senior (in development -- I definitely am in age) as I'd hoped I might be, so I have to ask what's the issue with @TestVisible? Commented Aug 28 at 16:19
  • @Moonpie Not entirely sure myself. Maybe it's a bit "code smell"-y, probably not super elegant, mixes just a bit of "test" stuff into production code. Beyond that, it might mostly be OOP purity/dogma. I generally try to avoid it, but it is an easy way out (of mostly a design issue) when I do use it. Commented Aug 28 at 17:29
  • @DerekF thanks for the detailed explanation, I hadn't realised that @TestSetup was a separate transaction (particularly given, as you say, that it's not a fresh set of limits) so that is at least a way forward. As for DI, we are indeed using injection to allow for proper unit tests, but I am also building a few integration tests which gave rise to this question. Commented Aug 29 at 10:49
  • @Moonpie as DerekF said, it's more of a code smell - if your test needs the code to run differently than it would in production, then it's not actually testing that it would work in production. You can look at what the difference is and reason that it will still work correctly in production but the test is not definitive proof of that. As with any rule, the skill of a developer is in knowing when it can/should be broken :) Commented Aug 29 at 10:54
  • @Jazzer @TestSetup isn't a separate transaction though, it just has the effect of wiping statics. If anything, it's closer to @BeforeAll in JUnit (with some odd behavior that still carries over limits usage to each individual test). Something that ostensibly only runs once for the entire test class. The entire run of a unit test class (with multiple test methods) appears to be a single transaction. The underlying test framework just does a more complete job of resetting the environment between individual tests. Commented Aug 29 at 11:22

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.