0

I am still struggling with one thing: my test class has a static variable (boolean). It is set to false; It also has a SimpleFinishAction instance (which is an instance of a finishAction interface defined in the Batch Class) with a method .onFinish() that is supposed to set the boolean to true.

Batch class, in the finish block, calls onFinish on the SimpleFinishAction instance and - if I debug within Batch - I can see the static boolean is then set to true. However, when the test.StopTest() happens, the assertion fails, as the boolean is false again. What gives?

Test Class:

@IsTest
public with sharing class MassTransactorBatchHelperTest {

    public static Boolean onFinishCalled = false;

    @IsTest
    public static void testIterable() {
        Contact c1 = new Contact(LastName='Contact 1');
        Contact c2 = new Contact(LastName='Contact 2');
        List<Contact> contacts = new List<Contact>{c1, c2};
        MassTransactorBatchHelper.ListIterable itbl = new MassTransactorBatchHelper.ListIterable(contacts);
        Iterator<SObject> it = itbl.Iterator();

        System.assert(it.hasNext());
        System.assert(((Contact)it.next()).LastName == 'Contact 1');
        System.assert(it.hasNext());
        System.assert(((Contact)it.next()).LastName == 'Contact 2');
        System.assert(!it.hasNext());
    }

    @IsTest
    public static void testCallsFinishAction() {
        Contact c1 = new Contact(LastName='Contact 1');
        Contact c2 = new Contact(LastName='Contact 2');
        List<Contact> contacts = new List<Contact>{c1, c2};
        SimpleFinishAction action = new SimpleFinishAction();
        MassTransactorBatchHelper bh = new MassTransactorBatchHelper(contacts, 'create', action);
        Test.startTest();
            Database.executeBatch(bh);
        Test.stopTest();
        System.debug('*** boolean onFinishCalled in Test: ' + onFinishCalled);
        //System.assert(onFinishCalled);
    }

    public class SimpleFinishAction implements MassTransactorBatchHelper.OnFinishAction {
        public void onFinish(Id jobId) {
            MassTransactorBatchHelperTest.onFinishCalled = true;
        }
    }
}

Batch Class [code is not mine originally, so I am deleting the irrelevant parts]:

public with sharing class MassTransactorBatchHelper implements Database.Batchable<SObject>, Database.Stateful {
    
    private OnFinishAction finishAction;
    
    public class ListIterator implements Iterator<SObject>

    public class ListIterable implements Iterable<SObject>
    
    public MassTransactorBatchHelper(List<SObject> records, OperationType action)
   
    public MassTransactorBatchHelper(List<SObject> records, String actionIn, onFinishAction fa) {
        this.finishAction = fa;
        ...
}
    public System.Iterable<SObject> start(Database.BatchableContext ctx) {      
      return new ListIterable(records);
    }
    
    public void execute(Database.BatchableContext ctx, List<SObject> records) {
    ...

    public void finish(Database.BatchableContext ctx) {
        if (finishAction != null) {
            System.debug('*** Executing: ' + finishAction + ' JobID: ' + ctx.getJobId());
            finishAction.onFinish(ctx.getJobId());
            System.debug('*** boolean onFinishCalled in Batch: ' + MassTransactorBatchHelperTest.onFinishCalled);
        }
    }


    public interface OnFinishAction {
        void onFinish(Id finishedJobId);
    }
 }

Now, in Debug, I can see:

"*** boolean onFinishCalled in Batch: true" "*** boolean onFinishCalled in Test: false"

Any ideas why the latter "looses" the value set by finish() of the Batch class?

@sfdcfox: here is the full code for the batch class (credits to Matt Comer):

public with sharing class MassTransactorBatchHelper implements Database.Batchable<SObject>, Database.Stateful {
    
    public enum OperationType {DO_INSERT, DO_UPDATE, DO_UPSERT, DO_DELETE}
    private List<SObject> records;
    private OperationType action;
    private OnFinishAction finishAction;
    
    public class ListIterator implements Iterator<SObject> {
        private List<SObject> records;
        Integer cursor;
        public ListIterator(List<SObject> records) {
            this.records = records;
            cursor = -1;
        }
        public boolean hasNext() {
            return (cursor < (records.size() -1));
        }
        public SObject next() {
            if (cursor == records.size() - 1) {return null;}
            cursor++;
            return records[cursor];
        }
    }

    public class ListIterable implements Iterable<SObject> {
        private List<SObject> records;
        public ListIterable(List<SObject> records) {
            this.records = records;
        }
        public Iterator<SObject> Iterator(){
            return new ListIterator(records);
        }
    }    
    
    public MassTransactorBatchHelper(List<SObject> records, OperationType action) {
        
        this.records = records;
        this.action = action;        
    }

    public MassTransactorBatchHelper(List<SObject> records, String actionIn) {
        
        this(records, actionIn, null);
    }

    public MassTransactorBatchHelper(List<SObject> records, String actionIn, onFinishAction fa) {
        this.records = records;
        String a = actionIn.toUpperCase();
        this.finishAction = fa;

        if (a == 'CREATE') {
            this.action = OperationType.DO_INSERT;
        } else if (a == 'UPDATE') {
            this.action = OperationType.DO_UPDATE;
        } else if (a == 'UPSERT') {
            this.action = OperationType.DO_UPSERT;
        } else if (a == 'DELETE') {
            this.action = OperationType.DO_DELETE;
        }
    }

    public System.Iterable<SObject> start(Database.BatchableContext ctx) {      
      return new ListIterable(records);
    }
    
    public void execute(Database.BatchableContext ctx, List<SObject> records) {
        // fix problem with picklist field values which can occur in async context (seems to be due to serialization / storage of state)
        if (action != OperationType.DO_DELETE) {
            Map<String, Schema.SObjectField> sObjectFieldsMap = Schema.describeSObjects(new List<String> { records[0].getSObjectType().getDescribe().getName() })[0].fields.getMap();
            for (SObject o: records) {
                for (String fieldName : o.getPopulatedFieldsAsMap().keySet()) {
                    Schema.DisplayType dt = sObjectFieldsMap.get(fieldName).getDescribe().getType();
                    if ((dt == Schema.DisplayType.PICKLIST || dt == Schema.DisplayType.MULTIPICKLIST) && 
                        String.valueOf(o.get(fieldName)) != null ) {
                        o.put(fieldName, String.valueOf(o.get(fieldName)).replace('[', '').replace(']', ''));
                    }
                }
            }
        }
        
        switch on action {
            when DO_INSERT {
                Database.insert(records, false);
            }
            when DO_UPDATE {
                Database.update(records, false);
            }
            when DO_UPSERT {
                Database.upsert(records, false);
            }
            when DO_DELETE {
                Database.delete(records, false);
            }
        }
    }

    public void finish(Database.BatchableContext ctx) {
        if (finishAction != null) {
            System.debug('*** Executing: ' + finishAction + ' JobID: ' + ctx.getJobId());
            finishAction.onFinish(ctx.getJobId());
            System.debug('*** boolean onFinishCalled in Batch: ' + MassTransactorBatchHelperTest.onFinishCalled);
        }
    }

    public interface OnFinishAction {
        void onFinish(Id finishedJobId);
    }
 }
0

1 Answer 1

2

Unfortunately, I don't know how to answer your question directly. We don't have the actual, full code to see what's wrong. The code as written doesn't compile, which means I had to pare it down to a simple reference implementation. I will say that the framework you're using should work correctly.

Static variables do exist from the start of a transaction to the end of a transaction. They are never reset after first being initialized. I would expect the test not to fail, and it doesn't in my reproduction of this code. If you'd like additional help, you'll need to provide a Short, Self Contained, Correct (Compilable), Example that actually demonstrates the problem.

More Details

Static variables are initialized the first time the class is referenced in execution. For a unit test, this means that before the first line of code is executed, all static initializers and static blocks will be executed in source code order from top to bottom. When the test first accesses another class, that class' static initializers and static blocks will be executed in source code order from top to bottom.

After this initialization happens for a given class, these static variables are never reinitialized or reset for the rest of the transaction. They will only be changed by statements that cause them to change, such as the line of code in the unit test that sets the variable to be true. There are zero exceptions to this rule: as long as you're in the same transaction, they will retain their values.

So now, we look at the execution of a unit test. For each unit test method, a single transaction is started. This transaction will persist from the moment the test starts, through Test.startTest() and Test.stopTest(), and all the way through any asynchronous code that may be triggered in the test code. This is true even if you schedule a job, execute a batch, queue a Queueable, call a future method, or publish a platform event. Unlike "live" mode, everything will be executed as one transaction.

This is why each unit test method always produces exactly one log file. Everything is happening in the same transaction, so we know that static variables persist, because we just learned that static variables are initialized only once per class. There is no mechanism that allows for the system to automatically reinitialize static variables during a single transaction.


Here's the code I used to verify the behavior works as I expect.

public with sharing class MassTransactorBatchHelper implements Database.Batchable<SObject>, Database.Stateful {
    private OnFinishAction finishAction;
    sObject[] records;
    public MassTransactorBatchHelper(List<SObject> records, String actionIn, onFinishAction fa) {
        this.finishAction = fa;
        this.records = records;
    }
    public Iterable<SObject> start(Database.BatchableContext ctx) {      
        return records;
    }
    public void execute(Database.BatchableContext ctx, List<SObject> records) {
    }
    public void finish(Database.BatchableContext ctx) {
        if (finishAction != null) {
            System.debug('*** Executing: ' + finishAction + ' JobID: ' + ctx.getJobId());
            finishAction.onFinish(ctx.getJobId());
            System.debug('*** boolean onFinishCalled in Batch: ' + MassTransactorBatchHelperTest.onFinishCalled);
        }
    }
    public interface OnFinishAction {
        void onFinish(Id finishedJobId);
    }
}
@isTest
public with sharing class MassTransactorBatchHelperTest {    
    public static Boolean onFinishCalled = false;

    @isTest
    public static void testCallsFinishAction() {
        Contact c1 = new Contact(LastName='Contact 1');
        Contact c2 = new Contact(LastName='Contact 2');
        List<Contact> contacts = new List<Contact>{c1, c2};
        SimpleFinishAction action = new SimpleFinishAction();
        MassTransactorBatchHelper bh = new MassTransactorBatchHelper(contacts, 'create', action);
        Test.startTest();
        Database.executeBatch(bh);
        Test.stopTest();
        System.debug('*** boolean onFinishCalled in Test: ' + onFinishCalled);
        Assert.isTrue(onFinishCalled);
    }
    
    public class SimpleFinishAction implements MassTransactorBatchHelper.OnFinishAction {
        public void onFinish(Id jobId) {
            MassTransactorBatchHelperTest.onFinishCalled = true;
        }
    }
}
7
  • Can you explain why you expect the unit test method's transaction to be shared with the batch's start/execute/finish execution transaction(s)? In my experience the batch still appears to be executed async (that's what I based my answer on), just submitted, executed and awaited on Test.stopTest. (I should, perhaps, check the debug logs for the unit test to validate my assumption!)
    – Phil W
    Commented Dec 5, 2023 at 17:08
  • @PhilW Sure thing.
    – sfdcfox
    Commented Dec 5, 2023 at 17:27
  • The interesting thing is, as you and I have discussed elsewhere, asyncs that get enqueued from the async executions run by Test.stopTest do actually run after the unit test method execution transaction has finished - I guess this is why I had the understanding that asyncs still ran in separate transactions that were simply awaited. Having a single log indicates this not to be true for asyncs enqueued prior to the Test.stopTest as you have said here. Another learning day!
    – Phil W
    Commented Dec 5, 2023 at 18:08
  • @sfdcfox: so, in Debug, you are still seeing the boolean return true after Test.stopTest()? This is the opposite of what I am getting (true while in Batch, false after). The rest of the code does not do anything concerning the little onFinish class we are using here, so I am not sure why I am not able to get the result you are getting. Thanks for the effort of looking into this! One thing I forgot to add is the System.assert(onFinishCalled) line right after Test.StopTest() --- this assertion fails (as the boolean is now false again) - I removed it not to keep having the test fail all the time.
    – kanczi
    Commented Dec 5, 2023 at 19:24
  • @sfdcfox: I added the full code for the batch class after all. Thanks!
    – kanczi
    Commented Dec 5, 2023 at 19:28

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .