diff --git a/README.md b/README.md index d82c3f5..9abc372 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,35 @@ A universal mocking class for Apex, built using the [Apex Stub API](https://deve AccountDBService mockDBService = (AccountDBService)mockInstance.createStub(); ``` +#### Sequential Mocks + +There might be instances where you may need the same method to mock different return values within the same test when +testing utility methods or selector classes and such. You can specify different return values based on the call count +in such cases + +- Basic example + +```java + mockInstance.when('getOneAccount').thenReturnUntil(3,mockAccountOne).thenReturn(mockAccountTwo); +``` + +Here, `mockAccountOne` is returned the first 3 times `getOneAccount` is called. All subsequent calls to `getOneAccount` +will return `mockAccountTwo` + +- You can also pair it with param types or to mock exceptions + +```java + mockInstance.when('getOneAccount').withParamTypes(new List{Id.class}) + .thenReturnUntil(1,mockAccountOne) + .thenThrowUntil(3,mockException) + .thenReturn(mockAccountTwo); +``` + +Refer to the [relevant unit tests](force-app/main/default/classes/example/AccountDomainTest.cls#L265) for further +clarity + +**Note**: It is recommended that you end all setup method call chains with `thenReturn` or `thenThrow` + #### Mutating arguments There might be instances where you need to modify the original arguments passed into the function. A typical example diff --git a/force-app/main/default/classes/UniversalMocker.cls b/force-app/main/default/classes/UniversalMocker.cls old mode 100755 new mode 100644 index 00f8daa..5f17f9f --- a/force-app/main/default/classes/UniversalMocker.cls +++ b/force-app/main/default/classes/UniversalMocker.cls @@ -6,12 +6,13 @@ *** @description: A universal class for mocking in tests. Contains a method for setting the return value for any method. Another method returns the number of times a method was called. https://github.com/surajp/universalmock */ -@isTest +@IsTest public with sharing class UniversalMocker implements System.StubProvider { // Map of methodName+paramTypes -> map of (paramname,value) for each invocation private final Map>> argumentsMap = new Map>>(); private final Type mockedClass; private final Map mocksMap = new Map(); + private final Map> returnUntilMap = new Map>(); private final Map callCountsMap = new Map(); @TestVisible @@ -24,6 +25,7 @@ public with sharing class UniversalMocker implements System.StubProvider { private String currentParamTypesString; private Integer expectedCallCount; private Integer forInvocationNumber = 0; + private Integer callCountToMock = null; private String KEY_DELIMITER = '||'; @@ -70,9 +72,19 @@ public with sharing class UniversalMocker implements System.StubProvider { } } - public virtual class IntermediateSetupState { - private final UniversalMocker parent; + public virtual class IntermediateSetupState extends FinalSetupState { private IntermediateSetupState(UniversalMocker parent) { + super(parent); + } + public FinalSetupState mutateWith(Mutator mutatorInstance) { + this.parent.mutateWith(mutatorInstance); + return (FinalSetupState) this; + } + } + + public virtual class FinalSetupState { + private final UniversalMocker parent; + private FinalSetupState(UniversalMocker parent) { this.parent = parent; } public void thenReturnVoid() { @@ -81,13 +93,17 @@ public with sharing class UniversalMocker implements System.StubProvider { public void thenReturn(Object returnObject) { this.parent.thenReturn(returnObject); } - public IntermediateSetupState mutateWith(Mutator mutatorInstance) { - this.parent.mutateWith(mutatorInstance); - return this; - } public void thenThrow(Exception exceptionToThrow) { this.parent.thenThrow(exceptionToThrow); } + public FinalSetupState thenReturnUntil(Integer callCount, Object returnObject) { + this.parent.thenReturnUntil(callCount, returnObject); + return this; + } + public FinalSetupState thenThrowUntil(Integer callCount, Exception exceptionToThrow) { + this.parent.thenThrowUntil(callCount, exceptionToThrow); + return this; + } } public class InitialValidationState { @@ -161,6 +177,7 @@ public with sharing class UniversalMocker implements System.StubProvider { } public InitialSetupState when(String stubbedMethodName) { + this.reset(); this.currentMethodName = stubbedMethodName; return this.setupAInstance; } @@ -177,14 +194,13 @@ public with sharing class UniversalMocker implements System.StubProvider { this.incrementCallCount(keyInUse); this.saveArguments(listOfParamNames, listOfArgs, keyInUse); - Object returnValue = this.mocksMap.get(keyInUse); - if (this.mutatorMap.containsKey(keyInUse)) { for (Mutator m : this.mutatorMap.get(keyInUse)) { m.mutate(stubbedObject, stubbedMethodName, listOfParamTypes, listOfArgs); } } + Object returnValue = this.getMockValue(keyInUse); if (returnValue instanceof Exception) { throw (Exception) returnValue; } @@ -193,10 +209,12 @@ public with sharing class UniversalMocker implements System.StubProvider { } public InitialValidationState assertThat() { + this.reset(); return this.assertAInstance; } public InitialParamValidationState forMethod(String stubbedMethodName) { + this.reset(); this.currentMethodName = stubbedMethodName; return this.getParamsAInstance; } @@ -234,11 +252,23 @@ public with sharing class UniversalMocker implements System.StubProvider { private void thenReturn(Object returnObject) { String key = this.getCurrentKey(); - this.mocksMap.put(key, returnObject); + this.putMockValue(key, returnObject); if (!this.callCountsMap.containsKey(key)) { this.callCountsMap.put(key, 0); } - this.reset(); + if (this.callCountToMock != null) { + this.callCountToMock = null; + } + } + + private void thenReturnUntil(Integer callCount, Object returnObject) { + this.callCountToMock = callCount; + this.thenReturn(returnObject); + } + + private void thenThrowUntil(Integer callCount, Exception exceptionToThrow) { + this.callCountToMock = callCount; + this.thenReturn(exceptionToThrow); } private void thenThrow(Exception exceptionToThrow) { @@ -259,7 +289,6 @@ public with sharing class UniversalMocker implements System.StubProvider { //Integer actualCallCount = this.callCountsMap.get(currentKey); Integer actualCallCount = this.getCallCountsMapInternal().get(currentKey); String methodName = this.currentMethodName; - this.reset(); switch on assertTypeValue { when OR_LESS { system.assert(this.expectedCallCount >= actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'less than or equal')); @@ -277,10 +306,9 @@ public with sharing class UniversalMocker implements System.StubProvider { String currentKey = this.getCurrentKey(); Integer actualCallCount = this.getCallCountsMapInternal().get(currentKey); String methodName = this.currentMethodName; - this.reset(); if (actualCallCount != null) { this.expectedCallCount = 0; - system.assertEquals(this.expectedCallCount, actualCallCount, String.format('Method {0} was called 1 or more times', new List{ methodName })); + System.assertEquals(this.expectedCallCount, actualCallCount, String.format('Method {0} was called 1 or more times', new List{ methodName })); } } @@ -295,14 +323,12 @@ public with sharing class UniversalMocker implements System.StubProvider { throw new IllegalArgumentException(String.format('Param name {0} not found for the method {1}', new List{ paramName, this.currentMethodName })); } Object returnValue = paramsMap.get(paramName.toLowerCase()); - this.reset(); return returnValue; } private Map getArgumentsMap() { String theKey = this.getCurrentKey(); Map returnValue = this.getArgumentsMapInternal().get(theKey).get(this.forInvocationNumber); - this.reset(); return returnValue; } @@ -318,6 +344,32 @@ public with sharing class UniversalMocker implements System.StubProvider { return (methodName + KEY_DELIMITER + this.getParamTypesString(paramTypes)).toLowerCase(); } + private Object getMockValue(String key) { + if (this.returnUntilMap.containsKey(key)) { + Integer callCount = this.callCountsMap.get(key); + List returnUntilList = this.returnUntilMap.get(key); + returnUntilList.sort(); + for (Integer returnUntil : returnUntilList) { + if (returnUntil >= callCount) { + return this.mocksMap.get(key + '-' + returnUntil); + } + } + } + return this.mocksMap.get(key); + } + + private void putMockValue(String key, Object value) { + if (this.callCountToMock != null) { + if (!this.returnUntilMap.containsKey(key)) { + this.returnUntilMap.put(key, new List{}); + } + this.returnUntilMap.get(key).add(this.callCountToMock); + this.mocksMap.put(key + '-' + this.callCountToMock, value); + } else { + this.mocksMap.put(key, value); + } + } + private String getParamTypesString(List paramTypes) { String[] classNames = new List{}; for (Type paramType : paramTypes) { diff --git a/force-app/main/default/classes/UniversalMocker.cls-meta.xml b/force-app/main/default/classes/UniversalMocker.cls-meta.xml index 1248daa..c14e405 100755 --- a/force-app/main/default/classes/UniversalMocker.cls-meta.xml +++ b/force-app/main/default/classes/UniversalMocker.cls-meta.xml @@ -1,5 +1,5 @@ - 56.0 + 59.0 Active diff --git a/force-app/main/default/classes/example/AccountDomainTest.cls b/force-app/main/default/classes/example/AccountDomainTest.cls index 176baba..19ec277 100644 --- a/force-app/main/default/classes/example/AccountDomainTest.cls +++ b/force-app/main/default/classes/example/AccountDomainTest.cls @@ -233,6 +233,162 @@ public with sharing class AccountDomainTest { System.assertNotEquals(null, acct.Id, 'Account Id is null after insert'); } + @IsTest + public static void it_should_handle_multiple_return_values_basic() { + //setup + String mockedMethodName = 'getOneAccount'; + Account mockAccountOne = new Account(Name = 'Mock Account One'); + Account mockAccountTwo = new Account(Name = 'Mock Account Two'); + + mockService.when(mockedMethodName).thenReturnUntil(1, mockAccountOne).thenReturn(mockAccountTwo); + + //test + Test.startTest(); + Account accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountOne.Name, accountDetail.Name); + + accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); + + //should return mockAccountTwo for all subsequent calls + for (Integer i = 0; i < 100; i++) { + accountDetail = sut.getAccountDetail(); + } + Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); + Test.stopTest(); + + //verify + mockService.assertThat().method(mockedMethodName).wasCalled(102); + } + + @IsTest + public static void it_should_handle_multiple_return_values_advanced() { + //setup + String mockedMethodName = 'getOneAccount'; + Account mockAccountOne = new Account(Name = 'Mock Account One'); + Account mockAccountTwo = new Account(Name = 'Mock Account Two'); + Account mockAccountThree = new Account(Name = 'Mock Account Three'); + + //returns mockAccountOne for the first call, mockAccountTwo for the next 2 calls, and mockAccountThree for all subsequent calls + mockService.when(mockedMethodName).thenReturnUntil(1, mockAccountOne).thenReturnUntil(3, mockAccountTwo).thenReturn(mockAccountThree); + + //test + Test.startTest(); + Account accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountOne.Name, accountDetail.Name); + + accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); + + accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); + + accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountThree.Name, accountDetail.Name); + + //should return mockAccountTwo for all subsequent calls + for (Integer i = 0; i < 100; i++) { + accountDetail = sut.getAccountDetail(); + } + Assert.areEqual(mockAccountThree.Name, accountDetail.Name); + Test.stopTest(); + + //verify + mockService.assertThat().method(mockedMethodName).wasCalled(104); + } + + @IsTest + public static void it_should_handle_multiple_return_values_exception() { + //setup + String mockedMethodName = 'getOneAccount'; + Account mockAccountOne = new Account(Name = 'Mock Account One'); + + String mockExceptionMessage = 'Mock exception'; + AuraHandledException mockException = new AuraHandledException(mockExceptionMessage); + mockException.setMessage(mockExceptionMessage); + + mockService.when(mockedMethodName).thenThrowUntil(2, mockException).thenReturn(mockAccountOne); + + //test + Test.startTest(); + + try { + Account accountDetail = sut.getAccountDetail(); + Assert.fail('Expected exception to be thrown'); + } catch (AuraHandledException ex) { + Assert.areEqual(mockExceptionMessage, ex.getMessage()); + } + + try { + Account accountDetail = sut.getAccountDetail(); + Assert.fail('Expected exception to be thrown'); + } catch (AuraHandledException ex) { + Assert.areEqual(mockExceptionMessage, ex.getMessage()); + } + + Account accountDetail = sut.getAccountDetail(); + Assert.areEqual(mockAccountOne.Name, accountDetail.Name); + + //should return mockAccountTwo for all subsequent calls + for (Integer i = 0; i < 100; i++) { + accountDetail = sut.getAccountDetail(); + } + Assert.areEqual(mockAccountOne.Name, accountDetail.Name); + Test.stopTest(); + + //verify + mockService.assertThat().method(mockedMethodName).wasCalled(103); + } + + @IsTest + public static void it_should_call_overloaded_methods_multiple_return_values() { + //setup + String mockedMethodName = 'getMatchingAccounts'; + Account acctByIdOne = new Account(Name = 'Account with matching Id One'); + Account acctByIdTwo = new Account(Name = 'Account with matching Id Two'); + Account acctByNameOne = new Account(Name = 'Account with matching name'); + + mockService.when(mockedMethodName) + .withParamTypes(new List{ Id.class }) + .thenReturnUntil(2, new List{ acctByIdOne }) + .thenReturn(new List{ acctByIdTwo }); + mockService.when(mockedMethodName).withParamTypes(new List{ String.class }).thenReturn(new List{ acctByNameOne }); + + //test + Test.startTest(); + Id mockAccountId = '001000000000001'; + List acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); + Assert.areEqual(acctByIdOne.Name, acctsWithMatchingId[0].Name); + + acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); + Assert.areEqual(acctByIdOne.Name, acctsWithMatchingId[0].Name); + + acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); + Assert.areEqual(acctByIdTwo.Name, acctsWithMatchingId[0].Name); + + for (Integer i = 0; i < 100; i++) { + acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); + } + + List acctsWithMatchingName = sut.getMatchingAccounts('Account with matching name'); + Test.stopTest(); + + //verify + mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ Id.class }).wasCalled(103); + mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ String.class }).wasCalled(1); + + Id accountIdParam = (Id) mockService.forMethod(mockedMethodName).withParamTypes(new List{ Id.class }).andInvocationNumber(50).getValueOf('accountId'); + String acctNameParam = (String) mockService.forMethod(mockedMethodName) + .withParamTypes(new List{ String.class }) + .andInvocationNumber(0) + .getValueOf('accountName'); + + Assert.areEqual(mockAccountId, accountIdParam); + Assert.areEqual('Account with matching name', acctNameParam); + Assert.areEqual(acctByIdTwo.Name, acctsWithMatchingId[0].Name); + Assert.areEqual(acctByNameOne.Name, acctsWithMatchingName[0].Name); + } + @IsTest public static void dummy_test_for_db_service() { AccountDBService dbSvc = new AccountDBService();