4

I have a schedulable class that will run periodically, sending emails on certain conditions. I've tried to write a unit test using @testvisible private members, but some @testvisible members aren't being preserved.

Here's the basic outline:

global with sharing class VisitReminder implements Schedulable {

  @TestVisible private Date runDate;
  @TestVisible private List<Messaging.Email> reminders;
  @TestVisible private List<Messaging.SendEmailResult> sendResults;

  global VisitReminder() {
    runDate = Date.today();
  }

  global void execute(SchedulableContext SC) {

    reminders = new List<Messaging.Email>();

    /* Code omitted that queries based on runDate, 
     * and adds Messaging.SingleEmailMessage() objects to reminders */

    system.debug('Reminder count: ' + reminders.size());  // debug log shows reminders has members
    sendResults = Messaging.sendEmail(reminders, false);

  }

}

and the test class:

public class VisitReminderTest() {

  /* code omitted that create test data */

  VisitReminder vr = new VisitReminder();
  vr.runDate = Date.parse('9/30/2013'); //special test date to work with test data

  Test.startTest();
  System.schedule('visitReminderTest', '0 0 19 * * ?', vrs);
  Test.startTest();

  system.assertEquals(Date.parse('9/30/2013'), vr.runDate); //this will pass
  system.assertNotEquals(null, vr.reminders); //this will fail
  system.assertNotEquals(null, vr.sendRestuls); //this also fails

}

The debug log shows that the execute() method ran, and created a list of emails (reminders). But when I try to inspect the list after test.stopTest(), it is now null. Likewise sendResults. The testvisible variable runDate preserves its value after Test.stopTest, however, it was set in the test method, so perhaps the vr variable in the test method is a copy of the pre-test version? Is @TestVisible incompatible with Schedulable? Or is this a more general Schedulable testing issue: the schedulable object's state isn't available after the asynchronous code has run? The method is intended to send emails, so I can't query changed records, and I'm having no luck tracking the emails as activities

  • A very minor thing to pick on, but your example code has Test.startTest(); twice. I'm going to assume this is just a question typo. :) – Daniel Ballinger Oct 2 '13 at 1:15
  • Confirmed, question typo. Real test class uses test.stopTest(). But thanks for checking! – Jason Clark Oct 2 '13 at 1:23
  • You could try and rule out the @TestVisible by making the members public. Another idea, what if you move the reminders = new List<Messaging.Email>(); into the constructor? I suspect it wouldn't be null any more, but it wouldn't have any entries either as the execute will be marshalled through an asynchronous process. I.e. Salesforce will create a new VisitReminder instance and run that separately. P.S. Sorry to keep throwing ideas and questions at you without any clear answers. – Daniel Ballinger Oct 2 '13 at 1:58
  • No need to apologize, I appreciate the input. I meant to try switching @TestVisible to public and forgot, so good point. No difference, still null after test. Setting reminders to a new empty list in the CTOR is much more intersesting; the test dies with `System.UnexpectedException: Not Serializable: com/salesforce/api/fast/List<Messaging/Email>. I think you are right about the marshalling; a copy is sent to the async process but it isn't sent back to the test class, which reduces the testing options. With email in particular, I'm running out of options. – Jason Clark Oct 2 '13 at 3:02
3

The issue here is that the instance of VisitReminder constructed in your test is not the same instance that is instantiated by the platform Scheduler within the start and stop test scope. So your asserts are failing since the instance in the vr variable is never actually the executed instance.

The instance created by the Scheduler is created from a de-serialised copy of the one originally given to System.schedule by your test code. Unfortunately there is no way to access this instance or its state, its more than likely it is executed on another thread in the server and then destroyed.

The only state the test and the scheduled class share is the database and static variables. Added to the complication is that your only output of this is a set of emails, which do not get executed in a test anyway. Its interesting that the Salesforce example includes asserts in the schedule class.

So I see a few options...

  • Place the Asserts in the Schedule Class. Its interesting that the Salesfore example places asserts in the schedule class. So I thought I'd try it. Indeed this works, and of course you can condition the execution of the System.assert calls around a Test.isRunningTest() check. This is probably your cheapest option, but is also a little ugly to see in production code and not in test code.
  • Assert Callbacks. Inspired by KeithC's comment on the other answer and the above observation, I've developed a generic solution to allow you to express the asserts in your test code, yet have them invoked during the execution of the scheduled job with the correct state available. See below for more details on this approach.
  • Use of Static Variables. You can create a static @TestVisible variable in your VisitReminder class, e.g. testReminders and testSendResults, then in the last statements of the execute assign these from the instance versions. Your test will be able to see these via VisitReminder.testReminders etc. Personally I feel this is leveraging an implementation detail of the platform, that said I've seen it used elsewhere so likely now one of those approaches that will be here to stay.
  • Use of a Reminder History Object.. You may want to consider that having a history of what reminders have been sent for future reference is a good feature to give to your users. Thus if so a new object will also help with your testing, as records emitted by the scheduled class can readily be asserted via SOQL in the test code.
  • Email.setSaveAsActivity. You may in fact want to look at using setSaveAsActivity as this saves email activity to the target object of the email (if you have an appropriate one). This might in fact be a good feature anyway for you to review when reminders where sent historically.

Some of these solutions require changes to your schedule class solely for the benefit of your test, you can condition such code paths around a Test.isRunningTest() condition.

NOTE: Use of the 'global' modifier is optional these days (yet the Salesforce examples still include it). If your packaging this code this will be important to you, as it bakes in the class name and signature to the package. If your not packaging its not that big a deal. Eitherway you can use 'public' if you want.

An AssertCallback Solution

As per my summary of options above, this is inspired by KeithC's comment about using a logging approach to capture information to later assert, combined with an observation that asserts don't have to be executed in the immediate code path of the executing test.

First the usage, here is my Scheduled job.

public with sharing class MyScheduledWork implements Schedulable {

    @TestVisible
    private String message;

    public void execute(SchedulableContext SC) 
    {   
        message = 'Goodbye X';

        AssertCallback.assert(MyScheduledWork.class, 'execute', this);
    }   
}

And now my test...

@IsTest
private with sharing class MyScheduledWorkTest {

    @IsTest
    private static void testSchedule()
    {
        // Register assert callback
        AssertCallback.registerCallback(MyScheduledWork.class, new AssertMyScheduleWork());

        // Run the scheduled job
        MyScheduledWork testSchedule = new MyScheduledWork();
        testSchedule.message = 'Hello';
        Test.startTest();
        System.schedule('Test', '0 0 19 * * ?', testSchedule);
        Test.stopTest();        
    }

    public class AssertMyScheduleWork implements AssertCallback.IAssertCallback
    {
        public void assert(String location, Object state)
        {
            // Assert callback from MyScheduledWork?
            if(state instanceof MyScheduledWork)
            {
                // State to assert
                MyScheduledWork myScheduledWork = (MyScheduledWork) state;

                // Assert the state of the scheduled job
                System.assertEquals('Goodbye', myScheduledWork.message);
            }
        }
    }
}

There is a deliberate bug in the above to test the assertion fires and gives a meaningful stack trace.

enter image description here

Summary: As you can see this solution requires some instrumentation of the code your testing, which could in fact be used regardless of batch/schedule or not (a poormans Mockito). So yes it does have the overhead of calling a method in your code, which if you where really concerned about statements could be conditioned around Test.isTestRunning(). Though the AssertCallback.assert method does do this also. The main thing though is it has allowed you to assert anything you want, when you want.

Finally you can find the source code to the AssertCallback class here.

Hope this helps!

  • Making the variables static solved the problem. The AssertCallback is interesting, but I'm not sure how I feel about having prod code call test code. I will keep it mind though. Regarding setSaveAsActivity, see the link at the end of my post. Would love some help there too :) – Jason Clark Oct 2 '13 at 13:49
2

Ok, I'm pretty sure what I'm about to say is accurate, but I'm not 100% certain. Here goes:

When you create an instance of your VisitReminder class and schedule it, APEX serializes and instance of that class and stores it.

When you run Test.StopTest(), Apex recreates an instance of the serialized class to execute (that's now it works normally - the system doesn't actually keep an in-memory copy of a class that is scheduled). At that point the value of the variables is set because they were in the original class.

When scheduled test execution completes, the instance of VisitReminder that was used is destroyed. There is no mechanism to copy any data modified into your original class instance.

So it's actually working as I would expect.

The way to test is this:

Change reminders into a static variable. Load it only in test mode with the test data (using Test.IsRunningTest()). You should then be able to read it in your test code, because it's the same execution context. It does cost an extra static variable, but the cost is light unless you're in test mode.

I know purists don't like the idea of adding code to a class that only runs at test time, but that's what Test.IsRunningTest() is for, and it's a good way to accomplish your goal as I understand it.

  • 1
    Note the same issue applies when testing a Database.Batchable too. For that case I had a logger interface that I was passing into the Database.Batchable and could use a fake created by the test to sense what was going on inside. – Keith C Oct 2 '13 at 7:44

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.