3

In my Angular 7 application, I have a service that is used to track active user tasks. In the service, a timer runs every second to check if any tasks still haven't been completed within 30 seconds. If any tasks are found to have expired, the task is emitted via an event emitter on the service to be handled elsewhere. This all works when the app is running in a browser, but when I try to write a unit test to test the behavior in a fakeAsync environment, tick(X) does not advance the time (or fakeAsync is not mocking the time for any 'new Date()' created within the service for tick() to work properly).

As I am new to angular unit testing, I also admit the issue could be how I am setting up the tests (in fact, I suspect this is the issue).

I have found a number of posts regarding older versions of Angular have had issues with Date not being mocked properly so the suggested workarounds were to use asyncScheduler to bypass tick or import other npm packages or, I have even tried other versions of the functions from the zone. I have tried these with no success. I have also tested the fakeAsync() and tick() functions from @angular/core/testing by running the simple test below which passes:

  it('should tick', fakeAsync(() => {
    const firstDate = new Date();
    tick(30000);
    const secondDate = new Date();
    expect(secondDate.getTime() - firstDate.getTime()).toBe(30000);
  }));

Here is a simplified version of the service:

export class UserTaskTrackerService {
  TaskExpired = new EventEmitter<UserTask>

  private activeUserTasks: UserTask[] = []
  private oneSecondTimer;
  private timerSubscription$;

  constructor() {
    this.oneSecondTimer = timer(1000, 1000);
    this.timerSubscription$ = this.oneSecondTimer.subscribe(() => {
       this.checkForExpiredUserTasks();
    });
  }

  addNewTask(task: UserTask) {
    if(this.taskExists(task)) {
      this.completeTask(task);  // not included in example
    }
    else {
      task.startTime = new Date();
      this.activeUserTasks.push(task);
    }
  }

  private checkForExpiredUserTasks() {
    const currentTime = new Date();
    const expiredTasks: UserTask[] = [];

    this.activeUserTasks.forEach(userTask => {
       if (this.taskHasExpired(userTask.startTime, currentTime)) {
         expiredTasks.push(userTask);
       }
    });

    if (expiredTasks.length > 0) {
       this.handleExpiredTasks(expiredTasks);
    }
  }

  private taskHasExpired(taskStartTime: Date, currentTime: Date): boolean {
     return (currentTime.getTime() - taskStartTime.getTime()) / 1000 > 30;
  }

  private handleExpiredTasks(expiredTasks: UserTasks[]) {
     // remove task(s) from collection and then emit the task
  }
}

Example unit tests. In this example, all testing functions from from @angular/core/testing

describe('User Action Tracking Service', () => {
   let service: UserTaskTrackerService;
   let testBed: TestBed;

   beforeEach(() => {
     TestBed.configureTestingModule({
         providers: [UserTaskTrackerService]
    });
   });

   beforeEach(() => {
     service = TestBed.get(UserTaskTrackerService);
   });

   it('should tick', fakeAsync(() => {
      const firstDate = new Date();
      tick(30000);
      const secondDate = new Date();
      expect(secondDate.getTime() - firstDate.getTime()).toBe(30000);
    }));

// Other tests removed for brevity

  it(`it should emit a UserTask when one expires`, fakeAsync(() => {
    let expiredUserTask: UserTask;

    service.TaskExpired.subscribe((userTask: UserTask) => {
      expiredUserTask = userTask;
    });

    service.addNewTask(new UserTask('abc', 'test action - request'));
    expect(service.getTaskCount()).toBe(1);

    tick(31000);

    expect(expiredUserTask).toBeDefined();
    expect(expiredUserTask.id).toBe('abc');
  }));
});

When the test runs, I get a failed result saying "expected 'undefined' to be 'defined'. "

If I continue to watch the console, ~30 seconds after the testing finished, I see some console.log output which I have in my service code which prints the expired user task when an expired task is found.

NickCoder
  • 1,504
  • 2
  • 23
  • 35
BEvans
  • 641
  • 1
  • 8
  • 12

2 Answers2

5

I have found the answer and I guess it makes sense.

TL:DR => When using timer() or setInterval() within a service (or component), the service (or component) needs to be created within the fakeAsync function in order to correctly patch the different date/time functions for the tick() function to work. Using a copy of the service or component created outside of fakeAsync() will not work. When using timers / setInterval within a service/ component, you will also need to have a function exposed to dispose of the timer after the test has finished or else you will get the error message:

Error: 1 periodic timer(s) still in the queue.

For those still reading, this is how I got the test working.

Add a 'disposeTimers()' function to the service.

disposeTimers() {
    if (this.timerSubscription$) {
        if (!this.timerSubscription$.closed) {
            this.timerSubscription$.unsubscribe();
            this.oneSecondTimer = undefined;
        }
    }
}

Then for my test, I used the following code:

   it(`it should emit a UserTask when one expires`, fakeAsync(() => {
      let expiredUserTask: UserTask;
      const singleTestService = new UserTaskTrackerService();

      singleTestService.TaskExpired.subscribe((userTask: UserTask) => {
      expiredUserTask = userTask;
      });

      singleTestService.addNewTask(new UserTask('abc', 'test action - request'));
      expect(singleTestService.getTaskCount()).toBe(1);

      tick(31000);

      expect(expiredUserTask).toBeDefined();
      expect(expiredUserTask.id).toBe('abc');
      singleTestService.disposeTimers();
   }));

I tried making it less hacky by using " beforeEach( fakeAsync() => { ... });" to generate the service but this causes the "1 periodic timer(s) still in the queue." error for every test, even if you dispose of the timers.

BEvans
  • 641
  • 1
  • 8
  • 12
0

It seems that the reason is rxjs timer which you use inside UserTaskTrackerService. Here is explanation of the problem and the solution. But in your case, the easiest solution should be to replace timer() with setInterval().

You can also use done() callback, to solve the problem, but in this case the test will take 30 seconds to complete. You can notice, I pass 31000 milliseconds timeout to the it() function, because the default timeout is 20 seconds, as I know.

it(`it should emit a UserTask when one expires`, done => {
  service.TaskExpired.subscribe(expiredUserTask => {
    expect(expiredUserTask).toBeDefined();
    expect(expiredUserTask.id).toBe('abc');
    done()
  });

  service.addNewTask(new UserTask('abc', 'test action - request'));
  expect(service.getTaskCount()).toBe(1);
}, 31000);
Valeriy Katkov
  • 33,616
  • 20
  • 100
  • 123
  • Thank you. This allows me to get the test to pass but the test now takes 31 seconds to complete. I will leave the question open a bit longer just in case someone can tell me how to use tick to speed up the testing process. – BEvans Jul 19 '19 at 10:50
  • Yes, sorry, it's important. I updated my answer with link to the other post, which explains the problem. – Valeriy Katkov Jul 19 '19 at 11:23
  • Thank you Valeriy. I had actually found the linked post before creating my own. That solution does not work. Under both the fakeAsync and fakeSchedulers testing environments result in the subscription not being called before the test completes. Even with using setInterval() rather than timer(). – BEvans Jul 22 '19 at 09:51