Build a Better Mocking Framework
Written by Zackary Frazier, posted on 2024-07-28
- SALESFORCE
- TESTING
Tests should be fast. They should run quickly. When tests run slow, you won't want to run them frequently. If you don't run them frequently, you won't find problems early enough to fix them easily.
- Bob Martin
As anyone who's ever deployed a bug to production knows, it is crucial to test your code. However, what happens when the tests take so long to run that you wind up spending more time running the tests than writing them?
In Salesforce, its default testing framework relies strictly on integration tests. There is no obvious way to test your code in isolation. Integration tests are fantastic of course however once there are hundreds of them and suddenly every test run takes two to three hours you start to see why most platforms don't default to using integration tests as their default.
So naturally, as an expert engineer, I scoured the internet in search of solutions.
From this blog post, I discovered the biggest pain-point is DML. This makes intuitive sense, since in Salesforce all CRUD operations trigger a dozen layers of automation between workflow rules, flows, apex triggers, process builders, and various other one-off automations. It introduced the idea of using CRUD wrappers that pipe to the Database class, and then mocking the wrappers.
One might think "why not just stub the Database class directly?" I was one of those people and I thought that for like five minutes before realizing that every method in the Database class is static. Salesforce does support an API for stubbing (brilliantly named the Stub API) however the stub API cannot mock static methods. So the solution, as discovered by James Simone, is to wrap the CRUD operations in a class with non-static methods that call the static Database methods.
Cool! We got the ball rolling. The next issue however is mocking SOQL queries. This is a more difficult challenge. The Apex Mocks framework has a solution to this problem, but I was never quite satisfied with this. Their approach was to create selector classes for each SObject and encapsulate each possible query used throughout the org. This works because we can standardize the queries, but it leaves a lot to be desired.
What happens if, after creating the initial Selector methods, someone adds new fields in the query? Or worse yet, what if a field is found to be unnecessary and its removed? Now the org has old unit tests stubbing queries to return fields the query doesn't actually return. The tests are now invalid. Their success is meaningless.
Though I understand why they took this approach, what else would one do in this scenario? We can't exactly replace the Salesforce database with our own dummy database in a unit test. There is, to my knowledge, nothing on the platform that supports this behavior. Someone would have to do something crazy to get around this constraint.
Anyway, so I did something crazy.
Moxygen
Borrowing from James Simone's idea from mocking DML, I figured what if we took that a step further and made an in-memory data store for mock DML statements and SOQL queries. Lets begin by defining the behavior we'd want to see,
List<Account> accounts = new List<Account> {
new Account(
Name = 'Test Account 1'
)
};
DML.doInsert(accounts)
List<Account> queriedAccounts = Selector.query('SELECT Id FROM Account')
Mocking the DML aspect was straight-forward enough. I simply reasoned lets put the records in a map by Id. Mocking CRUD operations with a map is not exceptionally difficult, especially with James Simone's work as a template. The difficult part was mocking the SOQL queries. What does it mean to translate a SOQL query into something that can execute on a map?
Well, I did take a compilers class in college, so I understand that the query needs to be parsed into an abstract syntax tree (AST), and then our interpreter needs to walk the tree and execute on the nodes. So that's what I did.
SELECT fieldList [subquery][...]
[TYPEOF typeOfField whenExpression[...] elseExpression END][...]
FROM objectType[,...]
[USING SCOPE filterScope]
[WHERE conditionExpression]
[WITH [DATA CATEGORY] filteringExpression]
[GROUP BY {fieldGroupByList|ROLLUP (fieldSubtotalGroupByList)|CUBE (fieldSubtotalGroupByList)}
[HAVING havingConditionExpression] ]
[ORDER BY fieldOrderByList {ASC|DESC} [NULLS {FIRST|LAST}] ]
[LIMIT numberOfRowsToReturn]
[OFFSET numberOfRowsToSkip]
[{FOR VIEW | FOR REFERENCE} ]
[UPDATE {TRACKING|VIEWSTAT} ]
[FOR UPDATE]
[WITH SECURITY_ENFORCED]
This documentation represents the Extended Backus-Naur Form of SOQL queries. From this, we can build a parser that knows what to expect from a query and when to throw a parsing error. We accomplish this with a recursive descent parser that evaluates the query string and converts it into our AST for our interpreter.
For a simplified example, for the select clause, we might parse it like this:
/**
* @param query - full SOQL query we are parsing
* @return Intermediary - intermediary representation of our AST
*/
public Intermediary parseSelect(String query) {
query = consume(query, 'select ');
Intermediary leftIntermediary = parseFieldList(query);
query = leftIntermediary.subquery;
return new Intermediary(
new NodeBuilder()
.setId('select')
.setNodeType('select')
.setLeft(leftIntermediary.head)
.build()
)
}
/**
* @param query - SOQL query we are parsing, after 'select ' has been parsed
* @return Intermediary - intermediary representation of our AST
*/
public Intermediary parseFieldList(String query) {
Intermediary fieldList = parseField(query);
query = fieldList.subQuery;
query = consume(query, ',');
if(!isNext(query, 'from')) {
Intermediary restOfList = parseFieldList(query);
fieldList.left = restOfList.head;
query = restOfList.subquery;
fieldList = restOfList.subquery;
}
return fieldList;
}
// and so on and so forth for each part of the query...
As you can see, we are recursively descending down the query string until we've parsed the full select clause. Through recursive calls to parseFieldList, we will eventually parse the entire field list and return it as an intermediary. Through a bit of voodoo, we curry the final resulting subquery up the chain so the final field list has the from clause as its subquery field, so from the top level we can then descend on the from clause.
The full implementation has far more edge cases to examine, but at the end of this article I'll leave a link to the full project for those who are interested in seeing it. In the end, we can retrieve our full AST by just pulling the head node of the resulting intermediary object.
With parsing out of the way, the next step is interpreting the AST. This of course is the most involved part. In a nutshell, from the from clause, we know which SObjects we're looking for. So the task it to loop on those records, apply the logic of the other clauses, then return a list of the results. Simple, but not easy.
Now I'd prefer not to drop 10,000 lines of code on you, so I'll leave it at the abstracted version.
public List<SObject> doQuery(Node selectNode, Map<String, Object> params) {
// gather the top-level nodes for this query, these are the nodes
// for the independent clauses of the query
TopLevelNodes topLevelNodes = getTopLevelNodes(selectNode);
Node fromNode = topLevelNodes.fromNode;
Node whereNode = topLevelNodes.whereNode;
Node limitNode = topLevelNodes.limitNode;
Node offsetNode = topLevelNodes.offsetNode;
Node usingScopeNode = topLevelNodes.usingScopeNode;
// validate the query, throws exception if invalid
QueryValidator validator = new QueryValidator(topLevelNodes);
validator.validate();
Node objNode = fromNode.left;
String objName = objNode.id;
String objApiName = SchemaService.getSObjectName(objName);
// otherwise valid query, but there's no data in the database
Map<Id, SObject> mockObjects = MockDatabase.mockRecords.get(objApiName);
if (mockObjects == null) {
return emptyResponse();
}
Boolean isAggregateQuery = NodeService.isAggregateQuery(topLevelNodes);
Boolean isCountQuery = NodeService.isSingularCount(topLevelNodes);
// COUNT() queries and aggregate queries have their own special query functions
// the reason is, they're retrieved differently
Boolean isRecordQuery = (!isCountQuery && !isAggregateQuery);
if(!isRecordQuery) {
throw new QueryException('Only record queries are supported for calls to query()');
}
List<SObject> records = new List<sObject>();
// process the query, whether it's a count, aggregate, or regular query
for(sObject databaseRecord : mockObjects.values()) {
// Salesforce doesn't immediately `delete` records, it marks them 'IsDeleted' before
// it hard-deletes them when it empties the org recycling bin
if(databaseRecord.get('IsDeleted') == true) {
continue;
}
// if the where clause isn't met, we should skip this record
if(!checkWhereClause(databaseRecord, whereNode, params)) {
continue;
}
// in the current implementation, handleUsingScopeNode always returns true
// there's not easy way to check this, so this check is left to be implemented later
if(!handleUsingScopeNode(databaseRecord, usingScopeNode, params)) {
continue;
}
// handle fields, they only asked for X amount of fields, make sure we only return those
SObject queriedRecord = handleSelectQuery(databaseRecord, selectNode, params);
records.add(queriedRecord);
}
// these nodes apply to the entire set of records, so we handle them last
records = (List<SObject>) handleOrderByNode(Types.SOQL.RECORD, records, selectNode);
records = (List<SObject>) handleOffsetNode(Types.SOQL.RECORD, offsetNode, records, params);
records = (List<SObject>) handleLimitNode(Types.SOQL.RECORD, limitNode, records, params);
// the queries done, eliminate the top-level nodes, we don't need them anymore
removeTopLevelNodesFor(selectNode);
return records;
}
You'd think we'd start from left to right, the same way we read the query, but we actually don't. As I understand it, SQL works the same way. We start with the from clause, because we need to know which table we're pulling from, then we eliminate record by "are they deleted", "do they not satisfy the where clause", "do they not satisfy the using scope clause", then we use the select clause to narrow down the fields we're pulling. Finally, we apply query-level clauses (order by, offset, and limit).
Now finally, after the interpreter was completed, we needed am easy way to use this, otherwise what's even the point? This is where the top-level framework comes in. The original implementation involved created a non-static data access object to invoke the mock database but then I realized, why even? We have the Test.isRunningTest() method to determine if we're in a test class, which is where we'd want to use this, and otherwise all we want to do is pipe our queries to the Database class. That is simple if-then-else logic right there.
public inherited sharing class Selector {
/**
* @description Query for a list of sObjects
* @param queryString `String`
* @return `List<SObject>`
*/
public static List<SObject> query(String queryString) {
if(ORM.isUnitTest()) {
return onQuery(queryString);
}
return Database.query(queryString);
}
// ... other methods
}
The only caveat is that we may want to still perform integration tests, so we need a way to let our test method know to do actual queries. Thus the ORM class (short for object-relational mapping... because Database was taken) was created as a simple state-manager for the Selector class.
This was pretty simple actually.
public inherited sharing class ORM {
private static Boolean isUnitTest = Test.isRunningTest();
public static Boolean isUnitTest() {
return isUnitTest;
}
@TestVisible
private static void doIntegrationTest() {
ORM.isUnitTest = false;
}
}
Now putting this all together, what do we get? What does it mean to have an in-memory database to a regular user of this library? Well, the sample code on the Git repo demonstrates how this would look in production code. Say you have an AccountService class that updates an account name for all newly created accounts like so.
public class AccountService {
public void updateAcctName(Id accountId) {
Map<String, Object> binds = new Map<String, Object> {
'acctId' => accountId
};
// one-to-one wrapper around Database.queryWithBinds
List<Account> acctList = Selector.queryWithBinds(
'SELECT Name FROM Account WHERE Id = :acctId',
binds,
AccessLevel.USER_MODE
);
for(Account acct : acctList) {
acct.Name = 'WOOOO!!!!';
}
// one-to-one wrapper around Database.update
DML.doUpdate(acctList, true);
}
}
Now to unit test this, and integration test this, you'd see a test class as follows,
@IsTest
private class AccountServiceTest {
@IsTest
private static void testUpdateAcctNameUnitTest() {
// Moxygen already knows its in a unit test, no setup required
Account newAcct = new Account(
Name = 'Lame'
);
// Does an insert without registering that DML was performed
DML.doMockInsert(newAcct);
AccountService service = new AccountService();
Assert.isFalse(
DML.didAnyDML(),
'Expected no DML statement to register'
);
Test.startTest();
service.updateAcctName(newAcct.Id);
Test.stopTest();
Account updatedAcct = (Account) Selector.selectRecordById(newAcct.Id);
// Did we actually update the record?
Assert.areEqual(
'WOOOO!!!!',
updatedAcct.Name,
'Expected account name to be updated'
);
// check for any DML
Assert.isTrue(
DML.didAnyDML(),
'Expected DML to fire'
);
// check for a specific DML operation
Assert.isTrue(
DML.didDML(Types.DML.UPDATED),
'Expected data to be updated'
);
// did we call a query?
Assert.isTrue(
Selector.calledAnyQuery(),
'Expected some query to be called'
);
// check that our specific query was called
Assert.isTrue(
Selector.calledQuery('SELECT Name FROM Account WHERE Id = :acctId'),
'Expected query to be called'
);
}
@IsTest
private static void testUpdateAcctNameIntegrationTest() {
// defaults to unit tests,
// need to specify when we want real DML and SOQL to fire off
ORM.doIntegrationTest();
Account newAcct = new Account(
Name = 'Lame'
);
DML.doInsert(newAcct, true);
AccountService service = new AccountService();
Test.startTest();
service.updateAcctName(newAcct.Id);
Test.stopTest();
Map<String, Object> acctBinds = new Map<String, Object> {
'newAcctId' => newAcct.Id
};
List<Account> updatedAcctList = (List<Account>) Selector.queryWithBinds(
'SELECT Name FROM Account WHERE Id = :newAcctId',
acctBinds,
AccessLevel.SYSTEM_MODE
);
Account updatedAcct = updatedAcctList[0];
// Did we actually update the record?
Assert.areEqual(
'WOOOO!!!!',
updatedAcct.Name,
'Expected account name to be updated'
);
}
}
Performance
Moxygen itself has around 650 unit tests to verify it's correctness. It executes all of them in under three minutes. I've seen orgs with a similar-sized number of unit tests that take two to three hours to deploy. This is not exactly a one-to-one comparison, but it's a good benchmark.
Closing Thoughts
This was a complex project. Before embarking on it, perhaps because I was smoking crack, I was not expecting this to transform into a six month implementation. However, I definitely learned a lot in my implementation of this. I think there is a lot of value to be obtained by being able to immediately see the results of your orgs unit tests rather than having to sit and wait for an hour or two to see if your code broke anything.
There are other monumental projects that solve similar problems. The next step I'd like to see with this, aside from expanding the number of supported queries (there's still a handful that are not supported), would be to integrate this with the ApexSharp project.
ApexSharp allows your Apex code to be transpiled to C#, to create a local dev environment for Salesforce developers (something Salesforce sorely needs). However ApexSharp curries queries to Salesforce rather than running them locally. If Moxygen were transpiled to C#, we could set something up like Vitest --watch which can automatically run all unit tests every time you save your Apex classes so you really do immediately know whether your code breaks anything.
Moxygen can be found here.
This is a free and open source project created and maintained by Zackary Frazier.