Getting our Cron Tests Passing in NestJs

Session IV of Building a Leetcode Contest Percentile Analyser Part II

ยท

6 min read

Recap

I lost my initial notes from my fourth session ๐Ÿ˜ญ ๐Ÿ˜ญ ๐Ÿ˜ญ because of the way I was updating the same article, so going forward I'm using a dedicated article for each session.

Right. Continuing my fourth session, I'm starting from a place where I have mocked the class housing our cron jobs, and I am in-between mocking the date.now for my first test.

import { Test, TestingModule } from '@nestjs/testing';
import { TasksService } from './tasks.service';

const TasksServiceMock = {
  scrapeContestData: jest.fn().mockImplementation(() => {
    return;
  }),
};

describe('TasksService', () => {
  let tasksService: TasksService;

  beforeEach(async () => {
    const moduleRef: TestingModule = await Test.createTestingModule({
      providers: [
        {
          provide: TasksService,
          useValue: TasksServiceMock,
        },
      ],
    }).compile();

    tasksService = moduleRef.get<TasksService>(TasksService);
  });

  describe('weekly', () => {
    test('Feb 13 at 4:30AM, scrapeContestData TO BE called', () => {

        jest
        .spyOn(global.Date, 'now')
        .mockImplementationOnce(() =>
          new Date('2022-02-13T11:01:58.135Z').valueOf()
        );
      expect(tasksService.scrapeContestData).toHaveBeenCalled();
    });

Mocking the service relied heavily on the example code nest provided. Yaaay for example code ๐ŸŽ‰ ๐Ÿฅณ .

While I quickly looked up an example online for how to mock date.now, but considering how many times I did this in my past role you'd think I wouldn't need to look this up anymore ๐Ÿ˜… .

Also, I'm specifying Feb 13 at 4:30AM as my mock and choosing to use a Date string to specify this, so I'm heading over to the ECMAScript Language Specification to cross-check that I'm using the right format, and afterwards, it'll be a couple of minutes of coding/debugging to get my first test passing.

First version of my test

    test('Feb 13 at 4:30AM, scrapeContestData TO BE called', () => {

        jest
        .spyOn(global.Date, 'now')
        .mockImplementationOnce(() =>
          new Date('2022-02-13T04:30Z').valueOf()
        );
      expect(tasksService.scrapeContestData).toHaveBeenCalled();
    });

At this point the test looks fine... now we need to make it pass.

Debugging a failing test

A few moments later, apparently the test is not fine because I can't get it to pass using the specific date I'm testing for.

Looking again at what the documentation says about task scheduling, I'm pretty sure I'll need to set this up for the test to work properly.

image.png

But first, let's see if some fluke happens to make my test pass when I configure my cron job to run every second.

A few moments later, no fluke occurs. And as we can see in the image below the cron job is definitely running, but the test is not going green.

image.png

This means there's something wrong with our test and we need to update it.

Looking at NestJs codebase for an example

Uhh, so I went to the src code to see how nest tests their own cron package ๐Ÿคทโ€โ™‚๏ธ

For starters it looks like I need an integration test that bootstraps the entire app for what I want to test. I can see that the nest codebase initializes an application in each test and keeps track of the calls made by the cronService in the cronService class itself, so I'm going to adopt this pattern.


  it(`should schedule "cron"`, async () => {
    ...
    await app.init(); /**This is where they initialize the app**/
    clock.tick(3000);

    expect(service.callsCount).toEqual(3);
  });
@Injectable()
export class CronService {
  callsCount = 0; /**They keep track of cron count here**/
  dynamicCallsCount = 0;
  ...
  }

Our first passing test

And so with a few modifications we have our first passing test ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ

image.png

Of course we can't stop here lol. To get this test passing, we're calling the cronService every second. Let's add a failing test that forces us to be more specific with our cronService schedule.

image.png

!!Note: The title of the failing test on line 37 should be: "a day after Feb 13 at 4:30AM, scrapeContestData to NOT BE called."

Now we have one test where we don't expect the service to run which is failing. We'll go ahead to make this green, and when we do we'll move on to the next test, and so on, and so forth.

A couple of moments later we pass our second test by saying run every second on sunday only

image.png

OOPS! We broke our first test

image.png

But that's odd, because Feb 13th IS a sunday. Well, turns out the way we're mocking our dates isn't taking effect. Okayyy, I noticed in the nest codebase they use sinon fake timers, so rather than mocking date.now, I'm going to switch over to using sinon.

Ahhh, actually scratch that! That's quite a leap to conclude the mock isn't working, so let's take a closer look at the cron config.

@Cron('* * * * sun *')

From (https://docs.nestjs.com/techniques/task-scheduling)
* * * * * *
| | | | | |
| | | | | day of week
| | | | months
| | | day of month
| | hours
| minutes
seconds (optional)

Looks like I put sun in the wrong place. So, correcting this I should get both tests passing but...

Nahh, that didn't work. So what's wrong?

I decide to log the date I have after I have mocked my Date.now and this log confirms that in the second test the date.now mock isn't taking effect in the sense that it's not wholistic. Date.now does get mocked, but new Date.getDay() still returns sun instead of mon. Therefore perhaps switching to sinon is a good idea.

So let's do that. We'll add sinon as a dev dependency and use it just as they used it in the nest codebase.

Using sinon to get our tests passing again

A Couple of moments later I've added sinon and importantly sinon typescript types, and it's looking good now ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ ๐Ÿฅณ .

image.png

The rest of session four is spent bringing in new test cases one at a time. When I'm done, all my weekly test cases pass with the cron specification below, giving me some confidence that my cron job will run at the time I intend it to run.

@Injectable()
export class TasksService {
  private readonly logger = new Logger(TasksService.name);

  _called = 0;

  @Cron('* 30 4 * * sun')
  scrapeContestData(): void {
    this._called += 1;
  }
}

Before ending this session I've also decided to push the code to github so that I don't lose all the work that went into this session ๐Ÿ˜… .

In the next session, I'll focus on bringing the biweekly tests online, doing some refactoring to make these tests more maintainable from the point of view of wanting to change the scheduled run time. I.e we don't want to touch 16 test cases each time we change the schedule, so a loop will be helpful. And following this refactoring, I'll pull in the logic I implemented in part 1 of these articles for actually scrapping the leetcode contest data.

ย