Make an Event Listener test simpler by Promise

eye-catch JavaScript/TypeScript

A unit test should test only one thing but there might be some cases that a test checks multiple things because those values are the set of the target object. If we want to write a test for an event listener, we need to register multiple listeners and put evaluation logic there. To check if all listeners are called, a counter might be used in the test that is not a good way.
Let’s improve those tests by Promise.

Sponsored links

Service controller that registers services and listeners

Let’s consider that we have 3 services. The service is available only if the enabled property is true. I created simple classes for it.

import EventEmitter from "events";

export const Services = ["Service1", "Service2", "Service3"] as const;
export type MyService = typeof Services[number];

abstract class BaseService  {
    abstract readonly key: MyService;
    private _enabled: boolean = false;
    public get enabled(): boolean {
        return this._enabled;
    }
    setEnableState(value: boolean): void {
        this._enabled = value;
    }
}

class Service1 extends BaseService {
    public readonly key = Services[0];
}
class Service2 extends BaseService {
    public readonly key = Services[1];
}
class Service3 extends BaseService {
    public readonly key = Services[2];
}

ServiceController class has those services and manages the service state.

export class ServiceController {
    private eventEmitter = new EventEmitter();
    private services: BaseService[] = [
        new Service1(),
        new Service2(),
        new Service3(),
    ];

    public start(...enableServices: MyService[]): void {
        enableServices = enableServices.length !== 0 ? enableServices : Object.values(Services);

        this.services
            .filter((service) => enableServices.includes(service.key))
            .forEach((service) => {
                service.setEnableState(true);
                this.eventEmitter.emit(service.key, true);
            });
    }

    public stop(...disableServices: MyService[]): void {
        disableServices = disableServices ?? Services;

        this.services
            .filter((service) => disableServices.includes(service.key))
            .forEach((service) => {
                service.setEnableState(false);
                this.eventEmitter.emit(service.key, false);
            });
    }

    public on(eventName: MyService, listener: (value: boolean) => void): void {
        this.eventEmitter.on(eventName, listener);
    }
}

start and stop functions change all service states if no argument is specified and notify the value change to the listeners. I didn’t put a value check for the concise.

Check the following article if it’s not possible to trigger the listener callback in a unit test because the class must be replaced with stub.

Sponsored links

Make sure that done function is called in the test

Let’s check how to write a unit test for event listeners with mocha and chai modules. Basically, it’s the same as a normal unit test but there is a point that we should be aware of. The following test always passes.

describe("ServiceController", () => {
    let instance: ServiceController;

    beforeEach(() => {
        instance = new ServiceController();
    });

    describe("start", () => {
        it("should always be successful", () => {
            instance.on("Service1", (value: boolean) => {
                expect(value).to.be.true;
            });
            instance.start("Service1");
        });
    });
});

It passes even if we remove the start function call because the listener specified in the instance.on function isn’t called. If it’s not triggered the test passes without validation. Such a test is sometimes seen.

We should call done function in the listener. If it’s not triggered, the test throws a timeout error.

it("should set true to specified services", (done) => {
    instance.on("Service1", (value: boolean) => {
        try {
            expect(value).to.be.true;
            done();
        } catch (e) {
            done(e);
        }
    });
    instance.start("Service1");
});

Check multiple things in a test

It is basically not good to check multiple things in a test but we may want to put them together into one test to reduce the test execution time. You can learn about good unit test in the following article.

Using counter to check all listeners are called

The first way is simple but not good. It has a counter and it is decremented when each listener is triggered.

describe("should set true for all services when no argument is specified", () => {
    it("first", (done) => {
        let count = Services.length;
        instance.on("Service1", (value: boolean) => {
            try {
                expect(value).to.be.true;
                if (--count === 0) {
                    done();
                }
            } catch (e) {
                done(e);
            }
        });
        instance.on("Service2", (value: boolean) => {
            try {
                expect(value).to.be.true;
                if (--count === 0) {
                    done();
                }
            } catch (e) {
                done(e);
            }
        });
        instance.on("Service3", (value: boolean) => {
            try {
                expect(value).to.be.true;
                if (--count === 0) {
                    done();
                }
            } catch (e) {
                done(e);
            }
        });
        instance.start();
    });
});

The same code is repeated 3 times. Let’s refactor the code.

it("second", (done) => {
    let count = Services.length;
    const createListener = (name: MyService) => {
        instance.on(name, (value: boolean) => {
            try {
                expect(value).to.be.true;
                if (--count === 0) {
                    done();
                }
            } catch (e) {
                done(e);
            }
        });
    };
    createListener("Service1");
    createListener("Service2");
    createListener("Service3");
    instance.start();
});

It looks nicer than the previous one but a problem is hidden in this test. This test expects that all functions are triggered but the test passes if only one listener is triggered 3 times. There are multiple paths to make the test green. It is not good. We should improve this test.

Waiting for all listeners to be triggered by Promise

To check if all listeners are triggered, we need to have a variable that stores the result for each listener.

 it("third", (done) => {
    let enabled = [false, false, false];
    const createListener = (name: MyService, index: number) => {
        instance.on(name, (value: boolean) => {
            try {
                expect(value).to.be.true;
                enabled[index] = true;
                if (enabled.every((x) => x === true)) {
                    done();
                }
            } catch (e) {
                done(e);
            }
        });
    };
    createListener("Service1", 0);
    createListener("Service2", 1);
    createListener("Service3", 2);
    instance.start();
});

It works but less variables and less conditional clauses are better. Let’s remove them.

it("fourth", (done) => {
    const createListener = (name: MyService) => new Promise<void>((resolve, reject) => {
        instance.on(name, (value: boolean) => {
            try {
                expect(value).to.be.true;
                resolve();
            } catch (e) {
                reject(e);
            }
        });
    });

    Promise.all([
        createListener("Service1"),
        createListener("Service2"),
        createListener("Service3"),
    ])
        .then(() => done())
        .catch((e) => done(e));

    instance.start();
});

This test doesn’t contain any conditional clause. This test is better than others.

Check the following article as well if you are not familiar with Promise.

End

The best way for a test is to check only one thing in a test. Splitting the test above into 3 tests is the best if it’s possible. Try this approach if you need to improve your tests in a similar case.

Comments

Copied title and URL