Thursday, 23 June 2016

ApexMocks Capture Support

Capture Support

Today's post focuses on the new Capture feature in ApexMocks. This is an awesome contribution from my talented FinancialForce colleague Vincenzo Denti. This is another feature ported over from Mockito, filling out our ever growing unit testing toolbelt.

All code samples from this post can be found in this Github repo.

ApexMocks already allowed you to verify that a method was called a certain number of times, with a specific set of arguments.

Say you want to inspect one of those arguments to verify its state. You need to get a hold of the argument. In many cases, you supply the argument into the method under test, or you can stub the argument as a return value for a method.

Equally, there are instances when you don't have a reference to the method, perhaps because it is created inside the method under test. Maybe it generates a random token so you can't predict the value, or it mutates the object so that it is no longer equal to the Object you had a reference to.

Capturing exposes argument values for a particular method call, allowing you to:

  1. Perform further asserts.
  2. (Bonus) dump the argument while you write your unit test - making it easier to declare the expected argument values or debug failing tests.

Let's see this in action!

Example code

Let's construct a contrived sample application. The application is inspired by that classic disco tune "Get Down Saturday Night". You can thank me later for getting the song stuck in your head.

I've built this on top of ApexMocks and ApexCommon, so if you want to deploy this into an org you will have to deploy those first:

  1. ApexMocks
  2. ApexCommon


Let's take a look at the AwesomeService. This class has a dependency on the DiscoService class, which is injected using the Application factory pattern (discussed at length here). Given a user and a date, AwesomeService will either ask the DiscoService to assist the user in 'getting down' or notify the user by creating a DiscoService.Event.

AwesomeService
public with sharing class AwesomeService
{
    //Don't match to 'Sat' - instead format a known Saturday, to avoid localisation issues
    private static final String FMT = 'EEE';
    private static final String SATURDAY = Datetime.newInstance(1970, 1, 3).format(FMT);

    private static IDiscoService disco = (IDiscoService)Application.Service.newInstance(IDiscoService.class);
 
    public static void getDownSaturdayNight(User u, Datetime now)
    {
        if (now.format(FMT) == SATURDAY
            && u.LikesToParty__c //(everybody does)
            && u.CantWaitForTheWeekend__c)
        {
            disco.getDown(u);
        }
        else
         {
            DiscoService.Event event = new DiscoService.Event();
            event.EventTarget = u;
            event.EventType = 'Uncool';
            event.Message = 'It\'s hip to be a square';

            disco.notifyUser(event);
        }
    }
}

IDiscoService
public interface IDiscoService
{
    void getDown(User u);
    void notifyUser(DiscoService.Event event);
}

DiscoService.Event
public class Event
{
    public User EventTarget;
    public String EventType;
    public String Message;
}

Unit Tests

Unit testing the behaviour where the User is up for a party.
This is a simple enough case.

private static final DateTime KNOWN_SATURDAY = Datetime.newInstance(2000, 1, 1);

@isTest
private static void getDownSaturdayNight_getsDown_IfAppropriate()
{
    fflib_ApexMocks mocks = new fflib_ApexMocks();
    IDiscoService mockDisco = new Mocks.DiscoService(mocks);
    Application.Service.setMock(IDiscoService.class, mockDisco);

    //Given
    User u = new User(
        FirstName = 'Duff',
        LastName = 'Man',
        LikesToParty__c = true,
        CantWaitForTheWeekend__c = true
    );
 
    //When
    AwesomeService.getDownSaturdayNight(u, KNOWN_SATURDAY);

    //Then
    ((IDiscoService)mocks.verify(mockDisco)).getDown(u);
}

Unit testing the behaviour where the User refuses to party
private static final DateTime KNOWN_SUNDAY = KNOWN_SATURDAY.addDays(1);

@isTest
private static void getDownSaturdayNight_doesntGetDown_OnASchoolNight_WithMatchers()
{
    fflib_ApexMocks mocks = new fflib_ApexMocks();
    IDiscoService mockDisco = new Mocks.DiscoService(mocks);
    Application.Service.setMock(IDiscoService.class, mockDisco);

    //Given
    User u = new User(
        FirstName = 'Buzz',
        LastName = 'Killington',
        LikesToParty__c = false,
        CantWaitForTheWeekend__c = false
    );
 
    //When
    AwesomeService.getDownSaturdayNight(u, KNOWN_SUNDAY);

    //Then
    ((IDiscoService)mocks.verify(mockDisco)).notifyUser(matchesEvent( ??? )); //How do we verify the behaviour??
}

Captors to the rescue
We create an ArgumentCaptor, and supply it to the verify method. This brings the actual argument value into scope, allowing us to assert the state is as we expect it to be.

//Then
fflib_ArgumentCaptor argument = fflib_ArgumentCaptor.forClass(DiscoService.Event.class);
((IDiscoService)mocks.verify(mockDisco)).notifyUser((DiscoService.Event)argument.capture());

DiscoService.Event actual = (DiscoService.Event)argument.getValue();
System.assertEquals(actual.EventTarget, u);
System.assertEquals(actual.EventType, 'Uncool');
System.assertEquals(actual.Message, 'It\'s hip to be a square');

Alternatives

There are of course other ways to test this code.
  • Override equals/hashcode on the DiscoService.Event class - Example.
    • This approach relies on the vanilla ApexMocks matching behaviour - argument values are considered equal if their equals method says they are.
    • We leverage this by overriding equals and hashcodes on the DiscoService.Event class. 
    • This means we have to write and maintain extra production code for the benefit of test classes, which feels inherently wrong. We have to ensure that these methods work accurately as well, in case non-test code comes to rely on this behaviour.
  • Create a custom matcher - Example.
    • We can define an fflib_IMatcher instance, in which we can provide our own definition of whether or not argument values should be considered equal.
    • The trouble with this approach is that it is very verbose. It takes about 50 lines of code to achieve the same thing that captors achieved in 5 lines.

Conclusions

Captors are another tool in your arsenal of testing strategies. They can't be used to stub method return values, and in some situations it may be more appropriate to use matchers for behaviour verification (e.g. where a common matcher already exists).

But hopefully you can see the advantages they offer, and consider using them in your tests in the future.

1 comment: