Wednesday, January 22, 2020

Debouncing Jasmine

I haven't been writing many unit tests lately, especially not in Jasmine for Angular. That led to a problem today that sucked up a few hours of my time and it really shouldn't have. I feel like at this point I've tested pretty much every "normal" thing I can so when I get stumped it really annoys me.

I have a typeahead control that uses debounceTime (from rxjs) to wait 250 milliseconds before firing off the request to the server. We do that to avoid banging against the server while the user is still typing something. In conjunction with debounceTime, we use switchMap to cancel the previous request and make sure the results we get back match what was actually searched for. When I tried to test this setup I was kept seeing that my spy had not been called and I could not figure out what was going on.

I finally Googled the right combination of terms and stumbled onto this answer on Stack Overflow. The gist of what's happening is that the debounceTime wasn't "passing" so the call to my service was never made. Here's the weird part, though: I added a bunch of logging and I could see that the service was receiving the call. That's why it took me so long to figure out what was going on. From my perspective it looked like the real service was being called instead of the spy I placed on the real service. That naturally sent me searching for known issues in Jasmine when the problem was with the design of my test.

OK, with all of that said, here's the component I was testing (or a close proximity of it anyway):
   1:  export class TypeaheadComponent implements OnInit {
   2:    public searchTermSubject = new Subject<string>();
   3:  
   4:    constructor(private searchService: SearchService) { }
   5:  
   6:    ngOnInit() {
   7:      this.searchTermSubject.pipe(
   8:        debounceTime(250)
   9:      )
  10:      .pipe(
  11:        switchMap(term => {
  12:          // check the term and do other stuff here before calling the server
  13:          return this.searchService.search(term);
  14:        })
  15:      ).subscribe((searchResults) => {
  16:        // do something with the results
  17:      });
  18:    }
  19:  
  20:    triggerSearch(term: string): void {
  21:      this.searchTermSubject.next(this.searchTerm);
  22:    }
  23:  }
  24:  

It looks pretty straightforward to me! Before I show the test that was failing, here's the overall test setup. I think it's important to include this so because I hate when I find an answer and something doesn't work quite right because whoever wrote it didn't show their import/using statements.
   1:  import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
   2:  import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 
   3:  
   4:  import { of } from 'rxjs';
   5:  
   6:  import { SearchService, MockSearchService } from '../providers'; 
   7:  
   8:  import { TypeaheadComponent } from './typeahead.component'; 
   9:  
  10:  describe('TypeaheadComponent', () =>
  11:    let component: TypeaheadComponent;
  12:    let fixture: ComponentFixture<TypeaheadComponent>;
  13:    let searchService: SearchService;
  14:  
  15:    beforeEach(async(() => {
  16:      TestBed.configureTestingModule({
  17:        imports: [FormsModule, ReactiveFormsModule],
  18:        declarations: [TypeaheadComponent],
  19:        providers: [
  20:          {
  21:            provide: SearchService,
  22:            useClass: MockSearchService
  23:          }
  24:        ]
  25:      }).compileComponents();
  26:  
  27:      searchService = TestBed.get(SearchService);
  28:  
  29:      spyOn(searchService, 'search').and.callFake(() => of({}));
  30:    }));
  31:  
  32:    beforeEach(() => {
  33:      fixture = TestBed.createComponent(TypeaheadComponent);
  34:      component = fixture.componentInstance;
  35:      fixture.detectChanges();
  36:    });
  37:  
  38:    // tests go here
  39:  
  40:  });
  41:  

Now that we've established that, here's the test I wrote to make sure the service function (search) was called:
   1:    describe('search', () => {
   2:      it('should search', () => {
   3:        component.triggerSearch('term');
   4:  
   5:        expect(searchService.search).toHaveBeenCalled();
   6:        expect(searchService.search).toHaveBeenCalledWith('term');
   7:      });
   8:    });
   9:  

Unfortunately, that's where the test was failing with an error message indicating that the spy should have been called, but it wasn't. Even looking at it now it still looks good to me. As I mentioned above, I added a bunch of logging to see what was going on and all the right spots were being hit, but my spy wasn't being called. It turns out I had to make a very small change that would "tick" the timer, causing the debounceTime function to allow everything to happen. Here's the updated test:
   1:    describe('search', () => {
   2:      it('should search', fakeAsync(() => {
   3:        component.triggerSearch('term');
   4:        tick(500);
   5:  
   6:        expect(searchService.search).toHaveBeenCalled();
   7:        expect(searchService.search).toHaveBeenCalledWith('term');
   8:      }));
   9:    });
  10:  

That was it. Wrap the test in the fakeAsync function and invoke a tick(500) after invoking the triggerSearch function. Now everything works as I expected.

Like I said at the beginning, my problem was that I assumed from the error message that something was wrong with Jasmine or the injection process in Angular. That's partly why it took me so long to figure out what was happening. Hopefully next time I run into this I remember to come back here and read this blog post.