Don’t you have a good idea to write a test of the logic specified in a callback function? Isn’t it possible to control the timing to trigger the callback? Then, this article is for you.
Case explanation
The case I faced was something like the following.
SignalMonitor
is written in C++ and Broker
and Client are written in TypeScript. It is not possible to replace a function in SignalMonitor
class. Broker
registers a callback that Client
specifies. When a signal in a device changes SignalMonitor
notifies the changes to Broker
. Broker
propagates it to Client
. Client
is the target class in this article for the test. How can we write tests if we can’t replace the function of SignalMonitor
class? This is the topic of this article.
Actual implementation
I implemented the classes according to the diagram above. I implemented SignalMonitor class as well in TypeScript in order to make this execution easy but assume that it is written C++ and we can’t control it in real.
export type ListenerCallback = (value: number) => void;
export class SignalMonitor {
private cb?: (value: number) => void;
public register(cb: ListenerCallback) {
this.cb = cb;
}
public update(value: number): void {
this.cb?.(value);
}
}
export class Broker {
private listeners: ListenerCallback[] = [];
constructor(signalMonitor: SignalMonitor) {
signalMonitor.register((value: number) => {
this.listeners.forEach((listener) => listener(value));
});
}
public subscribe(cb: ListenerCallback): void {
this.listeners.push(cb);
}
}
export class Client {
private canTrigger = false;
private status = 0;
constructor(private broker: Broker) {
this.broker.subscribe((value: number) => {
const isEvenNumber = value % 2 === 0;
this.canTrigger = this.status === 2 && isEvenNumber;
});
}
public updateStatus() {
if (this.status > 2) {
this.status = 0;
} else {
this.status++;
}
}
public doSomething(): string {
if (this.canTrigger) {
return "loading";
}
return "...";
}
}
The next step is to write a unit test for Client class. We want to write tests to confirm if doSomething
function returns "loading"
when the status is 2 and it receives an even number from SignalMonitor. How can we write the test? We can’t trigger the listener specified in the constructor because the Broker class doesn’t have a function to trigger.
Extracting the logic into a function
We want to trigger the callback function in a unit test. The first step is to extract the logic into a function. Then, we’ll make it protected. We’ll eventually make it public in the unit test by extending the class. Client class looks like this below.
export class Client {
private canTrigger = false;
private status = 0;
constructor(private broker: Broker) {
this.broker.subscribe((value: number) => {
// const isEvenNumber = value % 2 === 0;
// this.canTrigger = this.status === 2 && isEvenNumber;
this.callbackFunc(value);
});
}
...
protected callbackFunc(value: number) {
const isEvenNumber = value % 2 === 0;
this.canTrigger = this.status === 2 && isEvenNumber;
}
}
The logic shouldn’t be public because it is not necessary to be exposed. However, we can’t call this function in a unit test. Let’s create an extended class in the test directory.
class ExtendedClient extends Client {
public callbackFunc(value: number): void {
super.callbackFunc(value);
}
}
This is a simple class. It just exposes the target function. We can play with this class instead of the actual Client class. This technique is useful in other languages as well.
With this class, we can write the following tests.
describe("Client", () => {
let client: ExtendedClient;
beforeEach(() => {
const monitor = new SignalMonitor();
const broker = new Broker(monitor);
client = new ExtendedClient(broker);
})
describe("doSomething", () => {
it("should return '...' for the first time", () => {
const result = client.doSomething();
expect(result).to.equal("...");
});
it("should return '...' when status is 2 but it receives no value from monitor", () => {
client.updateStatus();
client.updateStatus();
const result = client.doSomething();
expect(result).to.equal("...");
});
it("should return 'loading' when status is 2 and it receives even number from monitor", () => {
client.updateStatus();
client.updateStatus();
client.callbackFunc(2); // it's originally protected function
const result = client.doSomething();
expect(result).to.equal("loading");
});
});
});
callbackFunc
is callable now, so we could write the test.
Extracting the check logic into a class
Another way to make it testable is to separate the class. Move the logic that you want to control if it is not possible to control from a unit test. In this example, we want to set true
to canTrigger
property. Therefore, we will move the related logic to another class.
export class ClientStatus {
// static function can be replaced with fake object
public static factory(): ClientStatus {
return new ClientStatus();
}
private _canTrigger = false;
private status = 0;
private constructor() { }
public get canTrigger(): boolean {
return this._canTrigger;
}
public updateStatus() {
if (this.status > 2) {
this.status = 0;
} else {
this.status++;
}
}
public callbackFunc(value: number) {
const isEvenNumber = value % 2 === 0;
this._canTrigger = this.status === 2 && isEvenNumber;
}
}
I made the constructor private because I want to replace the class in a unit test. If we don’t want to use it we need another class like this.
class ClientStatusFactory {
public static factory(): ClientStatus{
return new ClientStatus();
}
}
If the language you are using doesn’t have the capability to replace a static function with a fake object following way can be another solution.
class ClientStatusFactory {
private static _instance: ClientStatus;
public static factory(): ClientStatus{
if(!this._instance) {
this._instance = new ClientStatus();
}
return this._instance.
}
public static set instance(value: ClientStatus) {
this._instance = value;
}
}
In this way, you can set a fake object via setter in a unit test.
Client class looks like this. It is simpler than the previous version.
export class Client2 {
private clientStatus = ClientStatus.factory();
constructor(private broker: Broker) {
this.broker.subscribe((value: number) => {
this.clientStatus.callbackFunc(value);
});
}
public updateStatus() {
this.clientStatus.updateStatus();
}
public doSomething(): string {
if (this.clientStatus.canTrigger) {
return "loading";
}
return "...";
}
}
ClientStatus.factory()
is used internally because it is an internal thing and it shouldn’t be part of constructor arguments. We’re ready to write the test.
describe("Client2", () => {
let client: Client2;
let clientStatus: ClientStatus;
beforeEach(() => {
const monitor = new SignalMonitor();
const broker = new Broker(monitor);
clientStatus = ClientStatus.factory();
// replace factory function
sinon.stub(ClientStatus, "factory").returns(clientStatus);
client = new Client2(broker);
});
afterEach(()=>{
sinon.restore();
});
describe("doSomething", () => {
it("should return '...' for the first time", () => {
const result = client.doSomething();
expect(result).to.equal("...");
});
it("should return '...' when status is 2 but it receives no value from monitor", () => {
client.updateStatus();
client.updateStatus();
const result = client.doSomething();
expect(result).to.equal("...");
});
it("should return 'loading' when status is 2 and it receives even number from monitor", () => {
client.updateStatus();
client.updateStatus();
clientStatus.callbackFunc(12);
const result = client.doSomething();
expect(result).to.equal("loading");
});
});
});
Assigning new value to the target property directly
If the language is a dynamic language it might be possible to assign a new value directly. canTrigger
property is actually a private member but we can set value from outside when we make it any.
it("should return 'loading' when canTrigger is true", () => {
(client as any).canTrigger = true;
const result = client.doSomething();
expect(result).to.equal("loading");
});
This is not so good way because it doesn’t test if true
is set to canTrigger
when it fulfills necessary conditions. This is the last resort in a case that we have no other solutions.
End
The key is extracting the logic that we want to control. The destination can be a class or protected function. If it is a protected function we need to create a class that extends the class and exposes the function public.
Comments