Proper Unit Tests for @AuraEnabled Methods
True unit testing in Apex!
It is very common to write integration tests when striving for code coverage in Apex. Take the below example of a simple Todo List app:
This app has an LWC component that shows a todo list to the user. The LWC component calls the TodoController
to get a list of todos. The TodoController
also calls the TodoService
. In this example the TodoService
is responsible for all DML operations. Although the TodoController
could certainly perform the DML operations, it is common to break up a larger app in a similar fashion so we will be doing this for our example.
for the sake of this example we are using a custom object Todo__c. However, the standard Task object would fit this use case without any custom code.
For testing the TodoController something like the following would suffice:
@isTest()
public class TodoControllerIntegrationTest {
public static String todo1_Name = 'Sort Laundry';
public static Todo__c todo1 = new Todo__c(Name=todo1_Name);
@TestSetup
static void makeData() {
insert todo1;
}
@isTest()
private static void getTodos_returnsTodoList() {
// Test the method
List<Todo__c> todoList = TodoController.getTodos();
// Get Todos from database for assertions
List<Todo__c> todoListFromDb = [Select Id, Name From Todo__c];
// Assertions
System.assertEquals(todo1_Name, todoListFromDb[0].Name);
}
}
This is a very simple and effective test. Before the test method we insert a todo into the database then we simply verify that the inserted todo is returned from our getTodos
method call. However, previously I stated that the TodoService
is responsible for DML operations; however, we have DML operations in the TodoController
test!? This is because in our test TodoController
is calling TodoService
to get the list of todos from the database. So we are not just testing the TodoController
but the TodoService
as well. This means our test is essentially an integration test and not a unit test.
To write a proper unit test we need to only test the TodoController
. This would require mocking the TodoService
. Before we can actually mock the TodoService
we need to decouple our code. Currently our TodoController
looks like this:
public with sharing class TodoController {
@AuraEnabled
public static List<Todo__c> getTodos() {
return TodoService.getTodos();
}
}
And the TodoService method looks like this:
public with sharing class TodoService {
public static List<Todo__c> getTodos() {
return [SELECT Id, Name FROM Todo__c];
}
}
Static methods, for various reasons, are not easy to mock. So we will update the getTodos
method in TodoService
so that it is not static. We will also make the TodoService
and getTodos
method virtual so that can override the getTodos
method later when writing our test mock.
public virtual with sharing class TodoService {
public virtual List<Todo__c> getTodos() {
return [SELECT Id, Name FROM Todo__c];
}
}
Now we need to update our TodoController
to use the new getTodos
instance method. However, we need to make our update in a way that allows us to mock the TodoService
in our tests. Dependency injection (DI) is the perfect pattern to achieve this goal. Here is a great article about DI in Apex. We will be using the Injector
class from this article as a starting point for our Injector
.
First lets create the Injector
class
public virtual class Injector {
private static Injector injector;
@testVisible
private static Injector mockInjector;
public virtual Object instantiate(String className) {
// Load the Type corresponding to the class name
Type t = Type.forName(className);
// Create a new instance of the class
// and return it as an Object
return t.newInstance();
}
public static Injector getInjector() {
if (mockInjector != null) {
return mockInjector;
} else if (injector == null) {
injector = new Injector();
}
return injector;
}
}
The injector above also adds a mockInjector
static variable. This allows us to provide a mock injector in our test classes.
Now lets update our TodoController
to use the Injector
public with sharing class TodoController {
private final TodoService todoService;
private TodoController() {
this.todoService = (TodoService) Injector.getInjector().instantiate('TodoService');
}
private static final TodoController self = new TodoController();
@AuraEnabled
public static List<Todo__c> getTodos() {
return self.todoService.getTodos();
}
}
Now we are using Dependency Injection to get our TodoService
instance! Lets make one more update to our Injector class to allow us to mock the TodoService
:
public with sharing class TodoController {
private final TodoService todoService;
private TodoController() {
this.todoService = (TodoService) Injector.getInjector().instantiate('TodoService');
}
private static final TodoController self = new TodoController();
@AuraEnabled
public static List<Todo__c> getTodos() {
return self.todoService.getTodos();
}
}
Note the trick to instantiate a singleton for our static methods
private static final TodoController self = new TodoController();
. This is necessary since@AuraEnabled
methods must be static. I got this pattern from a great blog post here.
Now, in our tets, we can replace our Injector
with a mock Injector that can return a mock TodoService
. Lets update are test from earlier to mock the Injector
and TodoService
by adding the below code to the top of our test file and removing the @TestSetup
method.
public static String todo1_Name = 'Sort Laundry';
public static Todo__c todo1 = new Todo__c(Name=todo1_Name);
public static List<Todo__c> mockTodoList = new List<Todo__c>();
// Define a mock todo service to return our
// mock todo list instead of using DML
private class MockTodoService extends TodoService {
public override List<Todo__c> getTodos() {
return mockTodoList;
}
}
// Define a mock injector that returns our
// mock todo service
private class MockInjector extends Injector {
public override Object instantiate(String className) {
return new MockTodoService();
}
}
Above we have a mock Injector
and mock TodoService
that the injector returns. Our complete test class looks like this:
@isTest()
public class TodoControllerManualMockTest {
public static String todo1_Name = 'Sort Laundry';
public static Todo__c todo1 = new Todo__c(Name=todo1_Name);
public static List<Todo__c> mockTodoList = new List<Todo__c>();
// Define a mock todo service to return our
// mock todo list instead of using DML
private class MockTodoService extends TodoService {
public override List<Todo__c> getTodos() {
return mockTodoList;
}
}
// Define a mock injector that returns our
// mock todo service
private class MockInjector extends Injector {
public override Object instantiate(String className) {
return new MockTodoService();
}
}
// Runs before each test
static {
// Setup the Injector to return the mock injector
Injector.mockInjector = new MockInjector();
// Setup some test data
mockTodoList.add(todo1);
}
@isTest()
private static void getTodos_returnsTodoList() {
// Test the method
List<Todo__c> todoList = TodoController.getTodos();
// Assertions
System.assertEquals(todo1_Name, todoList[0].Name);
}
}
Note that we are now mocking the TodoService
and no DML is needed in our test class for the TodoController
!
If this seems overcomplicated or unnecessary maybe it is for some use cases. For a lot of projects integration tests are fine. But if you find yourself overwhelmed by the complexity of your tests you might want to try splitting up your code and mocking your dependencies to simplify things.
In the next blog post I will be creating a simple mocking framework to create automatically create the mocks as opposed to manually defining the mock classes in our test class. This will also alleviate the need to make the Injector
and TodoService
virtual classes, so stay tuned!
Questions or concerns? Feel free to open an issue in the blog post repository.
Credit:
Organizing Invocable And Static Code by James Simone. Full of great patterns for working with static code. I got the idea for the singleton TodoController
& mocking patterns from here.
Breaking Runtime Dependencies with Dependency Injection by Philippe Ozil. Great article on DI in Apex. I used the injector in this article as a base for the injector I created.