LWC Testing - Mocking registerListener in pubsub
Communication to sibling components in LWC is not included out of the box, but luckily the lwc-recipes GitHub repo contains a pubsub component that can handle this for you. Testing these custom events, however, was a little tricky and I wanted to share how I approached it.
Let’s say you have two components - one that displays data and one that displays a status. When the data is retrieved from the Apex controller, you want to fire an event so that the status component knows to display the status. With pubsub, you can fire an event from the data component and have the status component listen for that event. Now your two components can communicate while also staying loosely coupled. Here’s a simple version of what the JavaScript would look like for each component.
//c-data-component
import { LightningElement, wire } from 'lwc'
import { CurrentPageReference } from 'lightning/navigation'
import getData from '@salesforce/apex/DataController.getData'
import { fireEvent } from 'c/pubsub'
export default class DataComponent extends LightningElement {
@wire(CurrentPageReference) pageRef;
fetchData() {
getData()
.then(
function() {
fireEvent(this.pageRef, 'myevent')
}.bind(this)
);
}
}
//c-status-component
import { LightningElement, wire, track } from 'lwc'
import { CurrentPageReference } from 'lightning/navigation'
import { fireEvent, registerListener, unregisterAllListeners } from 'c/pubsub'
export default class DataComponent extends LightningElement {
@wire(CurrentPageReference) pageRef;
connectedCallback() {
registerListener('myevent', this.displayStatus, this);
}
disconnectedCallBack() {
unregisterAllListners(this);
}
displayStatus() {
this.displayStatus = true;
this.template.querySelector('.status').classList.remove('slds-hide');
}
}
//html
<template>
<div class="status slds-hide">Data Retrieved!></div>
<template>
In summary:
- When
fetchData()
is called byc-data-component
, it fires themyevent
event - When
c-status-component
is initialized, it registers a listener formyevent
- When
myevent
is fired,c-status-component
should calldisplayStatus
and display the status div
As a part of the c-status-component
lwc-jest tests, we should confirm that the listener is registered, and that when the myevent
is triggered, the status div is displayed. Testing that the listeners are registered is fairly straightforward, there are a lot of examples of this in lwc-recipes.
import { createElement } from 'lwc';
import StatusComponent from 'c/statusComponent'
import { registerListener, unregisterAllListeners } from 'c/pubsub'
//mock the pubSub methods
jest.mock('c/pubsub', () => {
return {
registerListener: jest.fn(),
unregisterAllListeners: jest.fn()
}
})
//remove all elements from test DOM after each test
afterEach(() => {
while (document.body.firstChild) {
document.body.removeChild(document.body.firstChild);
}
jest.clearAllMocks();
})
describe('Listeners', () => {
it('should register and unregister myevent listener', () => {
let element = createElement('c-status-component', { is: StatusComponent }
document.body.appendChild(element);
expect(registerListener.mock.calls.length).toBe(1);
expect(registerListener.mock.calls[0][0]).toEqual('myevent');
document.body.removeChild(element);
expect(unregisterAllListeners.mock.calls.length).toBe(1)
})
})
This is pretty boiler plate for any component that you want to use pubsub with. However, I was a little puzzled on how also wanted to test displaying the status. If registerListener is being mocked, then even if I could figure out how to fire this custom event, the mock wouldn’t fire the callback. Luckily, Jest mocks allow you to access the parameters used to call a mocked method.
So I figured a good test would be to intercept the callback parameter, fire it manually in the test, which would adequately simulate the pubsub listener firing the callback.
describe('Listeners', () => {
...
it('should display the status when the myevent is fired', () => {
//Access the first call of registerListener and get the 2nd parameter, which is the callback
let myeventCallback = registerListener.mock.calls[0][1];
//Access the first call of registerListener and get the 3rd parameter
//which is what `this` should be bound to
let thisArg = registerListener.mock.calls[0][1];
let element = createElement('c-status-component', { is: StatusComponent }
document.body.appendChild(element);
//fire the callback
myeventCallback.call(this)
//return a promise to resolve DOM changes
return Promise.resolve().then() => {
const statusDiv = element.shadowRoot.querySelector('.status');
expect(statusDiv.classList).not.toContain('slds-hide');
}
})
})
When document.body.appendChild(element);
is called, that will fire connectedCallback
, and the mock for registerListener will intercept the parameter this.displayStatus
. Now you can access that parameter and call the method with an explicitly set this
argument. Hopefully this helps you when writing tests on pubsub events!
Call Me Maybe - Using the Callable Interface to Build Versioned APIs
In Winter ‘19, Salesforce introduced the Callable Interface.
Enables developers to use a common interface to build loosely coupled integrations between Apex classes or triggers, even for code in separate packages. Agreeing upon a common interface enables developers from different companies or different departments to build upon one another’s solutions. Implement this interface to enable the broader community, which might have different solutions than the ones you had in mind, to extend your code’s functionality.
In short, you implement the interface and its single call
method, pass it the name of the action you want to call and a Map<String,Object>
with any necessary parameters, which dispatches the logic from there.
public class CallMeMaybe implements Callable {
public Object call(String action, Map<String, Object> args) {
switch on action {
when 'doThisThing' {
service.doThisThing();
}
when 'doThatThing' {
service.doThatThing();
}
}
return null;
}
}
public class Caller {
public void callTheCallable() {
if (Type.forName('namespaced__CallMeMaybe') != null) {
Callable extension = (Callable) Type.forName('namespaced__CallMeMaybe').newInstance();
extension.call('doThisThing', new Map<String,Object>());
}
}
}
There’s nothing too novel here other than the conveniences this new standard interface gives us, the largest being the ability to execute methods in other packages without having a hard dependency on that package. What jumped out to me, however, was the idea of dispatching actions using a string parameter and how we can use that to build more flexible APIs in managed packages.
Versioned APIs
One way to expose a method for execution in a managed package is to mark it as global. These global methods serve as an API to your package. However, if you ever wanted to adjust the behavior of a global method, you risked causing unintended side affects on subscribers that depend on the original implementation. To get around this, I generally see packages create additional global methods with names like myMethodV2
.
The finality of global methods tend me to make me agonize over creating them. Yes, you can deprecate them, but it felt like you were polluting your namespace. myMethodV2
may seem ok, but myMethodV16
starts to feel a little messy. Did you know there are 15 The Land Before Time movies? It’s not a good look.
Instead, what if you created a single Callable entry point into your org as an API?
public class VersionedAPI implements Callable {
public Object call(String action, Map<String, Object> args) {
//format actions using the template "domain/version/action"
//e.g. "courses/v1/create"
List<String> actionComponents = action.split('/');
String domain = actionComponents[0];
String version = actionComponents[1];
String method = actionComponents[2];
switch on domain {
when 'courses' {
return courseDomain(version, method, args);
}
when 'students' {
return studentDomain(version, method, args);
}
...
}
return null;
}
public Object courseDomain(String version, String method, Map<String, Object> args) {
if (version == 'v1') {
switch on method {
when 'create' {
return courseServiceV1.create();
}
...
}
} else if (version == 'v2') {
switch on method {
when 'create' {
return courseServiceV2.create();
}
...
}
}
}
...
}
By following this pattern, you’ll have a little more flexibility in defining your exposed methods without having to worry about the permanence of that method.
- Typos in your action names aren’t forever anymore!
- Remove actions that you don’t need. No more ghost town classes filled with
@deprecated
methods - Use new versions to change an actions behavior while allowing your subscribers to update their references at their convenience
- Experiment with new API actions in a packaged context without fear of them living in the package forever if you change your mind
Of course, with this added flexibility comes the burden of communicating these changes out to your subscribers - if you remove an action, make sure to have a migration plan in place so your subscribers aren’t suddenly faced with a bug that you introduced. By following this pattern, however, I hope it will encourage more developers to expose more functionality as well as foster inter-package testing.
Apex Quirks - Interfaces
The best part about working with code that you didn’t write is learning something new, like running into a new pattern or a feature that you didn’t even know existed. For the most part, you’ll think to yourself “Neat!” put it in your pocket for future use, and move on. Occasionally, however, you’ll run into something that stops you in your tracks. I don’t just mean confusing, spaghetti code. I’m talking about the times when you think, “Wait, that shouldn’t even work.”
I stumbled upon a piece of code that implemented an interface, yet only contained static methods. For example:
public interface MyInterface {
void doTheThing();
}
public with sharing class StaticImplementation implements MyInterface {
public static void doTheThing() {
System.debug('I\'m static!');
}
}
To my understanding, an interface is fulfilled using instance methods, like so:
public with sharing class InstanceImplementation implements MyInterface {
public void doTheThing() {
System.debug('I\'m an instance!');
}
}
MyInterface instanceImp = new InstanceImplementation();
instanceImp.doTheThing(); //Outputs "I'm an instance!"
But StaticImplementation
was compiling successfully, so it appears to be valid. At first I thought, “Can you actually execute static methods off of an instance of that class?”. Let’s try:
StaticImplementation staticImp = new StaticImplementation();
staticImp.doTheThing(); //Error: Static method cannot be referenced from a non static context
As I expected, you cannot. So at least I wasn’t grossly mistaken on how static methods are called. But if the interface is being fulfilled by that class, what happens when I call that method using the interface declaration? I guessed we’d get a weird runtime error:
MyInterface staticImp = new StaticImplementation();
staticImp.doTheThing(); //Outputs "I'm static!"
WHAT?! The static method is being run as an instance method!
I’m 99% sure this is a bug in the Apex compiler that just happens to work. Otherwise I am very mistaken in my understanding of how interfaces work in object oriented languages.
So what does this mean? My immediate thought was “Maybe I can use this to mock static methods!” But a cautionary quote came to mind:
“Your scientists were so preoccupied with whether or not they could, they didn’t stop to think if they should.” - Ian Malcolm in Jurassic Park
My advice is don’t try to leverage this as a feature. At best, you’ll confuse other developers, including yourself in 6 months, as to why this works. At worst, if this is actually an Apex bug and is fixed in an update, you’ll find yourself locked into older Apex API versions until you refactor this “feature” out of your existing code.
As a developer, you should aim to write code that has clear intent and is human readable. Leveraging “hidden” features/bugs is a red flag because it will potentially confuse others down the road, will have little to no documentation, and may not even exist in the future. “Clever” code should not come at the expense of clean and clear code, especially in a scenario like this that gives you so little benefit.
Hopefully if you run into this problem, now you’ll understand why it works, and why you should not do the same.