Easier mocking in Apex (because the Stub API sucks)

The ability to mock out components is essential to writing quality tests, especially when you’re dealing with integrations or external API invocation. And let’s face it - the out-of-the-box Stub API provided in Apex is just plain awful. Here’s a lightweight utility I threw together to simplify mocking.

I’ll first go over how this thing works, but feel free to skip to the code if your name’s Linus or you just don’t feel like following along.

How to use it

Lets say we have some service class called FoxService that invokes an external REST API. Now lets say you’re trying to test some other class, FoxServiceConsumer, that consumes FoxService. Naturally, you can’t make callouts from a unit test, so we need to mock out FoxService in any test that would involve its use. Here’s how you’d do it with Stub:

First, we need to create an instance of the Stub class, which will be later be used to inject a mock FoxService instance into our consumer. You do this by passing the class that you wish to mock into the Stub constructor.

Stub stub = new Stub(FoxService.class);

Now we can configure the stub to return mock values. Here, we’re telling the stub to return 'Ring-ding-ding-ding-dingeringeding' whenever the whatDoesTheFoxSay method is invoked.

stub.setReturnValue('whatDoesTheFoxSay', 'Ring-ding-ding-ding-dingeringeding');

We can also configure the stub to throw an exception from a particular method. This is very useful for testing negative test cases. Here we’re telling the stub to throw a UnsupportedMammalException whenever whatDoesTheCowSay is invoked, but we can also omit the second argument and the stub will throw a generic exception.

stub.setException('whatDoesTheCowSay', new UnsupportedMammalException('Are you mocking me?'));

After our stub is configured, we need to inject its mock instance into the consuming class that we’re testing. Keep in mind that the FoxService instance on the consumer will need to be annotated with @TestVisible to accomplish this.

FoxServiceConsumer thatThingUsingTheFoxService = new FoxServiceConsumer();
thatThingUsingTheFoxService.foxService = (FoxService) stub.instance;

That’s it! Whenever thatThingUsingTheFoxService invokes foxService.whatDoesTheFoxSay(), it will bypass the actual method and return 'Ring-ding-ding-ding-dingeringeding'.

If we want, we can even verify what methods on the FoxService class were and were not invoked:

stub.assertInvoked('whatDoesTheFoxSay');
stub.assertNotInvoked('whatDoesTheCowSay');

Putting it all together

@IsTest()
private static void testFoxServiceConsumer() {
	// Create an instance of 'Stub' for the class we're trying to mock
	Stub stub = new Stub(FoxService.class);
	
	// Configure the stub to return a mock values from its methods
	stub.setReturnValue('whatDoesTheFoxSay', 'Ring-ding-ding-ding-dingeringeding');
	
	// Inject our stub into what consumes it
	FoxServiceConsumer thatThingUsingTheFoxService = new FoxServiceConsumer();
	thatThingUsingTheFoxService.foxService = (FoxService) stub.instance;
	
	// Run our test
	Test.startTest();
	thatThingUsingTheFoxService.doAllTheThings();
	Test.stopTest();
	
	// Assert expected results
	stub.assertInvoked('whatDoesTheFoxSay');
    stub.assertNotInvoked('whatDoesTheCowSay');
}

Notes and limitations

  • Due to limitations of the Salesforce Stub API, we’re unable to mock out static members
  • Its not possible, using the Salesforce Stub API, to mock out a single method on a class. This problem is inherited by the Stub class. If you mock out one method on a class, you’ll need to mock any other method that will be invoked on that class.
  • If a method is invoked on a stub instance that doesn’t have a configured return value, a NoReturnValueException is thrown, to ensures the scenario can be caught and remedied
  • The stub class currently does not differentiate between overloaded method signatures. This could certainly be implemented, but its usefulness is likely limited.

Show me the code!

/**
 * MIT License
 * 
 * Copyright (c) 2019 Restless Labs, LLC
 * 
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/**
 * A configurable class/sObject instance stub that utilizes the Salesforce Stub API to return values from a method or
 * throw a specific exception.
 *
 * Because the Stub API requires one method to handle all invocations and the Reflection API does not provide a means of
 * invoking methods by name, it is not possible to stub out individual methods.
 *
 * A stub will throw a NoReturnValueException from all non-void methods not configured explicitly to avoid unexpected
 * NullPointerExceptions.
 */
@IsTest
public class Stub implements StubProvider {

	// Default mock exception
	public static final MockException MOCK_EXCEPTION = new MockException('Mock Exception');


	public Type type { get; private set; }
	public Object instance { get; private set; }
	public Set<String> invokedMethods { private get; private set; }
	public Map<String, Exception> exceptions { private get; private set; }
	public Map<String, Object> returnValues { private get; private set; }

	/**
	 * Constructor.
	 * @param typeToMock The class/SObject type that will be mocked by this stub
	 */
	public Stub(Type typeToMock) {
		this.type = typeToMock;
		this.instance = Test.createStub(typeToMock, this);
		this.invokedMethods = new Set<String>();
		this.exceptions = new Map<String, Exception>();
		this.returnValues = new Map<String, Object>();
	}


	//==================================================================================================================
	// Stub Provider
	//==================================================================================================================

	/**
	 * Handles a stubbed method call, per the StubProvider interface, by throwing an exception or returning a value, depending
	 * on what was configured for the method. If both a return value and exception have been set, the exception takes precedence.
	 * @param stubbedObject The stubbed object on which a method was invoked
	 * @param stubbedMethodName The name of the method that was invoked
	 * @param returnType The return type of the method that was invoked
	 * @param listOfParamTypes An ordered list of parameter types on the method that was invoked
	 * @param listOfParamNames An ordered list of parameter names on the method that was invoked
	 * @param listOfArgs An ordered list of arguments passed to the method
	 */
	public Object handleMethodCall(Object stubbedObject, String stubbedMethodName, Type returnType, List<Type> listOfParamTypes, List<String> listOfParamNames, List<Object> listOfArgs) {
		this.invokedMethods.add(stubbedMethodName);

		// If an exception has been configured for the invoked method, throw it
		// otherwise, if a return value has been configured for the method, return it
		if (exceptions.containsKey(stubbedMethodName)) {
			throw exceptions.get(stubbedMethodName);
		} else if (returnValues.containsKey(stubbedMethodName)) {
			return returnValues.get(stubbedMethodName);
		}

		// No result has been configured for the invoked method
		// if return type is null (void), return null
		if (String.valueOf(returnType) == 'void') {
			return null;
		}

		// Non-void method without configured response, throw an exception
		throw new NoReturnValueException(String.format(
			'No return value or exception has been configured for method \'\'{0}\'\' on stubbed class \'\'{1}\'\'',
			new String[] { stubbedMethodName, String.valueOf(type) }
		));
	}


	//==================================================================================================================
	// Configuration
	//==================================================================================================================

	/**
	 * Sets the value to be returned from a specific stubbed method.
	 * @param methodName The name of a method
	 * @param returnValue The value to return from the specified method when invoked
	 */
	public void setReturnValue(String methodName, Object returnValue) {
		returnValues.put(methodName, returnValue);
	}

	/**
	 * Configures a specified method to throw the default mock exception.
	 * @param methodName The name of a method
	 */
	public void setException(String methodName) {
		setException(methodName, MOCK_EXCEPTION);
	}

	/**
	 * Sets the exception to be thrown from a specific stubbed method.
	 * @param methodName The name of a method
	 * @param exceptionToThrow The exception to be thrown from the specified method when invoked
	 */
	public void setException(String methodName, Exception exceptionToThrow) {
		exceptions.put(methodName, exceptionToThrow);
	}


	//==================================================================================================================
	// Assertions
	//==================================================================================================================

	/**
	 * Asserts that a method with the given name has been invoked.
	 * @param methodName The name of the method in question
	 */
	public void assertInvoked(String methodName) {
		if (!invokedMethods.contains(methodName)) {
			throw new MethodNotInvokedException(String.format(
				'Method {0}.{1}() not invoked',
				new String[] { type.getName(), methodName }
			));
		}
	}

	/**
	 * Asserts that a method with the given name has not been invoked.
	 * @param methodName The name of the method in question
	 */
	public void assertNotInvoked(String methodName) {
		if (invokedMethods.contains(methodName)) {
			throw new MethodInvokedException(String.format(
				'Method {0}.{1}() invoked',
				new String[] { type.getName(), methodName }
			));
		}
	}


	//==================================================================================================================
	// Exceptions
	//==================================================================================================================

	// Default mock exception type
	public class MockException extends Exception {}

	// Exception thrown when a method without a configured mock return is invoked on a stubbed instance
	public class NoReturnValueException extends Exception {}

	// Exception thrown when a stub's invocation assertion fails
	public class MethodInvokedException extends Exception {}

	// Exception thrown when a callout is intercepted that has not been properly mocked
	public class MethodNotInvokedException extends Exception {}
}

Looking for More?

If you found this Stub abstraction useful or interesting, check out our HTTP Mock Registry for a similar abstraction around HTTP callout mocks!