Power of Eloquence

How to work with EventEmitters for unit testing in NodeJS

| Comments

Generated AI image by Microsoft Bing Image Creator

Happy New Year! Here’s to my first blog post of 2023.

Since my last post, I have finally managed to get a number of errands to accomplish before the end of 2022. One of the most important items was getting my father’s ashes finally sent homebound back to Malaysia in September for his eternal rest. It was great to be back home and get a good holiday stint around my hometown of Sibu, Sarawak as well as the capital city, Kuching where I haven’t been for a long time to just wind down and relax, along with the wedding preparations underway downunder.

Now, that’s all out of the way, it’s time to get back into my world of blogging once more!

Last year, I faced a deceptively complex but interesting solution design problem when working with NodeJS event-driven programming model, EventEmitter and how we should go about writing up appropriate unit testing when listeners were subscribing to certain events being emitted.

If you recalled your EventEmitter basics, EventEmitter is one of the core NodeJS modules that facilitates communication interaction between objects when running in asynchronous event-driven architecture. For any objects to inherit this module, the easiest thing is to write them extends like so.

class FooBar extends EventEmitter {
  constructor() {
    // some object properties to go here..
  }
}

By doing this, the same object is now inheriting a lot of EventEmitter’s methods.

Mainly, they are:

  • Emitting name events such as emit
  • Registering and unregistering listener functions such as on and off

In its simplest concept, emitter objects that emit named events will cause the previously registered listeners to be called upon. This programming model works basically like in pub/sub model more or less.

With that in mind, we can now look into this problem that I came across in one of my projects last year.

class CronService extends EventEmitter {
  constructor(configExpression, runSomeCronJobFunc, logger) {
    this.configExpression = configExpression;
    this.logger = logger;
    this.runSomeCronJobFunc = runSomeCronJobFunc;
    this.isStarted = false;
    this.isProcessing = false;
  }

  async start() {
    this.isStarted = true;
    this.performCronJob();
  }

  async stop() {
    this.isStarted = false;
  }

  async performCronJob() {

    if(this.isProcessing) {
      this.logger.info(()=> console.info('job is still running'));
      return;
    }

    this.isProcessing = true;

    try {
      await this.runSomeCronJobFunc();

      this.emit('finished', null)
    } catch(error) {
      this.warn(()=> console.warn('error encountered; job ended abruptly'));
      this.emit('finished', error);
    }

    this.isProcessing = false
  }

  getStatus() {
    return {
      isStarted: this.isStarted;
      isProcessing: this.isProcessing;s
    }
  }
}

Here, we have a CronService whose primary task is to kick off the cron job that executes at a certain interval we provide. Thus CronService uses configExpression for checking the cron interval expression and runSomeCronJobFunc as our main task placed in cron service for execution, along with the other auxiliary parameters in checking where in the state of the cron service being run isStarted and isProcessing.

The main area of interest to look is the performCronJob function block where our CronService emits two finished events. What does this block say?
In our project requirement, we say, in our try/catch block,

  1. We await this.runSomeCronJobFunc and expect the cron job task to execute into completion, we will emit finished to signify the job’s completed without errors.
  2. Or if we encountered any errors during the execution of the runSomeCronJobFunc , we capture the error and we will still emit finished to signify the job is also completed - but with errors this time.

For either of the two outcomes above, we will flag isProcessing to be false; it will not be marked as true if the current job is still in the middle of execution and we don’t want the next instance of cronjob service to kick off another one until it’s completed.

So, why is it that we emit finished instead of error one may ask? That’s because, in our solution design, we have a clear requirement any failed/incomplete cron job occurred is to be treated as a complete task so that at the next configExpression cycle we want to kick off the same CronService again. Our goal for CronService is to run batch job processing tasks at regular intervals throughout the day regardless if they were completed successfully or not.

That’s the context for our design rationale.

With that out of the day, we now come to the important part of the question - who are the listeners to these finished events and how do we thoroughly test the EventEmitters are working correctly based on the conditions above?

To start, we write our hypothetical unit test file here.

// our assertion utilities
const {assertThat, is, not, equalTo} = require('hamjest');
const sinon = require("sinon");

// setup mocks and test data
const logger = new someMockLogger();
const HOURLY_INTERVAL = 1000 * 60 * 60;

// cron to run hourly
const cronConfigExpression = "0 * * * *";

let someCronTaskPerformedCounter;

const performCronTask = () => {
  someCronTaskPerformedCounter++;
}

describe(('CronService'), () => {
  let cronService, clock;

  beforeEach(()=> {
    cronService = null;
    clock = sinon.useFakeTimers();
  });

  afterEach(()=> {
    sinon.restore();
    if(cronService !== null) {
      await cronService.stop();
    }
  });
  /// More unit test blocks to follow shortly....
})

What did we just write in the above?

  1. First, we add some assertion utilities into the mix using hamjest and sinon.
  2. We setup the mock and test data we need to pass/inject for the CronService constructor to work, ie logger, cronConfigExpression, HOURLY_INTERVAL, etc.
  3. We create performCronTask function call to pass as a callback parameter, that stores the total count of the cron task that gets performed ie someCronTaskPerformedCounter .
  4. We setup the describe test block for cronService, outlining the beforeEach and afterEach callbacks because we want to reset the cronService instantiation and SinonJS clock’s stub timer as well for each unit test we run (in this case, we’re doing one for the purpose of this demo);

Once your baseline unit testing structure is underway, we’re getting into the nitty-gritty of things.

it("should run every hour (0 * * * *)", async function () {
  cronService = new CronService(
    performSomeCronTask,
    cronConfigExpression,
    logger
  );

  assertThat(someCronTaskPerformedCounter, is(0));

  await cronService.start();
  clock.tick(HOURLY_INTERVAL);

  // This won't work!
  assertThat(someCronTaskPerformedCounter, is(1));
});

Here, our test should expect the cronService to run at hourly intervals meaning once the current cronjob gets to kick off, it’ll go for an hour (not in actual real-time hour interval thanks for SinonJS timebending’s clock.tick API ), and expects it’s still being processed ie isProcessing is true until it runs into completion by the exact hour.

So towards the end of running time, we first thought we could assert someCronTaskPerformedCounter to naturally increment to 1, isn’t it? But no, it’s not!

It would still stated at 0. No change here.

How on earth is it that possible when we’re not running things in real-time as everything is running under controlled environments?

But remember in the earlier performCronJob block of try/catch.

async performCronJob() {
  try {
    await this.runSomeCronJobFunc();

    this.emit('finished', null)
  } catch(error) {
    // boo!
  }
}

runSomeCronJobFunc inherently becomes a Promise and it will pause at this execution level until the same Promise gets settled (be rejected or fulfilled), which makes sense why our someCronTaskPerformedCounter assertion didn’t work because we expect it to complete prematurely too early during its running interval!

Thus we ask ourselves - what on earth should we ever come up with an assertion that says the runSomeCronJobFunc Promise will get fulfilled at some point without executing the rest of the test cases since everything will be running synchronously after that? We cannot expect to block the testing runtime because that will suspend the entire executing thread of the JS runtime space. Nothing will run at all!

How can we use the finished emitters signal for the unit test to be aware something has occurred downstream of events when multiple of cronService instances could be fired at some regular interval in a sequential manner?

The solution? 🤔

We create a new Promise wrapper over our promised assertions that gets fulfilled when the finished emitter gets triggered.

// our main ingredient
const cronServiceFinished = (cronService, runAssertions) => {
  return new Promise((resolve, reject) => {
    cronService.on('finished', (error) => {
      runAssertions(error).then(resolve, reject);
    }
  })
}

If you look at this closely, it makes sense right?

We are saying - as we are awaiting on runSomeCronJobFunc to complete, we then have to await cronServiceFinished until the finished emitters get triggered. Once it’s triggered, we use its EventEmitter callback signature for the emitted data (which in this case it’s null in the above example) to perform our assertions rules we see fit, which in itself is also a Promise!

With that in mind, we can now rewrite our unit test into the following

it("should run every hour", async function () {
  cronService = new CronService(
    performSomeCronTask,
    cronConfigExpression,
    logger
  );

  assertThat(someCronTaskPerformedCounter, is(0));

  await cronService.start();
  clock.tick(HOURLY_INTERVAL);

  const successAssertions = async (error) => {
    assertThat(error, is(null));
    assertThat(someCronTaskPerformedCounter, is(1));
  };

  // Now this works because it respects the async/await execution flow of the main cron task at hand.
  await cronServiceFinished(cronService, successAssertions);
});

That’s it!

Now we can make use of this to extend for another use case to verify cron job is still processing below:

it("verifies the cron service is processing when started", async function () {
  cronService = new CronService(
    performSomeCronTask,
    cronConfigExpression,
    logger
  );

  assertThat(someCronTaskPerformedCounter, is(0));

  const successAssertions = async (error) => {
    assertThat(error, is(null));
    assertThat(someCronTaskPerformedCounter, is(1));
  };

  await cronService.start();
  clock.tick(HOURLY_INTERVAL);

  const status = cronService.getStatus();
  assertThat(status.isProcessing, is(true));

  await cronServiceFinished(cronService, successAssertions);
});

Now how do we handle when the cronjob service encountered an error during its processing time with finished emitter triggered with error data?

We do the following:

it("verifies the cron service is processing when started", async function () {
  performSomeCronTaskWithErrorStub = sinon.stub().rejects(new Error("boo!"));

  cronService = new CronService(
    performSomeCronTaskWithErrorStub,
    cronConfigExpression,
    logger
  );

  assertThat(performSomeCronTaskWithErrorStub.notCalled, is(true));

  const failledAssertions = async (error) => {
    assertThat(error, is(not(equalTo(null))));
    assertThat(error.message, is(equalTo("boo!")));
    assertThat(performSomeCronTaskWithErrorStub.calledOnce, is(true));
  };

  await cronService.start();
  clock.tick(HOURLY_INTERVAL);

  await cronServiceFinished(cronService, failedAssertions);

  const status = cronService.getStatus();
  assertThat(status.isProcessing, is(false));
});

Here, we have created a mock cron job task comes with a stubbed error.

performSomeCronTaskWithErrorStub = sinon.stub().rejects(new Error("boo!"));

By using sinon.stub, we can make use of the spy method calls calledOnce or notCalled to inspect whether the internal states of the same function was called once or not at all respectivwely - hence for their named convenience methods.

Then our success assertions are now replaced with failure assertions that expect the error not to be null along with the generic error message that comes with it, and the performSomeCronTaskWithErrorStub was definitely called once.

When errors are encountered during its processing, we said to finished the cron job and thus marked the same instance state isProcessing to false as per our requirement.

So, there you have it.

This is how you want to write your unit test cases when dealing with EventEmitters in the event system flow where our unit test cases subscribe to these events and execute the assertions reliably from there. Especially when dealing with asynchronous operations like these, the use of Promise wrappers becomes incredibly handy when dealing with unexpected racing conditions between test cases running at different times to the finishing lines.

Hope you like it and you find this very useful.

Till next time, Happy Coding!

PS: If you want to find out how do Hamejest and SinonJS used in detail, you can find their resources here.

Comments