├── .eslintignore ├── .forceignore ├── .gitattributes ├── .github └── workflows │ └── sfdx.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── config └── project-scratch-def.json ├── force-app └── main │ └── default │ └── classes │ ├── UniversalMocker.cls │ ├── UniversalMocker.cls-meta.xml │ └── example │ ├── AccountDBService.cls │ ├── AccountDBService.cls-meta.xml │ ├── AccountDomain.cls │ ├── AccountDomain.cls-meta.xml │ ├── AccountDomainTest.cls │ ├── AccountDomainTest.cls-meta.xml │ ├── AccountsBatch.cls │ ├── AccountsBatch.cls-meta.xml │ ├── AccountsQueuable.cls │ └── AccountsQueuable.cls-meta.xml ├── package-lock.json ├── package.json └── sfdx-project.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lwc/**/*.css 2 | **/lwc/**/*.html 3 | **/lwc/**/*.json 4 | **/lwc/**/*.svg 5 | **/lwc/**/*.xml 6 | **/aura/**/*.auradoc 7 | **/aura/**/*.cmp 8 | **/aura/**/*.css 9 | **/aura/**/*.design 10 | **/aura/**/*.json 11 | **/aura/**/*.svg 12 | **/aura/**/*.xml 13 | .sfdx -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | package.xml 6 | 7 | # LWC configuration files 8 | **/jsconfig.json 9 | **/.eslintrc.json 10 | 11 | # LWC Jest 12 | **/__tests__/** -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.cls whitespace=cr-at-eol -------------------------------------------------------------------------------- /.github/workflows/sfdx.yml: -------------------------------------------------------------------------------- 1 | 2 | name: SFDX Test Run on Push 3 | 4 | on: [push] 5 | 6 | jobs: 7 | test: 8 | 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@master 13 | with: 14 | ref: ${{ github.ref }} 15 | - uses: sfdx-actions/setup-sfdx@v1 16 | with: 17 | sfdx-auth-url: ${{ secrets.AUTH_SECRET }} 18 | - name: sfdx-deploy 19 | run: sfdx force:source:deploy -p force-app/main/default/classes -l RunSpecifiedTests -r AccountDomainTest -w 30 -c 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sf/ 7 | .sfdx/ 8 | .localdevserver/ 9 | 10 | # LWC VSCode autocomplete 11 | **/lwc/jsconfig.json 12 | 13 | # LWC Jest coverage reports 14 | coverage/ 15 | 16 | # Logs 17 | logs 18 | *.log 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | # Dependency directories 24 | node_modules/ 25 | yarn.lock 26 | 27 | # Eslint cache 28 | .eslintcache 29 | 30 | # MacOS system files 31 | .DS_Store 32 | 33 | # Windows system files 34 | Thumbs.db 35 | ehthumbs.db 36 | [Dd]esktop.ini 37 | $RECYCLE.BIN/ 38 | 39 | #ctags 40 | tags 41 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running prettier 2 | # More information: https://prettier.io/docs/en/ignore.html 3 | # 4 | 5 | **/staticresources/** 6 | .localdevserver 7 | .sfdx 8 | 9 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "printWidth": 160, 4 | "overrides": [ 5 | { 6 | "files": "**/lwc/**/*.html", 7 | "options": { "parser": "lwc" } 8 | }, 9 | { 10 | "files": "*.{cmp,page,component}", 11 | "options": { "parser": "html" } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "salesforce.salesforcedx-vscode", 4 | "redhat.vscode-xml", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Apex Replay Debugger", 9 | "type": "apex-replay", 10 | "request": "launch", 11 | "logFile": "${command:AskForLogFileName}", 12 | "stopOnEntry": true, 13 | "trace": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true, 4 | "**/bower_components": true, 5 | "**/.sfdx": true 6 | } 7 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Suraj Pillai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex Universal Mocker 2 | 3 | A universal mocking class for Apex, built using the [Apex Stub API](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_testing_stub_api.htm), subject to all its limitations. The api design choices for this class have been driven by a desire to make mocking as simple as possible for developers to understand and implement. It favors fluency and readability above everything else. Consequently, trade-offs have been made such as the limitation noted towards the end of this Readme. 4 | 5 | ## Installation 6 | 7 | - Simply copy the `UniversalMocker.cls` to your org. The `examples` folder merely serves as a reference. 8 | 9 | ## Usage 10 | 11 | ### Setup 12 | 13 | #### Basic Setup 14 | 15 | - Create an instance of `UniversalMocker` for each class you want to mock. 16 | 17 | ```java 18 | UniversalMocker mockInstance = UniversalMocker.mock(AccountDBService.class); 19 | ``` 20 | 21 | - Set the mock values you want to return for each method. 22 | 23 | ```java 24 | mockInstance.when('getOneAccount').thenReturn(mockAccount); 25 | ``` 26 | 27 | - Use `withParamTypes` for overloaded methods. 28 | 29 | ```java 30 | mockInstance.when('getOneAccount').withParamTypes(new List{Id.class}) 31 | .thenReturn(mockAccount); 32 | ``` 33 | 34 | - You can also set up a method to throw an exception 35 | 36 | ```java 37 | mockInstance.when('getOneAccount').thenThrow(new MyCustomException()); 38 | ``` 39 | 40 | - Create an instance of the class you want to mock. 41 | 42 | ```java 43 | AccountDBService mockDBService = (AccountDBService)mockInstance.createStub(); 44 | ``` 45 | 46 | #### Sequential Mocks 47 | 48 | There might be instances where you may need the same method to mock different return values within the same test when 49 | testing utility methods or selector classes and such. You can specify different return values based on the call count 50 | in such cases 51 | 52 | - Basic example 53 | 54 | ```java 55 | mockInstance.when('getOneAccount').thenReturnUntil(3,mockAccountOne).thenReturn(mockAccountTwo); 56 | ``` 57 | 58 | Here, `mockAccountOne` is returned the first 3 times `getOneAccount` is called. All subsequent calls to `getOneAccount` 59 | will return `mockAccountTwo` 60 | 61 | - You can also pair it with param types or to mock exceptions 62 | 63 | ```java 64 | mockInstance.when('getOneAccount').withParamTypes(new List{Id.class}) 65 | .thenReturnUntil(1,mockAccountOne) 66 | .thenThrowUntil(3,mockException) 67 | .thenReturn(mockAccountTwo); 68 | ``` 69 | 70 | Refer to the [relevant unit tests](force-app/main/default/classes/example/AccountDomainTest.cls#L265) for further 71 | clarity 72 | 73 | **Note**: It is recommended that you end all setup method call chains with `thenReturn` or `thenThrow` 74 | 75 | #### Mutating Arguments 76 | 77 | There might be instances where you need to modify the original arguments passed into the function. A typical example 78 | would be to set the `Id` field of records passed into a method responsible for inserting them. 79 | 80 | - Create a class that implements the `UniversalMocker.Mutator` interface. The interface has a single method `mutate` 81 | with the following signature. 82 | 83 | ```java 84 | void mutate( 85 | Object stubbedObject, String stubbedMethodName, 86 | List listOfParamTypes, List listOfArgs 87 | ); 88 | ``` 89 | 90 | Here's the method for setting fake ids on inserted records, in our example. 91 | 92 | ```java 93 | public void mutate( 94 | Object stubbedObject, String stubbedMethodName, 95 | List listOfParamTypes, List listOfArgs 96 | ) { 97 | Account record = (Account) listOfArgs[0]; 98 | record.Id = this.getFakeId(Account.SObjectType); 99 | } 100 | ``` 101 | 102 | - Pass in an instance of your implementation of the `Mutator` class to mutate the method arguments. 103 | 104 | ```java 105 | mockInstance.when('doInsert').mutateWith(dmlMutatorInstance).thenReturnVoid(); 106 | ``` 107 | 108 | Check out the [AccountDomainTest](./force-app/main/default/classes/example/AccountDomainTest.cls#L244) class for the 109 | full example. 110 | 111 | You can call the `mutateWith` method any number of times in succession, with the same or different mutator instances, 112 | to create a chain of methods to mutate method arguments. 113 | 114 | #### Sequential Mutators 115 | 116 | You can also use specific mutators based on call count. Multiple mutators with the same value of call count will be 117 | accumulated and applied in succession for all calls since the previous established call count. 118 | 119 | For example, lets say you have a `DescriptionMutator` class as shown below. It appends a given string to the `Account 120 | Description` field. 121 | 122 | ```java 123 | //Adds a given suffix to account description 124 | public class DescriptionMutator implements UniversalMocker.Mutator { 125 | private String stringToAdd = ''; 126 | public DescriptionMutator(String stringToAdd) { 127 | this.stringToAdd = stringToAdd; 128 | } 129 | public void mutate(Object stubbedObject, String stubbedMethodName, List listOfParamTypes, List listOfArgs) { 130 | Account record = (Account) listOfArgs[0]; 131 | if (record.get('Description') != null) { 132 | record.Description += this.stringToAdd; 133 | } else { 134 | record.Description = this.stringToAdd; 135 | } 136 | } 137 | } 138 | ``` 139 | 140 | If you wanted to append the string `12` to the Account Description for the first 2 calls and then the string `3` for all subsequent 141 | calls, your setup would look something like: 142 | 143 | ```java 144 | mockService.when(mockedMethodName).mutateUntil(2, new DescriptionMutator('1')).mutateUntil(2, new DescriptionMutator('2')) 145 | .mutateWith(new DescriptionMutator('3')); 146 | ``` 147 | 148 | or 149 | 150 | ```java 151 | mockService.when(mockedMethodName).mutateUntil(2, new DescriptionMutator('12')).mutateWith(new DescriptionMutator('3')); 152 | ``` 153 | 154 | If you wanted to append the string `1` to the Account Description for the first call, the string `2` for the second call, 155 | and string `3` for all subsequent calls, your setup would look as follows: 156 | 157 | ```java 158 | mockService.when(mockedMethodName).mutateUntil(1, new DescriptionMutator('1')).mutateUntil(2, new DescriptionMutator('2')) 159 | .mutateWith(new DescriptionMutator('3')); 160 | ``` 161 | 162 | Check out the [AccountDomainTest](./force-app/main/default/classes/example/AccountDomainTest.cls#L193) class for the 163 | full example. 164 | 165 | ### Verification 166 | 167 | - Assert the exact number of times a method was called. 168 | 169 | ```java 170 | mockInstance.assertThat().method('getOneAccount').wasCalled(1); 171 | mockInstance.assertThat().method('getOneAccount').wasCalled(2); 172 | ``` 173 | 174 | - Assert if the number of times a method was called was more or less than a given integer. 175 | 176 | ```java 177 | mockInstance.assertThat().method('getOneAccount').wasCalled(1,UniversalMocker.Times.OR_MORE); 178 | mockInstance.assertThat().method('getOneAccount').wasCalled(1,UniversalMocker.Times.OR_LESS); 179 | ``` 180 | 181 | - Assert that a method was not called. This works both for methods that had mock return values set up before the test 182 | and for ones that didn't. 183 | 184 | ```java 185 | mockInstance.assertThat().method('dummyMethod').wasNeverCalled(); 186 | ``` 187 | 188 | Note that `mockInstance.assertThat().method('dummyMethod').wasCalled(0,UniversalMocker.Times.EXACTLY);` would only 189 | work if you had a mock return value set up for `dummyMethod` before running the test. 190 | 191 | - Get the value of an argument passed into a method. Use `withParamTypes` for overloaded methods. 192 | 193 | ```java 194 | mockInstance.forMethod('doInsert').andInvocationNumber(0).getValueOf('acct'); 195 | mockInstance.forMethod('doInsert').withParamTypes(new List{Account.class}).andInvocationNumber(0).getValueOf('acct'); 196 | ``` 197 | 198 | **Note**: If you use `mutateWith` to mutate the original method arguments, the values returned here are the mutated 199 | arguments and not the original method arguments. 200 | 201 | ## Notes 202 | 203 | 1. Method and argument names are case-insensitive. 204 | 2. If you don't have overloaded methods, it is recommended to not use `withParamTypes`. Conversely, if you do have overloaded methods, 205 | it is recommended that you do use `withParamTypes` for mocking as well as verification. 206 | 3. If you use `withParamTypes` for setting up the mock, you need to use it for verification and fetching method arguments as well. 207 | 4. It is highly recommended that you always verify the mocked method call counts to insulate against typos in method names being mocked and any future refactoring. 208 | 5. The glaring limitation in the current version is the inability to mock methods with exact arguments, so this may not work if that's what you're looking to do. 209 | 6. Although it is not recommended to test async behavior in unit tests since that is a platform feature, the library does support it. 210 | 211 | ## Contributions 212 | 213 | Many thanks to my fellow [SFXD](https://sfxd.github.io/) members [@jamessimone](https://github.com/jamessimone) [@ThieveryShoe](https://github.com/Thieveryshoe) [@jlyon11](https://github.com/jlyon87) [@elements](https://github.com/elements) for their feedback and contribution. 214 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "85suraj company", 3 | "edition": "Developer", 4 | "features": [], 5 | "settings": { 6 | "lightningExperienceSettings": { 7 | "enableS1DesktopEnabled": true 8 | }, 9 | "mobileSettings": { 10 | "enableS1EncryptedStoragePref2": false 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /force-app/main/default/classes/UniversalMocker.cls: -------------------------------------------------------------------------------- 1 | /************************************************************ 2 | 3 | *** @author: Suraj Pillai 4 | *** @group: Test Class 5 | *** @date: 01/2020 6 | *** @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 7 | 8 | */ 9 | @IsTest 10 | public with sharing class UniversalMocker implements System.StubProvider { 11 | // Map of methodName+paramTypes -> map of (paramname,value) for each invocation 12 | private Map>> argumentsMap = new Map>>(); 13 | private final Type mockedClass; 14 | private Map mocksMap = new Map(); 15 | private Map> returnUntilMap = new Map>(); 16 | private Map> mutateUntilMap = new Map>(); 17 | private Map callCountsMap = new Map(); 18 | 19 | @TestVisible 20 | private static final Map uMockInstances = new Map(); 21 | 22 | //even though the 'guid' we are generating is a long (using Crypto.getRandomLong), we keep this a string, to make it easier if we need to switch to an actual guid in the future, and it isn't really costing us anything 23 | private String guid; 24 | 25 | private String currentMethodName; 26 | private String currentParamTypesString; 27 | private Integer forInvocationNumber = 0; 28 | private Integer callCountToMock = null; 29 | 30 | private String KEY_DELIMITER = '||'; 31 | 32 | //Map for storing mutators 33 | Map> mutatorMap = new Map>(); 34 | 35 | // Inner class instances 36 | private InitialSetupState setupAInstance; 37 | private InitialValidationState assertAInstance; 38 | private IntermediateValidationState assertBInstance; 39 | private InitialParamValidationState getParamsAInstance; 40 | 41 | private enum Modes { 42 | SETUP, 43 | ASSERT, 44 | GETPARAMS 45 | } 46 | 47 | /* Begin Public Methods */ 48 | 49 | public enum Times { 50 | OR_LESS, 51 | OR_MORE, 52 | EXACTLY 53 | } 54 | 55 | public static UniversalMocker mock(Type mockedClass) { 56 | UniversalMocker uMock = new UniversalMocker(mockedClass); 57 | uMockInstances.put(uMock.guid, uMock); 58 | return uMock; 59 | } 60 | 61 | public Object createStub() { 62 | return Test.createStub(this.mockedClass, this); 63 | } 64 | 65 | public class InitialSetupState extends FinalSetupState { 66 | private InitialSetupState(UniversalMocker parent) { 67 | super(parent); 68 | } 69 | public FinalSetupState withParamTypes(List paramTypes) { 70 | this.parent.withParamTypes(paramTypes); 71 | return (FinalSetupState) this; 72 | } 73 | } 74 | 75 | public virtual class FinalSetupState { 76 | private final UniversalMocker parent; 77 | private FinalSetupState(UniversalMocker parent) { 78 | this.parent = parent; 79 | } 80 | public FinalSetupState mutateUntil(Integer callCount, Mutator mutatorInstance) { 81 | this.parent.mutateUntil(callCount, mutatorInstance); 82 | return this; 83 | } 84 | public FinalSetupState mutateWith(Mutator mutatorInstance) { 85 | this.parent.mutateWith(mutatorInstance); 86 | return this; 87 | } 88 | public void thenReturnVoid() { 89 | this.parent.thenReturnVoid(); 90 | } 91 | public void thenReturn(Object returnObject) { 92 | this.parent.thenReturn(returnObject); 93 | } 94 | public void thenThrow(Exception exceptionToThrow) { 95 | this.parent.thenThrow(exceptionToThrow); 96 | } 97 | public FinalSetupState thenReturnUntil(Integer callCount, Object returnObject) { 98 | this.parent.thenReturnUntil(callCount, returnObject); 99 | return this; 100 | } 101 | public FinalSetupState thenThrowUntil(Integer callCount, Exception exceptionToThrow) { 102 | this.parent.thenThrowUntil(callCount, exceptionToThrow); 103 | return this; 104 | } 105 | } 106 | 107 | public class InitialValidationState { 108 | private final UniversalMocker parent; 109 | private InitialValidationState(UniversalMocker parent) { 110 | this.parent = parent; 111 | } 112 | public IntermediateValidationState method(String methodName) { 113 | parent.method(methodName); 114 | return parent.assertBInstance; 115 | } 116 | } 117 | 118 | public class IntermediateValidationState extends FinalValidationState { 119 | private IntermediateValidationState(UniversalMocker parent) { 120 | super(parent); 121 | } 122 | public FinalValidationState withParamTypes(List paramTypes) { 123 | parent.withParamTypes(paramTypes); 124 | return (FinalValidationState) this; 125 | } 126 | } 127 | 128 | public virtual class FinalValidationState { 129 | private final UniversalMocker parent; 130 | private FinalValidationState(UniversalMocker parent) { 131 | this.parent = parent; 132 | } 133 | public void wasCalled(Integer expectedCallCount, Times assertTypeValue) { 134 | parent.wasCalled(expectedCallCount, assertTypeValue); 135 | } 136 | public void wasCalled(Integer expectedCallCount) { 137 | parent.wasCalled(expectedCallCount); 138 | } 139 | public void wasNeverCalled() { 140 | parent.wasNeverCalled(); 141 | } 142 | } 143 | 144 | public class InitialParamValidationState extends IntermediateParamValidationState { 145 | private InitialParamValidationState(UniversalMocker parent) { 146 | super(parent); 147 | } 148 | public IntermediateParamValidationState withParamTypes(List paramTypes) { 149 | parent.withParamTypes(paramTypes); 150 | return (IntermediateParamValidationState) this; 151 | } 152 | } 153 | 154 | public virtual class IntermediateParamValidationState extends FinalParamValidationState { 155 | private IntermediateParamValidationState(UniversalMocker parent) { 156 | super(parent); 157 | } 158 | public FinalParamValidationState andInvocationNumber(Integer invocation) { 159 | parent.andInvocationNumber(invocation); 160 | return (FinalParamValidationState) this; 161 | } 162 | } 163 | 164 | public virtual class FinalParamValidationState { 165 | private final UniversalMocker parent; 166 | private FinalParamValidationState(UniversalMocker parent) { 167 | this.parent = parent; 168 | } 169 | public Object getValueOf(String paramName) { 170 | return parent.getValueOf(paramName); 171 | } 172 | public Map getArgumentsMap() { 173 | return parent.getArgumentsMap(); 174 | } 175 | } 176 | 177 | public InitialSetupState when(String stubbedMethodName) { 178 | this.reset(); 179 | this.currentMethodName = stubbedMethodName; 180 | return this.setupAInstance; 181 | } 182 | 183 | public Object handleMethodCall( 184 | Object stubbedObject, 185 | String stubbedMethodName, 186 | Type returnType, //currently unused 187 | List listOfParamTypes, 188 | List listOfParamNames, 189 | List listOfArgs 190 | ) { 191 | String keyInUse = this.determineKeyToUseForCurrentStubbedMethod(stubbedMethodName, listOfParamTypes); 192 | this.incrementCallCount(keyInUse); 193 | this.saveArguments(listOfParamNames, listOfArgs, keyInUse); 194 | 195 | for (Mutator m : this.getApplicableMutators(keyInUse)) { 196 | m.mutate(stubbedObject, stubbedMethodName, listOfParamTypes, listOfArgs); 197 | } 198 | 199 | Object returnValue = this.getMockValue(keyInUse); 200 | if (returnValue instanceof Exception) { 201 | throw (Exception) returnValue; 202 | } 203 | this.copyState(); //for async calls, we store the current object instance in a static map so the state is preserved even after leaving the async context 204 | return returnValue; 205 | } 206 | 207 | public InitialValidationState assertThat() { 208 | this.reset(); 209 | return this.assertAInstance; 210 | } 211 | 212 | public InitialParamValidationState forMethod(String stubbedMethodName) { 213 | this.reset(); 214 | this.currentMethodName = stubbedMethodName; 215 | return this.getParamsAInstance; 216 | } 217 | 218 | public class InvalidOperationException extends Exception { 219 | } 220 | 221 | public interface Mutator { 222 | void mutate(Object stubbedObject, String stubbedMethodName, List listOfParamTypes, List listOfArgs); 223 | } 224 | 225 | public void resetState() { 226 | this.reset(); 227 | this.argumentsMap = new Map>>(); 228 | this.mocksMap = new Map(); 229 | this.returnUntilMap = new Map>(); 230 | this.mutateUntilMap = new Map>(); 231 | this.callCountsMap = new Map(); 232 | this.mutatorMap = new Map>(); 233 | this.initInnerClassInstances(); 234 | } 235 | 236 | /* End Public methods */ 237 | 238 | /* Begin Private methods */ 239 | 240 | private void withParamTypes(List paramTypes) { 241 | this.currentParamTypesString = this.getParamTypesString(paramTypes); 242 | } 243 | 244 | private void mutateWith(Mutator mutatorInstance) { 245 | String key = this.getCurrentKey(); 246 | this.putMutatorValue(key, mutatorInstance); 247 | if (!this.callCountsMap.containsKey(key)) { 248 | this.callCountsMap.put(key, 0); 249 | } 250 | if (this.callCountToMock != null) { 251 | this.callCountToMock = null; 252 | } 253 | } 254 | 255 | private void thenReturnVoid() { 256 | this.thenReturn(null); 257 | } 258 | 259 | private void thenReturn(Object returnObject) { 260 | String key = this.getCurrentKey(); 261 | this.putMockValue(key, returnObject); 262 | if (!this.callCountsMap.containsKey(key)) { 263 | this.callCountsMap.put(key, 0); 264 | } 265 | if (this.callCountToMock != null) { 266 | this.callCountToMock = null; 267 | } 268 | } 269 | 270 | private void mutateUntil(Integer callCount, Mutator mutatorInstance) { 271 | this.callCountToMock = callCount; 272 | this.mutateWith(mutatorInstance); 273 | } 274 | 275 | private void thenReturnUntil(Integer callCount, Object returnObject) { 276 | this.callCountToMock = callCount; 277 | this.thenReturn(returnObject); 278 | } 279 | 280 | private void thenThrowUntil(Integer callCount, Exception exceptionToThrow) { 281 | this.callCountToMock = callCount; 282 | this.thenReturn(exceptionToThrow); 283 | } 284 | 285 | private void thenThrow(Exception exceptionToThrow) { 286 | this.thenReturn(exceptionToThrow); 287 | } 288 | 289 | private void method(String methodName) { 290 | this.currentMethodName = methodName; 291 | } 292 | 293 | private void wasCalled(Integer expectedCallCount) { 294 | wasCalled(expectedCallCount, UniversalMocker.Times.EXACTLY); 295 | } 296 | 297 | private void wasCalled(Integer expectedCallCount, Times assertTypeValue) { 298 | String currentKey = this.getCurrentKey(); 299 | //Integer actualCallCount = this.callCountsMap.get(currentKey); 300 | Integer actualCallCount = this.getCallCountsMapInternal().get(currentKey); 301 | String methodName = this.currentMethodName; 302 | switch on assertTypeValue { 303 | when OR_LESS { 304 | system.assert(expectedCallCount >= actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'less than or equal')); 305 | } 306 | when OR_MORE { 307 | system.assert(expectedCallCount <= actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'more than or equal')); 308 | } 309 | when else { 310 | system.assertEquals(expectedCallCount, actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'equal')); 311 | } 312 | } 313 | } 314 | 315 | private void wasNeverCalled() { 316 | String currentKey = this.getCurrentKey(); 317 | Integer actualCallCount = this.getCallCountsMapInternal().get(currentKey); 318 | String methodName = this.currentMethodName; 319 | if (actualCallCount != null) { 320 | Integer expectedCallCount = 0; 321 | System.assertEquals(expectedCallCount, actualCallCount, String.format('Method {0} was called 1 or more times', new List{ methodName })); 322 | } 323 | } 324 | 325 | private void andInvocationNumber(Integer invocation) { 326 | this.forInvocationNumber = invocation; 327 | } 328 | 329 | private Object getValueOf(String paramName) { 330 | String theKey = this.getCurrentKey(); 331 | Map paramsMap = this.getArgumentsMapInternal().get(theKey).get(this.forInvocationNumber); 332 | if (!paramsMap.containsKey(paramName.toLowerCase())) { 333 | throw new IllegalArgumentException(String.format('Param name {0} not found for the method {1}', new List{ paramName, this.currentMethodName })); 334 | } 335 | Object returnValue = paramsMap.get(paramName.toLowerCase()); 336 | return returnValue; 337 | } 338 | 339 | private Map getArgumentsMap() { 340 | String theKey = this.getCurrentKey(); 341 | Map returnValue = this.getArgumentsMapInternal().get(theKey).get(this.forInvocationNumber); 342 | return returnValue; 343 | } 344 | 345 | private String getCurrentKey() { 346 | String retVal = this.currentMethodName; 347 | if (!String.isEmpty(this.currentParamTypesString)) { 348 | retVal += KEY_DELIMITER + this.currentParamTypesString; 349 | } 350 | return retVal.toLowerCase(); 351 | } 352 | 353 | private String getKey(String methodName, List paramTypes) { 354 | return (methodName + KEY_DELIMITER + this.getParamTypesString(paramTypes)).toLowerCase(); 355 | } 356 | 357 | private Object getMockValue(String key) { 358 | if (this.returnUntilMap.containsKey(key)) { 359 | Integer callCount = this.callCountsMap.get(key); 360 | List returnUntilList = this.returnUntilMap.get(key); 361 | returnUntilList.sort(); 362 | for (Integer returnUntil : returnUntilList) { 363 | if (returnUntil >= callCount) { 364 | return this.mocksMap.get(key + '-' + returnUntil); 365 | } 366 | } 367 | } 368 | return this.mocksMap.get(key); 369 | } 370 | 371 | private List getApplicableMutators(String key) { 372 | if (this.mutateUntilMap.containsKey(key)) { 373 | Integer callCount = this.callCountsMap.get(key); 374 | List mutateUntilList = this.mutateUntilMap.get(key); 375 | mutateUntilList.sort(); 376 | for (Integer mutateUntil : mutateUntilList) { 377 | if (mutateUntil >= callCount) { 378 | key = key + '-' + mutateUntil; 379 | break; 380 | } 381 | } 382 | } 383 | if (this.mutatorMap.containsKey(key)) { 384 | return this.mutatorMap.get(key); 385 | } 386 | return new List(); 387 | } 388 | 389 | private void putMutatorValue(String key, Mutator mutatorInstance) { 390 | if (this.callCountToMock != null) { 391 | if (!this.mutateUntilMap.containsKey(key)) { 392 | this.mutateUntilMap.put(key, new List{}); 393 | } 394 | this.mutateUntilMap.get(key).add(this.callCountToMock); 395 | key = key + '-' + callCountToMock; 396 | } 397 | if (!this.mutatorMap.containsKey(key)) { 398 | this.mutatorMap.put(key, new List()); 399 | } 400 | this.mutatorMap.get(key).add(mutatorInstance); 401 | } 402 | 403 | private void putMockValue(String key, Object value) { 404 | if (this.callCountToMock != null) { 405 | if (!this.returnUntilMap.containsKey(key)) { 406 | this.returnUntilMap.put(key, new List{}); 407 | } 408 | this.returnUntilMap.get(key).add(this.callCountToMock); 409 | this.mocksMap.put(key + '-' + this.callCountToMock, value); 410 | } else { 411 | this.mocksMap.put(key, value); 412 | } 413 | } 414 | 415 | private String getParamTypesString(List paramTypes) { 416 | String[] classNames = new List{}; 417 | for (Type paramType : paramTypes) { 418 | classNames.add(paramType.getName()); 419 | } 420 | return String.join(classNames, '-'); 421 | } 422 | 423 | private String determineKeyToUseForCurrentStubbedMethod(String stubbedMethodName, List listOfParamTypes) { 424 | String keyWithParamTypes = this.getKey(stubbedMethodName, listOfParamTypes); 425 | return this.callCountsMap.containsKey(keyWithParamTypes) ? keyWithParamTypes : stubbedMethodName.toLowerCase(); 426 | } 427 | 428 | private void incrementCallCount(String key) { 429 | Integer count = this.callCountsMap.containsKey(key) ? this.callCountsMap.get(key) : 0; 430 | this.callCountsMap.put(key, count + 1); 431 | } 432 | 433 | private void saveArguments(List listOfParamNames, List listOfArgs, String key) { 434 | Map currentArgsMap = new Map(); 435 | if (!this.argumentsMap.containsKey(key)) { 436 | this.argumentsMap.put(key, new List>{ currentArgsMap }); 437 | } else { 438 | this.argumentsMap.get(key).add(currentArgsMap); 439 | } 440 | 441 | for (Integer i = 0; i < listOfParamNames.size(); i++) { 442 | currentArgsMap.put(listOfParamNames[i].toLowerCase(), listOfArgs[i]); 443 | } 444 | } 445 | 446 | private String getMethodCallCountAssertMessage(String methodName, String comparison) { 447 | return String.format('Expected call count for method {0} is not {1} to the actual count', new List{ methodName, comparison }); 448 | } 449 | 450 | private Map getCallCountsMapInternal() { 451 | return uMockInstances.get(this.guid).callCountsMap; 452 | } 453 | 454 | private Map>> getArgumentsMapInternal() { 455 | return uMockInstances.get(this.guid).argumentsMap; 456 | } 457 | 458 | private void copyState() { 459 | uMockInstances.put(this.guid, this); 460 | } 461 | 462 | private UniversalMocker(Type mockedClass) { 463 | this.mockedClass = mockedClass; 464 | this.guid = this.getGUID(); 465 | this.initInnerClassInstances(); 466 | } 467 | 468 | private String getGUID() { 469 | String guid = Crypto.getRandomLong() + ''; // since guid generation is expensive, we "settle" for this, as it generates unique values and is performant 470 | return guid; 471 | } 472 | 473 | private void initInnerClassInstances() { 474 | this.setupAInstance = new InitialSetupState(this); 475 | this.assertAInstance = new InitialValidationState(this); 476 | this.assertBInstance = new IntermediateValidationState(this); 477 | this.getParamsAInstance = new InitialParamValidationState(this); 478 | } 479 | 480 | private void reset() { 481 | this.currentParamTypesString = ''; 482 | this.currentMethodName = ''; 483 | this.forInvocationNumber = 0; 484 | } 485 | 486 | /* End Private Methods */ 487 | } 488 | -------------------------------------------------------------------------------- /force-app/main/default/classes/UniversalMocker.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 59.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountDBService.cls: -------------------------------------------------------------------------------- 1 | public with sharing class AccountDBService { 2 | public Account getOneAccount() { 3 | return [SELECT Name FROM Account LIMIT 1]; 4 | } 5 | 6 | public Account[] getMatchingAccounts(Id accountId) { 7 | return [SELECT Name FROM Account WHERE Id = :accountId]; 8 | } 9 | 10 | public Account[] getMatchingAccounts(String accountName) { 11 | return [SELECT Name FROM Account WHERE Name = :accountName]; 12 | } 13 | 14 | public void doInsert(Account acct) { 15 | insert acct; 16 | } 17 | 18 | public void doUpdate(Account acct) { 19 | update acct; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountDBService.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountDomain.cls: -------------------------------------------------------------------------------- 1 | public with sharing class AccountDomain { 2 | AccountDBService acctService = null; 3 | 4 | public AccountDomain(AccountDBService svc) { 5 | this.acctService = svc; 6 | } 7 | 8 | public AccountDomain() { 9 | this.acctService = new AccountDBService(); 10 | } 11 | 12 | public Account getAccountDetail() { 13 | return this.acctService.getOneAccount(); 14 | } 15 | 16 | public void createPublicAccount(String acctName) { 17 | Account acct = new Account(Name = acctName, Ownership = 'Public'); 18 | this.acctService.doInsert(acct); 19 | } 20 | 21 | public void updateAccount(Account acct) { 22 | this.acctService.doUpdate(acct); 23 | } 24 | 25 | public Account[] getMatchingAccounts(String attribute) { 26 | if (attribute instanceof Id) { 27 | return this.acctService.getMatchingAccounts(Id.valueOf(attribute)); 28 | } else { 29 | return this.acctService.getMatchingAccounts(attribute); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountDomain.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountDomainTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | public with sharing class AccountDomainTest { 3 | private static final UniversalMocker mockService; 4 | private static final AccountDBService mockServiceStub; 5 | private static final AccountDomain sut; // system under test 6 | 7 | static { 8 | mockService = UniversalMocker.mock(AccountDBService.class); //This is the service we are mocking 9 | mockServiceStub = (AccountDBService) mockService.createStub(); 10 | sut = new AccountDomain(mockServiceStub); //This is the class into which we inject our mocked service 11 | } 12 | 13 | @IsTest 14 | public static void it_should_return_one_account() { 15 | //setup 16 | String mockedMethodName = 'getOneAccount'; 17 | Account mockAccount = new Account(Name = 'Mock Account'); 18 | 19 | mockService.when(mockedMethodName).thenReturn(mockAccount); 20 | 21 | //test 22 | Test.startTest(); 23 | Account accountDetail = sut.getAccountDetail(); 24 | Test.stopTest(); 25 | 26 | //verify 27 | system.assertEquals(mockAccount.Name, accountDetail.Name); 28 | mockService.assertThat().method(mockedMethodName).wasCalled(1); 29 | } 30 | 31 | @IsTest 32 | public static void it_should_create_a_public_account() { 33 | //setup 34 | String mockedMethodName = 'doInsert'; 35 | 36 | //test 37 | Test.startTest(); 38 | sut.createPublicAccount('Mock Account'); 39 | Test.stopTest(); 40 | 41 | //verify 42 | Account newAccount = (Account) mockService.forMethod(mockedMethodName).andInvocationNumber(0).getValueOf('acct'); 43 | system.assertEquals('Mock Account', newAccount.Name); 44 | system.assertEquals('Public', newAccount.Ownership); 45 | } 46 | 47 | @IsTest 48 | public static void it_should_verify_call_counts_correctly() { 49 | //setup 50 | String mockedMethodName = 'getOneAccount'; 51 | Account mockAccount = new Account(Name = 'Mock Account'); 52 | 53 | mockService.when(mockedMethodName).thenReturn(mockAccount); 54 | mockService.when('mockedDummyMethod').thenReturn(null); 55 | 56 | //test 57 | Test.startTest(); 58 | Account accountDetail = sut.getAccountDetail(); 59 | sut.getAccountDetail(); 60 | Test.stopTest(); 61 | 62 | //verify 63 | system.assertEquals(mockAccount.Name, accountDetail.Name); 64 | mockService.assertThat().method(mockedMethodName).wasCalled(1, UniversalMocker.Times.OR_MORE); 65 | mockService.assertThat().method(mockedMethodName).wasCalled(2, UniversalMocker.Times.OR_MORE); 66 | mockService.assertThat().method(mockedMethodName).wasCalled(2); 67 | mockService.assertThat().method(mockedMethodName).wasCalled(2, UniversalMocker.Times.OR_LESS); 68 | mockService.assertThat().method(mockedMethodName).wasCalled(3, UniversalMocker.Times.OR_LESS); 69 | mockService.assertThat().method('mockedDummyMethod').wasNeverCalled(); 70 | mockService.assertThat().method('nonMockedDummyMethod').wasNeverCalled(); 71 | } 72 | 73 | @IsTest 74 | public static void it_should_call_overloaded_methods_correctly() { 75 | //setup 76 | String mockedMethodName = 'getMatchingAccounts'; 77 | Account acctOne = new Account(Name = 'Account with matching Id'); 78 | Account acctTwo = new Account(Name = 'Account with matching name'); 79 | 80 | mockService.when(mockedMethodName).withParamTypes(new List{ Id.class }).thenReturn(new List{ acctOne }); 81 | mockService.when(mockedMethodName).withParamTypes(new List{ String.class }).thenReturn(new List{ acctTwo }); 82 | 83 | //test 84 | Test.startTest(); 85 | Id mockAccountId = '001000000000001'; 86 | List acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); 87 | List acctsWithMatchingName = sut.getMatchingAccounts('Account with matching name'); 88 | Test.stopTest(); 89 | 90 | //verify 91 | mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ Id.class }).wasCalled(1); 92 | mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ String.class }).wasCalled(1); 93 | Id accountIdParam = (Id) mockService.forMethod(mockedMethodName).withParamTypes(new List{ Id.class }).andInvocationNumber(0).getValueOf('accountId'); 94 | String acctNameParam = (String) mockService.forMethod(mockedMethodName) 95 | .withParamTypes(new List{ String.class }) 96 | .andInvocationNumber(0) 97 | .getValueOf('accountName'); 98 | 99 | System.assertEquals(mockAccountId, accountIdParam); 100 | System.assertEquals('Account with matching name', acctNameParam); 101 | System.assertEquals(acctOne.Name, acctsWithMatchingId[0].Name); 102 | System.assertEquals(acctTwo.Name, acctsWithMatchingName[0].Name); 103 | } 104 | 105 | @IsTest 106 | public static void shouldResetMethodAndParamsAfterEachChain() { 107 | //setup 108 | String mockedMethodName = 'getMatchingAccounts'; 109 | String newMethodName = 'getOneAccount'; 110 | Account acctOne = new Account(Name = 'Account with matching Id'); 111 | Account acctTwo = new Account(Name = 'Account with matching name'); 112 | 113 | mockService.when(mockedMethodName).withParamTypes(new List{ Id.class }).thenReturn(new List{ acctOne }); 114 | mockService.when(newMethodName).thenReturn(acctTwo); //This method takes no params. So by mocking this we attempt to ensure that the param type list from previous mock call (Id.class) has been cleared out 115 | 116 | //test 117 | Test.startTest(); 118 | Id mockAccountId = '001000000000001'; 119 | List acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); 120 | Account anotherAccount = sut.getAccountDetail(); 121 | Test.stopTest(); 122 | 123 | //verify 124 | mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ Id.class }).wasCalled(1); 125 | mockService.assertThat().method(newMethodName).wasCalled(1); 126 | 127 | System.assertEquals(acctOne.Name, acctsWithMatchingId[0].Name); 128 | System.assertEquals(acctTwo.Name, anotherAccount.Name); 129 | 130 | Id acctIdParam = (Id) mockService.forMethod(mockedMethodName).withParamTypes(new List{ Id.class }).getValueOf('accountId'); 131 | Map argsMap = mockService.forMethod(newMethodName).getArgumentsMap(); 132 | Assert.areEqual(mockAccountId, acctIdParam); 133 | Assert.areEqual(0, argsMap.size()); 134 | } 135 | 136 | @IsTest 137 | public static void it_should_throw_mock_exception() { 138 | //setup 139 | String mockedMethodName = 'doInsert'; 140 | String mockExceptionMessage = 'Mock exception'; 141 | AuraHandledException mockException = new AuraHandledException(mockExceptionMessage); 142 | /*https://salesforce.stackexchange.com/questions/122657/testing-aurahandledexceptions*/ 143 | mockException.setMessage(mockExceptionMessage); 144 | 145 | mockService.when(mockedMethodName).thenThrow(mockException); 146 | 147 | //test 148 | Test.startTest(); 149 | boolean hasException = false; 150 | try { 151 | sut.createPublicAccount('Mock Account'); 152 | } catch (AuraHandledException ex) { 153 | System.assertEquals(mockExceptionMessage, ex.getMessage()); 154 | hasException = true; 155 | } 156 | Test.stopTest(); 157 | 158 | //verify 159 | mockService.assertThat().method(mockedMethodName).wasCalled(1); 160 | System.assert(hasException, 'Mocked exception was not thrown'); 161 | } 162 | 163 | @IsTest 164 | public static void it_should_generate_unique_guids() { 165 | Integer numInstances = 20000; 166 | for (Integer i = 0; i < numInstances; i++) { 167 | UniversalMocker uMock = UniversalMocker.mock(AccountDBService.class); 168 | } 169 | System.assertEquals(numInstances + 1, UniversalMocker.uMockInstances.size(), 'We have collision in the generated guids'); //numInstances + 1 generated in the static block above 170 | } 171 | 172 | @IsTest 173 | public static void it_should_track_call_counts_across_queueables() { 174 | String mockedMethodName = 'doInsert'; 175 | String mockExceptionMessage = 'Mock exception'; 176 | UniversalMocker.Mutator dmlMutatorInstance = new DMLMutator(); 177 | 178 | mockService.when(mockedMethodName).mutateWith(dmlMutatorInstance).thenReturnVoid(); 179 | AccountsQueuable queueableSut = new AccountsQueuable(sut); 180 | 181 | //test 182 | Test.startTest(); 183 | System.enqueueJob(queueableSut); 184 | Test.stopTest(); 185 | 186 | //verify 187 | mockService.assertThat().method(mockedMethodName).wasCalled(1); 188 | Account acct = (Account) mockService.forMethod(mockedMethodName).getValueOf('acct'); 189 | System.assertNotEquals(null, acct.Id, 'Account Id is null after insert'); 190 | } 191 | 192 | @IsTest 193 | public static void shouldApplyMutatorsBasedOnCallCounts() { 194 | String mockedMethodName = 'doUpdate'; 195 | String mockExceptionMessage = 'Mock exception'; 196 | UniversalMocker.Mutator dmlMutatorInstance = new DMLMutator(); 197 | 198 | mockService.when(mockedMethodName).mutateUntil(1, new DescriptionMutator('1')).thenReturnVoid(); 199 | 200 | Account acct = new Account(Name = 'Acme Inc'); 201 | sut.updateAccount(acct); 202 | sut.updateAccount(acct); 203 | sut.updateAccount(acct); 204 | 205 | //verify 206 | mockService.assertThat().method(mockedMethodName).wasCalled(3); 207 | Assert.areEqual('1', acct.Description, 'Expected description to only be set once (till callcount 1)'); 208 | 209 | mockService.resetState(); 210 | acct.Description = ''; //reset account description 211 | mockService.when(mockedMethodName) 212 | .mutateUntil(2, new DescriptionMutator('1')) 213 | .mutateUntil(2, new DescriptionMutator('2')) 214 | .mutateWith(new DescriptionMutator('3')) 215 | .thenReturnVoid(); 216 | sut.updateAccount(acct); 217 | sut.updateAccount(acct); 218 | sut.updateAccount(acct); 219 | Assert.areEqual( 220 | '12123', 221 | acct.Description, 222 | 'Expected description to set to "12123" ("12" appended for each of the first two calls and "3" appended for the third call' 223 | ); 224 | } 225 | 226 | @IsTest 227 | public static void it_should_track_call_counts_with_batchables() { 228 | String mockedMethodName = 'getOneAccount'; 229 | Account mockAccount = new Account(Name = 'Mock Account'); 230 | mockService.when(mockedMethodName).thenReturn(mockAccount); 231 | 232 | AccountsBatch batchableSut = new AccountsBatch(sut); 233 | 234 | //test 235 | Test.startTest(); 236 | Database.executeBatch(batchableSut, 1); 237 | Test.stopTest(); 238 | 239 | //verify 240 | mockService.assertThat().method(mockedMethodName).wasCalled(1); 241 | } 242 | 243 | @IsTest 244 | public static void it_should_mutate_arguments() { 245 | //setup 246 | String mockedMethodName = 'doInsert'; 247 | String mockExceptionMessage = 'Mock exception'; 248 | UniversalMocker.Mutator dmlMutatorInstance = new DMLMutator(); 249 | 250 | mockService.when(mockedMethodName).mutateWith(dmlMutatorInstance).thenReturnVoid(); 251 | 252 | //test 253 | Test.startTest(); 254 | boolean hasException = false; 255 | try { 256 | sut.createPublicAccount('Mock Account'); 257 | } catch (AuraHandledException ex) { 258 | System.assertEquals(mockExceptionMessage, ex.getMessage()); 259 | hasException = true; 260 | } 261 | Test.stopTest(); 262 | 263 | //verify 264 | mockService.assertThat().method(mockedMethodName).wasCalled(1); 265 | System.assert(!hasException, 'Mocked exception was not thrown'); 266 | Account acct = (Account) mockService.forMethod('doInsert').getValueOf('acct'); 267 | System.assertNotEquals(null, acct.Id, 'Account Id is null after insert'); 268 | } 269 | 270 | @IsTest 271 | public static void it_should_handle_multiple_return_values_basic() { 272 | //setup 273 | String mockedMethodName = 'getOneAccount'; 274 | Account mockAccountOne = new Account(Name = 'Mock Account One'); 275 | Account mockAccountTwo = new Account(Name = 'Mock Account Two'); 276 | 277 | mockService.when(mockedMethodName).thenReturnUntil(1, mockAccountOne).thenReturn(mockAccountTwo); 278 | 279 | //test 280 | Test.startTest(); 281 | Account accountDetail = sut.getAccountDetail(); 282 | Assert.areEqual(mockAccountOne.Name, accountDetail.Name); 283 | 284 | accountDetail = sut.getAccountDetail(); 285 | Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); 286 | 287 | //should return mockAccountTwo for all subsequent calls 288 | for (Integer i = 0; i < 100; i++) { 289 | accountDetail = sut.getAccountDetail(); 290 | } 291 | Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); 292 | Test.stopTest(); 293 | 294 | //verify 295 | mockService.assertThat().method(mockedMethodName).wasCalled(102); 296 | } 297 | 298 | @IsTest 299 | public static void it_should_handle_multiple_return_values_advanced() { 300 | //setup 301 | String mockedMethodName = 'getOneAccount'; 302 | Account mockAccountOne = new Account(Name = 'Mock Account One'); 303 | Account mockAccountTwo = new Account(Name = 'Mock Account Two'); 304 | Account mockAccountThree = new Account(Name = 'Mock Account Three'); 305 | 306 | //returns mockAccountOne for the first call, mockAccountTwo for the next 2 calls, and mockAccountThree for all subsequent calls 307 | mockService.when(mockedMethodName).thenReturnUntil(1, mockAccountOne).thenReturnUntil(3, mockAccountTwo).thenReturn(mockAccountThree); 308 | 309 | //test 310 | Test.startTest(); 311 | Account accountDetail = sut.getAccountDetail(); 312 | Assert.areEqual(mockAccountOne.Name, accountDetail.Name); 313 | 314 | accountDetail = sut.getAccountDetail(); 315 | Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); 316 | 317 | accountDetail = sut.getAccountDetail(); 318 | Assert.areEqual(mockAccountTwo.Name, accountDetail.Name); 319 | 320 | accountDetail = sut.getAccountDetail(); 321 | Assert.areEqual(mockAccountThree.Name, accountDetail.Name); 322 | 323 | //should return mockAccountTwo for all subsequent calls 324 | for (Integer i = 0; i < 100; i++) { 325 | accountDetail = sut.getAccountDetail(); 326 | } 327 | Assert.areEqual(mockAccountThree.Name, accountDetail.Name); 328 | Test.stopTest(); 329 | 330 | //verify 331 | mockService.assertThat().method(mockedMethodName).wasCalled(104); 332 | } 333 | 334 | @IsTest 335 | public static void it_should_handle_multiple_return_values_exception() { 336 | //setup 337 | String mockedMethodName = 'getOneAccount'; 338 | Account mockAccountOne = new Account(Name = 'Mock Account One'); 339 | 340 | String mockExceptionMessage = 'Mock exception'; 341 | AuraHandledException mockException = new AuraHandledException(mockExceptionMessage); 342 | mockException.setMessage(mockExceptionMessage); 343 | 344 | mockService.when(mockedMethodName).thenThrowUntil(2, mockException).thenReturn(mockAccountOne); 345 | 346 | //test 347 | Test.startTest(); 348 | 349 | try { 350 | Account accountDetail = sut.getAccountDetail(); 351 | Assert.fail('Expected exception to be thrown'); 352 | } catch (AuraHandledException ex) { 353 | Assert.areEqual(mockExceptionMessage, ex.getMessage()); 354 | } 355 | 356 | try { 357 | Account accountDetail = sut.getAccountDetail(); 358 | Assert.fail('Expected exception to be thrown'); 359 | } catch (AuraHandledException ex) { 360 | Assert.areEqual(mockExceptionMessage, ex.getMessage()); 361 | } 362 | 363 | Account accountDetail = sut.getAccountDetail(); 364 | Assert.areEqual(mockAccountOne.Name, accountDetail.Name); 365 | 366 | //should return mockAccountTwo for all subsequent calls 367 | for (Integer i = 0; i < 100; i++) { 368 | accountDetail = sut.getAccountDetail(); 369 | } 370 | Assert.areEqual(mockAccountOne.Name, accountDetail.Name); 371 | Test.stopTest(); 372 | 373 | //verify 374 | mockService.assertThat().method(mockedMethodName).wasCalled(103); 375 | } 376 | 377 | @IsTest 378 | public static void it_should_call_overloaded_methods_multiple_return_values() { 379 | //setup 380 | String mockedMethodName = 'getMatchingAccounts'; 381 | Account acctByIdOne = new Account(Name = 'Account with matching Id One'); 382 | Account acctByIdTwo = new Account(Name = 'Account with matching Id Two'); 383 | Account acctByNameOne = new Account(Name = 'Account with matching name'); 384 | 385 | mockService.when(mockedMethodName) 386 | .withParamTypes(new List{ Id.class }) 387 | .thenReturnUntil(2, new List{ acctByIdOne }) 388 | .thenReturn(new List{ acctByIdTwo }); 389 | mockService.when(mockedMethodName).withParamTypes(new List{ String.class }).thenReturn(new List{ acctByNameOne }); 390 | 391 | //test 392 | Test.startTest(); 393 | Id mockAccountId = '001000000000001'; 394 | List acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); 395 | Assert.areEqual(acctByIdOne.Name, acctsWithMatchingId[0].Name); 396 | 397 | acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); 398 | Assert.areEqual(acctByIdOne.Name, acctsWithMatchingId[0].Name); 399 | 400 | acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); 401 | Assert.areEqual(acctByIdTwo.Name, acctsWithMatchingId[0].Name); 402 | 403 | for (Integer i = 0; i < 100; i++) { 404 | acctsWithMatchingId = sut.getMatchingAccounts(mockAccountId); 405 | } 406 | 407 | List acctsWithMatchingName = sut.getMatchingAccounts('Account with matching name'); 408 | Test.stopTest(); 409 | 410 | //verify 411 | mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ Id.class }).wasCalled(103); 412 | mockService.assertThat().method(mockedMethodName).withParamTypes(new List{ String.class }).wasCalled(1); 413 | 414 | Id accountIdParam = (Id) mockService.forMethod(mockedMethodName).withParamTypes(new List{ Id.class }).andInvocationNumber(50).getValueOf('accountId'); 415 | String acctNameParam = (String) mockService.forMethod(mockedMethodName) 416 | .withParamTypes(new List{ String.class }) 417 | .andInvocationNumber(0) 418 | .getValueOf('accountName'); 419 | 420 | Assert.areEqual(mockAccountId, accountIdParam); 421 | Assert.areEqual('Account with matching name', acctNameParam); 422 | Assert.areEqual(acctByIdTwo.Name, acctsWithMatchingId[0].Name); 423 | Assert.areEqual(acctByNameOne.Name, acctsWithMatchingName[0].Name); 424 | } 425 | 426 | @IsTest 427 | public static void dummy_test_for_db_service() { 428 | AccountDBService dbSvc = new AccountDBService(); 429 | Account a = new Account(Name = 'Acme'); 430 | dbSvc.doInsert(a); 431 | dbSvc.getOneAccount(); 432 | dbSvc.getMatchingAccounts(Id.valueOf('001000000000001')); 433 | dbSvc.getMatchingAccounts('Acme'); 434 | } 435 | 436 | //Adds a given suffix to account description 437 | public class DescriptionMutator implements UniversalMocker.Mutator { 438 | private String stringToAdd = ''; 439 | public DescriptionMutator(String stringToAdd) { 440 | this.stringToAdd = stringToAdd; 441 | } 442 | public void mutate(Object stubbedObject, String stubbedMethodName, List listOfParamTypes, List listOfArgs) { 443 | Account record = (Account) listOfArgs[0]; 444 | if (record.get('Description') != null) { 445 | record.Description += this.stringToAdd; 446 | } else { 447 | record.Description = this.stringToAdd; 448 | } 449 | } 450 | } 451 | 452 | public class DMLMutator implements UniversalMocker.Mutator { 453 | // Ideally, 'fakeCounter' should be a static variable and 'getFakeId' should be a static method in another top-level class. 454 | private Integer fakeIdCounter = 1; 455 | public String getFakeId(Schema.SObjectType objType) { 456 | String result = String.valueOf(this.fakeIdCounter++); 457 | return objType.getDescribe().getKeyPrefix() + '0'.repeat(12 - result.length()) + result; 458 | } 459 | 460 | public void mutate(Object stubbedObject, String stubbedMethodName, List listOfParamTypes, List listOfArgs) { 461 | Account record = (Account) listOfArgs[0]; 462 | record.Id = this.getFakeId(Account.SObjectType); 463 | } 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountDomainTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountsBatch.cls: -------------------------------------------------------------------------------- 1 | public class AccountsBatch implements Database.Batchable { 2 | AccountDomain acctDomain = null; 3 | public AccountsBatch(AccountDomain acctDomain) { 4 | this.acctDomain = acctDomain; 5 | } 6 | 7 | public Iterable start(Database.BatchableContext bc) { 8 | return new List{ 1 }; 9 | } 10 | 11 | public void execute(Database.BatchableContext bc, List scope) { 12 | acctDomain.getAccountDetail(); //calls AcctDBservice.getOneAccount 13 | } 14 | 15 | public void finish(Database.BatchableContext bc) { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountsBatch.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountsQueuable.cls: -------------------------------------------------------------------------------- 1 | public with sharing class AccountsQueuable implements Queueable { 2 | AccountDomain acctDomainInstance = null; 3 | public AccountsQueuable(AccountDomain acctDomain) { 4 | this.acctDomainInstance = acctDomain; 5 | } 6 | 7 | public void execute(System.QueueableContext qc) { 8 | this.acctDomainInstance.createPublicAccount('Test Account'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /force-app/main/default/classes/example/AccountsQueuable.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-app", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Salesforce App", 6 | "scripts": { 7 | "lint": "npm run lint:lwc && npm run lint:aura", 8 | "lint:aura": "eslint **/aura/**", 9 | "lint:lwc": "eslint **/lwc/**", 10 | "test": "npm run test:unit", 11 | "test:unit": "sfdx-lwc-jest", 12 | "test:unit:watch": "sfdx-lwc-jest --watch", 13 | "test:unit:debug": "sfdx-lwc-jest --debug", 14 | "test:unit:coverage": "sfdx-lwc-jest --coverage", 15 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 16 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"" 17 | }, 18 | "devDependencies": { 19 | "@prettier/plugin-xml": "^0.7.2", 20 | "@salesforce/eslint-config-lwc": "^3.5.2", 21 | "@salesforce/eslint-plugin-aura": "^2.1.0", 22 | "@salesforce/sfdx-lwc-jest": "^0.7.1", 23 | "eslint": "^8.56.0", 24 | "eslint-config-prettier": "^6.11.0", 25 | "prettier": "^2.0.5", 26 | "prettier-plugin-apex": "^1.5.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "namespace": "", 9 | "sfdcLoginUrl": "https://login.salesforce.com", 10 | "sourceApiVersion": "53.0" 11 | } 12 | --------------------------------------------------------------------------------