Search code examples
angulartypescripttestingtimer

Angular Testing - Tick doesn't work if timer is in component initialization


Question

How do I get tick to work, or at least, how do I get the test to advance 10s to appropriately call submit in my component?

Note: I don't want to do await new Promise(r => setTimeout(r, 10000)) because it will make my tests run long, and tests are supposed to be short

Goal

I want submit to only call cb after 10 seconds has elapsed from the creation of the component

Description

I have a timer in my component that completes after 10s. This timer changes a subject from false to true, and is used to determine whether or not it is considered valid to submit the data in the component.

In testing, tick does not seem to advance the timer at all, and it actually runs a full 10s. I attempted to fix this by putting a fakeAsync in the beforeEach that creates the component to no avail.

What I have tried

  • Using fakeAsync in the test component init, as well as the test
  • Using fakeAsync in only the test
  • Using setTimeout(() => this.obs.next(true), 10_000) instead of timer
  • Using empty().pipe(delay(10000)).subscribe(() => this.obs.next(true)); instead of timer
  • Putting the timer in ngOnInit instead of constructor
  • Putting the timer in constructor instead of ngOnInit

Observations

If you adjust this code

    timer(10_000).subscribe(() => this.testThis$.next(true));

to instead be this

    timer(10_000).subscribe(() => {
      debugger;
      this.testThis$.next(true)
    });

You will find that every time a test runs, the Javascript debugger in Dev Tools is triggered 10 seconds after component creation (instead of instantly if tick worked).

Code

Here's the code. At the bottom is a link to a minimal reproduction on GitHub.

// component code
import { Component, OnInit, Inject } from '@angular/core';
import { BehaviorSubject, Subject, timer } from 'rxjs';
import { first, filter } from 'rxjs/operators';

@Component({
  selector: 'app-tick-test',
  templateUrl: './tick-test.component.html',
  styleUrls: ['./tick-test.component.scss']
})
export class TickTestComponent implements OnInit {

  public testThis$: Subject<boolean>;

  constructor(
    @Inject('TICK_CALLBACK') private readonly cb: () => void,
  ) {
    this.testThis$ = new BehaviorSubject<boolean>(false);
    timer(10_000).subscribe(() => this.testThis$.next(true));
  }

  public ngOnInit(): void {
  }

  public submit(): void {
    // call the callback after 10s
    this.testThis$
      .pipe(first(), filter(a => !!a))
      .subscribe(() => this.cb());
  }

}
// test code
/**
 * The problem in this one is that I am expecting `tick` to advance the
 * time for the timer that was created in the constructor, but it is not working
 */



import { async, ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing';

import { TickTestComponent } from './tick-test.component';

describe('TickTestComponent', () => {
  let component: TickTestComponent;
  let fixture: ComponentFixture<TickTestComponent>;

  let callback: jasmine.Spy;

  beforeEach(async(() => {

    callback = jasmine.createSpy('TICK_CALLBACK');

    TestBed.configureTestingModule({
      providers: [
        { provide: 'TICK_CALLBACK', useValue: callback },
      ],
      declarations: [ TickTestComponent ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(TickTestComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should be true after 10s', fakeAsync(() => {
    tick(10_001);

    component.submit();
    expect(callback).toHaveBeenCalled();
  }));
});

Minimal Reproduction Repo

Link To Github


Solution

  • Solution

    1. Move fixture.detectChanges() into each test, and call tick(10_000) there.
    2. Move timer(10_000)... to the ngOnInit in the component

    What was happening

    Whenever you use fakeAsync a "zone" that your code can run in is made. From my observations, this zone "exists" until it goes out of scope. By using fakeAsync in the beforeEach, you destroy the zone and you get issues with the timer not finishing (despite the timer not finishing being the desireable outcome).

    You want to move the timer into the ngOnInit because it is NOT called immediately upon the .createComponent call. It is instead called when you run fixture.detectChanges() for the first time. Thus when you call fixture.detectChanges() inside of a test's fakeAsync zone for the first time, ngOnInit is called for you, the timer is captured in the zone, and you can control time as expected.

    Code

    describe('TickTestComponent', () => {
      let component: TickTestComponent;
      let fixture: ComponentFixture<TickTestComponent>;
    
      let callback: jasmine.Spy;
    
      beforeEach(async(() => {
    
        callback = jasmine.createSpy('TICK_CALLBACK');
    
        TestBed.configureTestingModule({
          providers: [
            { provide: 'TICK_CALLBACK', useValue: callback },
          ],
          declarations: [ TickTestComponent ]
        })
        .compileComponents();
      }));
    
      beforeEach(() => {
        fixture = TestBed.createComponent(TickTestComponent);
        component = fixture.componentInstance;
        // don't run this here
        // fixture.detectChanges();
      });
    
      it('should create', () => {
        expect(component).toBeTruthy();
      });
    
      it('should be true after 10s', fakeAsync(() => {
        // this calls ngOnInit if it is the first detectChanges call
        fixture.detectChanges();
        tick(10_001);
    
        component.submit();
        expect(callback).toHaveBeenCalled();
      }));
    });