Apex Mock Objects, Spies and Stubs - A Simple Mocking framework for Apex (Salesforce)
Amoss provides a simple interface for implementing Mock, Test Spy and Stub objects (Test Doubles) for use in Unit Testing.
It's intended to be very straightforward to use, and to result in code that's even more straightforward to read.
As a simple example, the following example:
- Creates a Test Double of the class
DeliveryProvider
- Configures the methods
canDeliver
andscheduleDelivery
to always returntrue
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.willReturn( true )
.also().when( 'scheduleDelivery' )
.willReturn( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
It also provides an interface for creating and registering an HttpCalloutMock
in a simple and easy to read format.
For example:
Amoss_Instance httpCalloutMock = new Amoss_Instance();
httpCalloutMock
.isACalloutMock()
.when()
.method( 'GET' )
.endpoint().containing( 'account/' )
.respondsWith()
.status( 'Complete' )
.statusCode( 200 )
.body( new Map<String,Object>{ 'Name' => 'The account name' } )
.also().when()
.method( 'POST' )
.respondsWith()
.status( 'Not Found' )
.statusCode( 404 );
This documetation page covers the fundamentals of building Test Doubles. For documentation on creating HttpCalloutMocks
, see here..
If you are familar with using SFDX, the ant migration tool or using a local IDE, It is recommended that you either clone this repository or download a release from the release section, copy the Amoss files you require, and install them using your normal mechanism.
Alternatively, Amoss is available as an Unlocked Package, and the 'currently published' version based this branch can be installed (after setting the default org), using:
sfdx force:package:install --package "amoss@1.2.0-0"
You should note that this may not be the most recent version that exists on this branch. There are times when the most recent version has not been published as an Unlocked Package Version. In addition, the Unlocked Package contains the amoss_main
and amoss_test
files, though does not include amoss_examples
.
Links to the release notes, and any changes pending release can be found at the end of this file.
If you are not familiar with the SFDX commands, then it is recommended that you read the documentation here: https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference_force_package.htm
For Dev Instances or Production, the Unlocked Package can be installed via:
For all other instances:
As a final option, you can install directly from this page, using the following 'Deploy to Salesforce' button.
If running from the branch 'main', you should enter 'main' into the 'Branch/Tag/Commit:' field of 'Salesforce Deploy'.
This is because of a bug in that application that incorrectly selects the default branch as 'master' (afawcett/githubsfdeploy#43)
Be aware that this will grant a third pary Heroku app access to your org. I have no reason to believe that 'Andy in the Cloud' (https://andyinthecloud.com/about/) will do anything malicious with that access, though you should always be aware that there are risks when you grant access to an org, and using such an app is entirely at your own risk.
Amoss consists of 3 sets of classes:
Amoss_
- The only parts that are necessary - the framework itself.
AmossTest_
- The tests for the framework, and their supporting files. Required if you want to enhance Amoss, not so useful otherwise, but worth keeping if you can, just in case. All
Amoss_
classes are defined as@isTest
, meaning they do not count towards code coverage or Apex lines of codes.
- The tests for the framework, and their supporting files. Required if you want to enhance Amoss, not so useful otherwise, but worth keeping if you can, just in case. All
AmossExample_
- Example classes and tests, showing simple use cases for Amoss in a runnable format. There is no reason to keep these in your code-base once you've read and understand them.
Amoss can be used to build simple stub objects - AKA Configurable Test Doubles, by:
- Constructing an
Amoss_Instance
, passing it the type of the class you want to make a double of. - Asking the resulting 'controller' to generate a Test Double for you.
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
The result is an object that can be used in place of the object being stubbed.
Every non-static public method on the class is available, and when called, will do nothing.
If a method has a return value, then null
will be returned.
You then use the Test Double as you would any other instance of the object.
In this example, we're testing scheduleDelivery
on the class DeliveryOrder
.
The method takes a DeliveryProvider
as a parameter, calls methods against it and then returns true
when the delivery is successfully scheduled:
Test.startTest();
DeliveryOrder order = new DeliveryOrder().setDeliveryDate( deliveryDate ).setDeliveryPostcode( deliveryPostcode );
Boolean scheduled = order.scheduleDelivery( deliveryProviderDouble );
Test.stopTest();
System.assert( scheduled, 'scheduleDelivery, when called for a DeliveryOrder that can be delivered, will check the passed provider if it can deliver, and return true if the order can be' );
What we need to do, is configure our Test Double version of DeliveryProvider
so that tells the order that it can deliver, and then allows the order to schedule it.
We can configure the Test Double, so that certain methods return certain values by calling when
, method
andwillReturn
against the controller.
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.willReturn( true )
.also().when( 'scheduleDelivery' )
.willReturn( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
Now, whenever either canDeliver
or scheduleDelivery
are called against our double, true
will be returned. This is regardless of what parameters have been passed in.
If we want our Test Double to be a little more strict about the parameters it recieves, we can also specify that methods should return particular values only when certain parameters are passed by using methods like withParameter
, thenParameter
:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.withParameter( deliveryPostcode )
.thenParameter( deliveryDate )
.willReturn( true )
.also().when( 'canDeliver' )
.willReturn( false );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
We can also use a 'named parameter' notation, by using the methods withParameterNamed
and setTo
:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.willReturn( true )
.also().when( 'canDeliver' )
.willReturn( false );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
If we want to be strict about some parameters, but don't care about others we can also be flexible about the contents of particular parameters, using withAnyParameter
, and thenAnyParameter
:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.withParameter( deliveryPostcode )
.thenAnyParameter()
.willReturn( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
Or, when using 'named notation', we can simply omit them from our list:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode ) // since we don't mention 'deliveryDate', it can be any value
.willReturn( true )
.also().when( 'canDeliver' )
.willReturn( false );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
This is very useful for making sure that our tests only configure the Test Doubles to care about the parameters that are important for the tests we are running, making them less brittle.
There are several ways of specifying the expected values of parameters, and the details are covered later in the documentation.
The controller can then be used as a Test Spy, allowing us to find out what values where passed into methods by using latestCallOf
and call
and get().call()
:
System.assertEquals( deliveryPostcode, deliveryProviderController.latestCallOf( 'canDeliver' ).parameter( 0 )
, 'scheduling a delivery, will call canDeliver against the deliveryProvider, passing the postcode required, to find out if it can deliver' );
System.assertEquals( deliveryDate, deliveryProviderController.call( 0 ).of( 'canDeliver' ).parameter( 1 )
, 'scheduling a delivery, will call canDeliver against the deliveryProvider, passing the date required, to find out if it can deliver' );
Much like when we set the expected parameters, we can also name the parameters:
System.assertEquals( deliveryPostcode, deliveryProviderController.latestCallOf( 'canDeliver' ).parameter( 'postcode' )
, 'scheduling a delivery, will call canDeliver against the deliveryProvider, passing the postcode required, to find out if it can deliver' );
System.assertEquals( deliveryDate, deliveryProviderController.call( 0 ).of( 'canDeliver' ).parameter( 'deliveryDate' )
, 'scheduling a delivery, will call canDeliver against the deliveryProvider, passing the date required, to find out if it can deliver' );
This allows us to check that the correct parameters are passed into methods when there are no visible effects of those parameters, and to do so in a way that follows the standard format of a unit test.
A very similar configuraiton syntax can be used to define the Test Double as a self validating Mock Object using expects
and verify
:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.expects( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.returning( true )
.then().expects( 'scheduleDelivery' )
.withParameter( deliveryPostcode ) // once again, either syntax is fine
.thenParameter( deliveryDate )
.returning( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
deliveryProviderController.verify();
When done, the Mock will raise a failure if any method other than those specified are called, or if any method is called out of sequence.
verify
then checks that every method that was expected, was called, ensuring that all the expecations were met.
This can be very useful if the order and completeness of processing is absolutely vital.
A single object can be used as both a Mock and a Test Spy at the same time:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.expects( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.returning( true )
.also().when( 'scheduleDelivery' )
.willReturn( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
deliveryProviderController.verify();
System.assertEquals( deliveryPostcode, deliveryProviderController.latestCallOf( 'scheduleDelivery' ).parameter( 'postcode' )
, 'scheduling a delivery, will call scheduleDelivery against the deliveryProvider, passing the postcode required' );
This will then allow scheduleDelivery
to be called at any time (and any number of times), but canDeliver
must be called with the stated parameters, and must be called exactly once.
If you like the way Test Spies have clear assertions, but don't want just any method to be allowed to be called on your Test Double, you can use allows
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.allows( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.returning( true )
.also().allows( 'scheduleDelivery' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.returning( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
This means that canDeliver
and scheduleDelivery
can be called in any order, and do not have to be called, but that only these two methods with these precise parameters can be called, and any other method called against the Test Double will result a test failure.
Defining a controller as isFluent
will ensure that all otherwise unspecified method calls will return an instance of the generated Test Double.
For example, given that AmossTest_ClassToDouble
has a method fluentMethod
that has a return type of AmossTest_ClassToDouble
, the following is true:
Amoss_Instance classController = new Amoss_Instance( AmossTest_ClassToDouble.class );
classController
.isFluent();
AmossTest_ClassToDouble classDouble = (AmossTest_ClassToDouble)classController.getDouble();
System.assertEquals( classDouble, classDouble.fluentMethod() );
The specification of any when
, allows
or expects
against a method will override the return value of that method for the specified parameter configuration.
For example, the following is true:
Amoss_Instance classController = new Amoss_Instance( AmossTest_ClassToDouble.class );
classController
.isFluent()
.when( 'fluentMethod' )
.returns( null );
AmossTest_ClassToDouble classDouble = (AmossTest_ClassToDouble)classController.getDouble();
System.assertEquals( null, classDouble.fluentMethod() );
This is the case even if no return is specified for the method. For example, the following is true:
Amoss_Instance classController = new Amoss_Instance( AmossTest_ClassToDouble.class );
classController
.isFluent()
.when( 'fluentMethod' );
AmossTest_ClassToDouble classDouble = (AmossTest_ClassToDouble)classController.getDouble();
System.assertEquals( null, classDouble.fluentMethod() );
It should be noted that if a method is called that has an incompatible type, then a "System.TypeException: Invalid conversion from runtime type..." exception will be thrown. Currently, there is no way for Amoss to detect and stop this exception from occurring. If Salesforce provides the capabiliy to stop this from occurring in the future, then the library will be updated to more helpfully describe the issue, or stop it from occurring.
If a new controller is cloned from a pre-existing one (I.E. by using createClone
), or multiple Test Doubles are generated (I.E. by using multiple calls to generateDouble
against the same controller), each instance will continue to return the appropriate this
from each fluent method.
For example, the following is true:
Amoss_Instance classToDoubleController = new Amoss_Instance( AmossTest_ClassToDouble.class );
classToDoubleController
.isFluent();
AmossTest_ClassToDouble classToDouble1 = (AmossTest_ClassToDouble)classToDoubleController.getDouble();
AmossTest_ClassToDouble classToDouble2 = (AmossTest_ClassToDouble)classToDoubleController.generateDouble();
Test.startTest();
AmossTest_ClassToDouble returnFromDouble1 = classToDouble1.fluentMethod();
AmossTest_ClassToDouble returnFromDouble2 = classToDouble2.fluentMethod();
Test.stopTest();
System.assertEquals( classToDouble1, returnFromDouble1 );
System.assertEquals( classToDouble2, returnFromDouble2 );
System.assertNotEquals( returnFromDouble1, returnFromDouble2 );
And, the following is true:
Amoss_Instance classToDoubleController1 = new Amoss_Instance( AmossTest_ClassToDouble.class );
classToDoubleController1
.isFluent();
Amoss_Instance classToDoubleController2 = classToDoubleController1.createClone();
AmossTest_ClassToDouble classToDouble1 = (AmossTest_ClassToDouble)classToDoubleController1.getDouble();
AmossTest_ClassToDouble classToDouble2 = (AmossTest_ClassToDouble)classToDoubleController2.getDouble();
Test.startTest();
AmossTest_ClassToDouble returnFromDouble1 = classToDouble1.fluentMethod();
AmossTest_ClassToDouble returnFromDouble2 = classToDouble2.fluentMethod();
Test.stopTest();
System.assertEquals( classToDouble1, returnFromDouble1 );
System.assertEquals( classToDouble2, returnFromDouble2 );
System.assertNotEquals( returnFromDouble1, returnFromDouble2 );
Stating that byDefaultMethodsReturn
will set the default value of any method calls that are not otherwise specified against the controller.
For example, given that AmossTest_ClassToDouble
has a method methodUnderDouble
that has a return type of String
, the following is true:
Amoss_Instance classController = new Amoss_Instance( AmossTest_ClassToDouble.class );
classController
.byDefaultMethodsReturn( 'ThisDefaultValue' );
AmossTest_ClassToDouble classDouble = (AmossTest_ClassToDouble)classController.getDouble();
System.assertEquals( 'ThisDefaultValue', classDouble.methodUnderDouble( '1', 2 ) );
As with isFluent
, the specification of any when
, allows
or expects
against a method will override the return value of that method for the specified parameter configuration. This is true whether a return is specified for the method or not.
For example, the following is true:
Amoss_Instance classController = new Amoss_Instance( AmossTest_ClassToDouble.class );
classController
.byDefaultMethodsReturn( 'ThisDefaultValue' );
.when( 'methodUnderDouble' );
AmossTest_ClassToDouble classDouble = (AmossTest_ClassToDouble)classController.getDouble();
System.assertEquals( null, classDouble.methodUnderDouble( '1', 2 ) );
All of the below can be used with either withParameter
or withParameterNamed
.
In general, will check that the expected and passed values are the same instance, unless object specific behaviour has been defined.
It is probably the most common method of checking values, particularly when you care that the Sobjects / Objects / collections are the same instance and therefore may be mutated by the methods correctly - for example, when you are testing trigger handlers that aim to mutate the trigger context variables.
In detail, it checks that the passed parameter:
-
If an Sobject / List / Set / Map, equals the expected, as per the behaviour of '===', being:
- That the expected and passed objects are the same instance.
-
Otherwise, as per the behaviour of '==', being:
- If the parameter is a primitive - that the value is the same.
- If the parameter is an Object that does not implement
equals
:- That the expected and passed objects are the same instance.
- If the parameter is an Object that does implement
equals
:- That the return of
equals
is true.
- That the return of
Note: the specification of withParmeter( value )
is shorthand for withParameter().setTo( value )
.
Attempts to check that the expected and passed values evaluate to the same value, regardless of whether they are the same instance.
Used when you don't have access to the instances that are likely to be passed, or if it is unimportant that the objects are new instances. For example, it may be used to check the values of parameters where the objects are constructed within the method under test.
In detail, it checks that the expected and passed values equal each other when serialised as JSON strings.
You should note that this may not be reliable in all situations, but should suffice for the majority of use cases.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().setToTheSameValueAs( anObject )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).setToTheSameValueAs( anObject )
.willReturn( 'theReturn' );
Attempts to check that the passed value is set to a not null value.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().set()
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).set()
.willReturn( 'theReturn' );
Checks that the passed value is a String, which contains the given String - matching in a case sensitive way.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().containing( 'AnExpectedString' )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).containing( 'AnExpectedString' )
.willReturn( 'theReturn' );
Checks that the passed value is a String, which fully 'matches' the given regular expression - matching in a case sensitive way.
Note that the whole of the String must match the regular expression, rather than a fragment of string matching, as per the behaviour of Matcher.matches
.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().matching( 'OPP-[0-9]+' )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).matching( 'OPP-[0-9]+' )
.willReturn( 'theReturn' );
Used to check the field values of sObjects when only some of the fields are important. For example, you may check that certain fields are populated by the method under test before passing them into the method being doubled. This allows you to specify the fields that will be set without concerning your test with the other values, which will be incidental.
withFieldsSetLike
- Receives an sObjectwithFieldsSetTo
- Receives aMap<String,Object>
For each of the properties set on the 'expected' object, the passed sObject is checked. Only if all the specified properties match will the passed object 'pass'.
The passed object may have more properties set, and they can have any value.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().withFieldsSetLike( new Contact( FirstName = 'theFirstName', LastName = 'theLastName' ) )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'theFirstName', 'LastName' => 'theLastName' } )
.willReturn( 'theReturn' );
Used to check that a parameter is a list that consists of the specified number of elements.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().aListOfLength( 1 )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).aListOfLength( 2 )
.willReturn( 'theReturn' );
Used to check that a parameter is a list that contains any of the elements passing the specified condition. It can be used with any of the matching methods that you can use directly on the parameter (e.g. setTo
, setToTheSameValueAs
, etc), with the exception of the other list comparisons (I.E. you cannot check a list within a list. Yet).
withAnyElement
- Requires a further condition to be defined.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().withAnyElement().setTo( 'expectedString' )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).withAnyElement().withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'theFirstName', 'LastName' => 'theLastName' } )
.willReturn( 'theReturn' );
Used to check that a parameter is a list where all of the elements pass the specified condition. It can be used with any of the matching methods that you can use directly on the parameter (e.g. setTo
, setToTheSameValueAs
, etc), with the exception of the other list comparisons (I.E. you cannot check a list within a list. Yet).
withAllElements
- Requires a further condition to be defined.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter().withAllElements().setTo( 'expectedString' )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' ).withAllElements().withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'theFirstName', 'LastName' => 'theLastName' } )
.willReturn( 'theReturn' );
Used to check that a parameter is a list where the element at the given position passes the specified condition. It can be used with any of the matching methods that you can use directly on the parameter (e.g. setTo
, setToTheSameValueAs
, etc), with the exception of the other list comparisons.
withElementAt
- Requires an element position to be defined, followed by a further condition.
Examples:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter()
.withElementAt( 0 ).setTo( 'expectedString-number1' )
.withElementAt( 1 ).setTo( 'expectedString-number2' )
.willReturn( 'theReturn' );
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterNamed( 'parameterName' )
.withElementAt( 0 ).withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'Person1' } )
.withElementAt( 1 ).withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'Person2' } )
.willReturn( 'theReturn' );
All of the list based specifications can be combined, providing the opportunity to create quite complex parameter checking with simple structures.
For example, you may want to check that every Contact in a list has the 'PersonAccount' flag set, and then also check the particular Names of the Contacts at the same time, maybe to ensure they are passed in a particular order. You might do that with:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameter()
.withAllElements().withFieldsSetTo( new Map<String,Object>{ 'IsPersonAccount' => true } )
.withElementAt( 0 ).withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'Person1' } )
.withElementAt( 1 ).withFieldsSetTo( new Map<String,Object>{ 'FirstName' => 'Person2' } )
.willReturn( 'theReturn' );
If the standard means of verifying parameters doesn't give you the level of control you need, you might consider writing a custom verifier.
Before you do so, you might consider if you would be better served by making your Test Double's behaviour less specific and using it as a Test Spy (check parameters after the call). Doing so will likely result in a more readable test that is less brittle.
That said, if you need to, you can implement your own implementations of Amoss_ValueVerifier
and pass it into the specification using verifiedBy
.
For example:
classToDoubleController
.when( 'objectMethodUnderDouble' )
.withParameterName( 'parameter1' ).verifiedBy( customVerifier )
.willReturn( 'theReturn' );
An implementation must implement two methods:
toString
- A string representation that will be used when describing the expected call in a failed verify call against the Test Double's controller.verify
- The method that will check the given value 'matches' the expected.
verify
is used in two ways:
- Checking that a method call matches an expectation, and ultimately issuing a failing assertion if it doesn't.
- Checking if a 'when' is applicable for a particular method call.
In order to ensure that that the method works in both situations, verify
should check that the passed given value passes verification, by reporting any via throwing an exception of one the following types:
* Amoss_Instance.Amoss_AssertionFailureException
* Amoss_Instance.Amoss_EqualsAssertionFailureException
These exceptions are then either caught and resolved as a 'mis-match', or converted into a failed assertion.
When the Exception is raised, setAssertionMessage
should be called to clearly define the failure.
E.g.
throw new Amoss_Instance.Amoss_AssertionFailureException()
.setAssertionMessage( 'Value should be a Map indexed by Date, containing if each is a bank holiday' )
// or some other complex check
When using Amoss_EqualsAssertionFailureException, setExpected and setActual should also be set, with the values being relevant within the context of the stated assertionMessage.
E.g.
throw new Amoss_Instance.Amoss_EqualsAssertionFailureException()
.setExpected( 'Map<Date,Boolean>' )
.setActual( actualType )
.setAssertionMessage( 'Value should be a Map indexed by Date, containing if each is a bank holiday. Was not the expected Type.' )
(Note that setAssertionMessage
returns a Amoss_AssertionFailureException
, so it is easiest to order the method calls this way round)
If other verifiers are used within a custom verifier, any Amoss_AssertionFailureExceptions
can be caught and
have context added to the failure by calling addContextToMessage against the exception before re-throwing.
Care should be taken to ensure that no exceptions other than Amoss_AssertionFailureExceptions
and its subclasses are
thrown. This ensures that failures are clearly reported to the user.
In addition, no calls to System.assert
or its variations should be made directly in this method otherwise unexpected behaviours may result, particularly when using the 'when' and 'allows' syntax.
In some situations it is not enough to define a response statically in this way.
For example, you may require a response that is based on the values of a passed in parameter in a more dynamic way - like the Id of an Sobject that is passed in.
Also, you want to take advantage of some of the parameter checking and verify mechanisms of the framework for existing tests that currently use the standard Salesforce StubProvider
.
For those situations, you can use handledBy
in order to specify an object that will handle the call, perform processing and generate a return.
This method can take one of two types of parameter:
StubProvider
- Providing the full capabilities of the StubProvider interface means that you can re-use any pre-existing test code, as well as write new classes that utilise the full set of parameters (methodName
,parameterTypes
, etc), if so required.Amoss_MethodHandler
- A much simpler version of aStubProvider
-like interface means that you can create handler methods that are focused entirely on the parameter values of the called method.
For example, you are testing a method that uses a method on another object (ClassBeingDoubled.getContactId
) to get the Id from a Contact. This method has a single parameter - the Contact.
You may implement this be defining an implementation of Amoss_MethodHandler and using that in your Test Double's definition:
class ExampleMethodHandler implements Amoss_MethodHandler {
public Object handleMethodCall( List<Object> parameters ) {
Contact passedContact = (Contact)parameters[0];
return passedContact.Id;
}
}
@isTest
private static void methodBeingTested_whenGivenSomething_doesSomething() {
Amoss_MethodHandler methodHander = new ExampleMethodHandler();
Amoss_Instance objectBeingDoubledController = new Amoss_Instance( ClassBeingDoubled.class );
objectBeingDoubledController
.when( 'getContactId' )
.withAnyParameter()
.handledBy( methodHander );
...
Notice that you can still use withParameter
and the related methods in order to specify the situations in which the handler will be called, as well as any of the other expects
, allows
and spy capabilities.
Alternatively, you may define the handler class using the StubProvider
interface.
E.g.
class ExampleMethodHandler implements StubProvider {
public Object handleMethodCall( Object mockedObject,
String mockedMethod,
Type returnType,
List<Type> parameterTypes,
List<String> parameterNames,
List<Object> parameters ) {
Contact passedContact = (Contact)parameters[0];
return passedContact.Id;
}
}
@isTest
private static void methodBeingTested_whenGivenSomething_doesSomething() {
StubProvider methodHander = new ExampleMethodHandler();
Amoss_Instance objectBeingDoubledController = new Amoss_Instance( ClassBeingDoubled.class );
objectBeingDoubledController
.when( 'getContactId' )
.withAnyParameter()
.handledBy( methodHander );
...
That is, handledBy
is overloaded, and can take either definition type. The behaviours being identical to each other.
Test Doubles can also be told to throw exceptions, using throwing
, throws
or willThrow
:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.when( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.throws( new DeliveryProvider.DeliveryProviderUnableToDeliverException( 'DeliveryProvider does not have a delivery slot' ) );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
In some situations you may want to create a Mock Object that ensures that no calls are made against it.
For that, you can use expectsNoCalls
.
This method cannot be used in conjunction with any other method definition (expects
, allows
, when
). If an attempt is made to do so, an exception is thrown.
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.expectsNoCalls();
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
...
It is valid to call verify
against the controller at the end of the test, but this will always pass since the expected call stack will always be empty.
There are two mechanisms for retrieving the Test Double from the controller:
getDouble
generateDouble
In must situations, you will only ever retrieve a single double from a single controller, and in that instance there is no functional difference between getDouble
and generateDouble
.
The functional difference only appears on the second and subsequent calls to these methods.
Will return the same Test Double as the previous call to either getDouble
or generateDouble
.
That is, the following is true:
Amoss_Instance classToDoubleController = new Amoss_Instance( AmossTest_ClassToDouble.class );
AmossTest_ClassToDouble classToDouble1 = (AmossTest_ClassToDouble)classToDoubleController.getDouble();
AmossTest_ClassToDouble classToDouble2 = (AmossTest_ClassToDouble)classToDoubleController.getDouble();
System.assertEquals( classToDouble1, classToDouble2 );
There is not normally any reason to call getDouble
multiple times, since you can, of course, store the result of the first getDouble
call in a variable and use that variable multiple times.
Will return a new instance of the Test Double, tied to the same Amoss_Instance
as any previously generated Test Doubles.
That is, the following is true:
Amoss_Instance classToDoubleController = new Amoss_Instance( AmossTest_ClassToDouble.class );
AmossTest_ClassToDouble classToDouble1 = (AmossTest_ClassToDouble)classToDoubleController.getDouble();
AmossTest_ClassToDouble classToDouble2 = (AmossTest_ClassToDouble)classToDoubleController.generateDouble();
System.assertNotEquals( classToDouble1, classToDouble2 );
classToDouble1.methodUnderDouble( '1', 2 );
classToDouble2.methodUnderDouble( '1', 2 );
System.assertEquals( 2, classToDoubleController.countOf( 'methodUnderDouble' ) );
Any Amoss_Instance
can be cloned in order to create an independent controller that can then generate its own Test Doubles. The cloned controller will have the same initial configuration as the controller it was cloned from, including any defined when
, allows
and expects
behaviours.
However, once cloned, there is no link between the original and the cloned controller and they do not interact in any way.
That is, the following is true:
Amoss_Instance classToDoubleController1 = new Amoss_Instance( AmossTest_ClassToDouble.class );
Amoss_Instance classToDoubleController2 = classToDoubleController1.createClone();
AmossTest_ClassToDouble classToDouble1 = (AmossTest_ClassToDouble)classToDoubleController1.getDouble();
AmossTest_ClassToDouble classToDouble2 = (AmossTest_ClassToDouble)classToDoubleController2.getDouble();
System.assertNotEquals( classToDouble1, classToDouble2 );
classToDouble1.methodUnderDouble( '1', 2 );
classToDouble2.methodUnderDouble( '1', 2 );
System.assertEquals( 1, classToDoubleController1.countOf( 'methodUnderDouble' ) );
System.assertEquals( 1, classToDoubleController2.countOf( 'methodUnderDouble' ) );
Occassionally you may find that the definition of the method name gets swamped by the other definitions. In that situation you may want to use the slightly longer form when().method( methodName )
, to more clearly highlight the methods being defined.
For example:
Amoss_Instance deliveryProviderController = new Amoss_Instance( DeliveryProvider.class );
deliveryProviderController
.expects()
.method( 'canDeliver' )
.withParameterNamed( 'postcode' ).setTo( deliveryPostcode )
.andParameterNamed( 'deliveryDate' ).setTo( deliveryDate )
.returning( true )
.also().when()
.method( 'scheduleDelivery' )
.willReturn( true );
DeliveryProvider deliveryProviderDouble = (DeliveryProvider)deliveryProviderController.getDouble();
The method
variation is available for all three method definition scenarios: when
, allows
and expects
.
With such flexibility, it's important that you make good decisions on which behaviours to use, and when.
The decision will be based on a balance of two main factors:
- Ensuring that you produce a meaningful test of the behaviour, whilst
- Limiting the scope of test changes that are required when the implementation of the classes that are being stubbed or tested change.
The following aims to describe when to each of the constructs, roughly referencing the types of "Test Doubles" that are described in Gerard Meszaros's book "xUnit Test Patterns".
Is the least brittle of the constructs, allowing any method to be called, potentially with any parameters. In most cases, some return values for some methods will be specified.
Typically used to replace ('stub out') an object that is not the focus of the test, but on which the test relies, often in order to direct the object under test into a specificaly required behaviour.
That is, the object provides some functionality that means that the test can run, but it is not the calling of methods on this object that define the behaviour that is being tested.
However, it is likely that the test will check that the method under tests acts in a particular way when it receives certain values from the methods being stubbed.
It is of particular note that when implemented using 'withAnyParameter' (or without parameters being specified), the method signatures of the object being stubbed can change without impacting the tests.
testDoubleController
.when( 'methodName1' )
.withAnyParameters() // this is actually redundant, as the default behaviour is 'withAnyParameters'
.willReturn( true )
.also().when( 'methodName2' )
.withAnyParameters()
.willReturn( true );
If return values do not need to be specified, may be as simple as:
ObjectUnderTestDouble testDouble = (ObjectUnderTestDouble)( new Amoss_Instance( ObjectUnderTestDouble.class ).getDouble() );
- Additions to the interface of the object being stubbed will not break the test, unless particular return values are required in the test.
- Changes to the interface of existing methods will not break tests when
withAnyParameters
orwithAnyParameter
are used and the parameters do not need to be reflected in the return values. - Changes to the interface of existing methods may break tests when parameter values are specified in the configuration.
- Generally not affected by changes the implementation of the method under test that affect the order of processing in, or number of method calls made by the method under test.
- Is affected by changes in the implementation of the method under test where the change results in different values being returned by the methods being stubbed.
Similar to a Test Stub, although is defined in such a way that only the methods that are configured are allowed to be called.
Also typically used when the object being stubbed is not the focus of the test, and potentially the parameter values being passed in are not of importance.
However, it is implied that it is important that other methods, those not specified, are not called.
Is not particularly brittle to changes in the implementation of the class being 'stubbed'. However, tests may start to break when the implementation under test changes and new methods are called against that object.
testDoubleController
.allows( 'methodName1' )
.withAnyParameters()
.returning( true )
.also().allows( 'methodName2' )
.withAnyParameters()
.returning( true );
- Additions to the interface of the object being stubbed will break the test if those methods are called by the method under test.
- Changes to the interface of existing methods will not break tests when
withAnyParameters
orwithAnyParameter
are used and the parameters do not need to be reflected in the return values. - Changes to the interface of existing methods may break tests when parameter values are specified in the configuration.
- Generally not affected by changes to the order of processing in, or number of method calls made by the method under test.
- Is affected by changes in the implementation of the method under test where the change results in additional methods being called against the object being stubbed.
- Is affected by changes in the implementation of the method under test where the change results in different values being returned by the methods being stubbed.
Is specified intially in the same way as a Test Stub, though after the method under test is executed, the controller is then interrogated to determine the value of parameters.
Typically used to create a Test Double of an object that is the focus of the test.
That is, the test is checking that the method under test calls particular methods on the given Test Double passing parameters with certain values that are predictable.
Can be used to test that individual methods are called, and the order in which that particular method is called. However, cannot be used to test that different methods are called in a particular sequence. In order to do that, a Mock Object (see below) is required.
spiedUponObjectController
.when( 'methodName1' )
.withAnyParameters()
.willReturn( true )
.also().when( 'methodName2' )
.withAnyParameters()
.willReturn( true );
// followed by
System.assertEquals( 'expectedParameterValue1',
spiedUponObjectController.latestCallOf( 'method1' ).parameter( 'parameter1' ),
'methodUnderTest, when called will pass "expectedParameterValue1" into "method1"' );
System.assertEquals( 'expectedParameterValue2',
spiedUponObjectController.call( 0 ).of( 'method2' ).parameter( 'parameter2' ),
'methodUnderTest, when called will pass "expectedParameterValue2" into "method2"' );
Similar to a Test Spy, although is defined in such a way that only the methods that are configured are allowed to be called.
As with the Test Spy, is used to create a Test Double an object that is the focus of the test.
That is, the test is checking that the method under test calls particular methods on the given Test Double passing parameters with certain values that are predictable.
Can be used to test that individual methods are called, and the order in which that particular method is called. However, cannot be used to test that different methods are called in a particular sequence. In order to do that, a Mock Object (see below) is required.
It is implied that it is important that other methods, those not specified, are not called.
spiedUponObjectController
.allows( 'methodName1' )
.withAnyParameters()
.willReturn( true )
.also().allows( 'methodName2' )
.withAnyParameters()
.willReturn( true );
// followed by
System.assertEquals( 'expectedParameterValue1',
spiedUponObjectController.latestCallOf( 'method1' ).parameter( 'parameter1' ),
'methodUnderTest, when called will pass "expectedParameterValue1" into "method1"' );
System.assertEquals( 'expectedParameterValue2',
spiedUponObjectController.call( 0 ).of( 'method2' ).parameter( 'parameter2' ),
'methodUnderTest, when called will pass "expectedParameterValue2" into "method2"' );
Similar to a Test Spy, although is defined in such a way that only the methods that are configured are allowed to be called, and only in the order that they are specified.
If any specified method is called out of order, or with the wrong parameters, it will fail the test.
Is therefore used to 'mock' an object when the order of execution of different methods is important to the success of the test.
It is also implied that it is important that other methods, those not specified, are not called.
Because of the strict nature of the specification, this is the most brittle of the constructs, and often results in tests that fail when the implementation of the method under test is altered.
mockObjectController
.expects( 'methodName1' )
.withParameter( 'expectedParameterValue1' )
.willReturn( true )
.then().expects( 'methodName2' )
.withParameter( 'expectedParameterValue2' )
.willReturn( true );
// followed by
mockObjectController.verify();
In all cases, 'willReturn' or 'returning' could be replaced with 'throws' or 'handledBy' without changing the categorisation of the Test Double in question.
Type | Use Cases | Brittle? | Construct Pattern |
---|---|---|---|
Test Stub | Ancillary objects, parameters passed are not the main focus of the test | Least brittle | when.with.willReturn |
Strict Test Stub | Ancillary objects, parameters passed are not the main focus of the test | Brittle to addition of new calls on object being stubbed | allows.with.returning |
Test Spy | Focus of the test, order of execution is not important, prefer the assertion syntax | Is brittle to the interface of the object being stubbed, less brittle to the implementation of the method under test | when.with.willReturn, call.of.parameter |
Strict Test Spy | Focus of the test, order of execution is not important, prefer the assertion syntax | Is brittle to the interface of the object being stubbed and addition of new calls on object being stubbed, a little more brittle to the implementation of the method under test | allows.with.returns, call.of.parameter |
Mock Object | Focus of the test, order of execution is important | Most brittle construct, brittle to the implementation of the method under test | expect.with.returning, verify |
Some of the functions have synonyms, allowing you to choose the phrasing that is most readable for your team.
Purpose | Synonyms |
---|---|
Specifying individual parameters (positional notation) | withParameter , thenParameter (I.E. start with withParameter, otherwise use thenParameter) |
Stating that any single parameter is allowed (positional notation) | withAnyParameter , thenAnyParameter (I.E. start with withAnyParameter, otherwise use thenAnyParameter) |
Specifying individual parameters (named notation) | withParameterNamed , andParameterNamed (I.E. start with withParameterNamed, otherwise use andParameterNamed) |
Stating the return value of a method | returning , returns , willReturn |
Stating that a method throws an exception | throwing , throws , willThrow |
Start the specification of an additional method | then , also (Generally use 'then' with 'expects', 'also' with 'when' and 'allows' ) |
Retrieving the parameters from a particular call of a method | call , get().call |
Retrieving the parameters from the latest call of a method | latestCallOf , call( -1 ).of |
Since the codebase uses that Salesforce provided StubProvider as its underlying mechanism for creating the Test Double, it suffers from the same fundamental limitations.
Primarily these are:
- The following cannot have Test Doubles generated:
- Sobjects
- Classes with only private constructors (e.g. Singletons)
- Inner Classes
- System Types
- Batchables
- Static and Private methods may not be stubbed / spied or mocked.
- Member variables, getters and setters may not be stubbed / spied or mocked.
- Iterators cannot be used as return types or parameter types.
For more information, see here: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_interface_System_StubProvider.htm
Release notes for Amoss can be found here, and changes on this branch that are pending release into the Unlocked Package / Release Tags can be found here.
Thanks to Aidan Harding (https://twitter.com/AidanHarding), for kickstarting the whole process. If it wasn't for his post on an experiment he did https://twitter.com/AidanHarding/status/1276512814421639168, this project probably wouldn't have started.
You can find the repo with his experimental implementation here: https://github.com/aidan-harding/apex-stub-as-mock
Also to Martin Fowler for the beautifully succinct article referenced in that tweet - https://martinfowler.com/articles/mocksArentStubs.html
And Gerard Meszaros for his book "xUnit Test Patterns", from which many of the ideas of how Mocks, Spies and Test Doubles should work are taken.