Angular signal testing with Jasmin and Karma

44 Views Asked by At

I'm trying to test whether the value of "showToolbar" is true or false based on a spyOn on getScrollTop().

My code :

@Component({
  selector: 'app-header',
  standalone: true,
  imports: [
    MatToolbar,
    MatIcon,
    MatIconButton,
    NgOptimizedImage,
    MatAnchor,
    MatIconAnchor,
    RouterLink
  ],
  templateUrl: './header.component.html',
  styleUrl: './header.component.scss',
  animations: [
    trigger('toolbarAnimation', [
      state('open', style({ transform: 'translateY(0px)'})),
      state('close', style({ transform: 'translateY(-100px)'})),
      transition('open => close',[animate('300ms ease-out')]),
      transition('close  => open',[animate('300ms ease-in')]),
    ]),
  ],
})

export class HeaderComponent {

  protected readonly sidenavOpeningService :SidenavOpeningService = inject(SidenavOpeningService);
 
  showToolbar: Signal<boolean>;
  private readonly topLimitShowToolbar = 349;

  constructor(private scrollDispatcher: ScrollDispatcher, private viewportRuler: ViewportRuler) {
    const scrollLimit = toSignal(this.scrollLimit(scrollDispatcher,viewportRuler,this.topLimitShowToolbar),{ initialValue: true }) ;
    const directionScroll = toSignal(this.directionScroll(scrollDispatcher,viewportRuler),{ initialValue: true });
    
    this.showToolbar = computed(() => directionScroll() || scrollLimit());
    
    effect(() => this.showToolbar());
  }

  getScrollTop(scrollDispatcher:ScrollDispatcher,viewportRuler:ViewportRuler): Observable<number> {
    return scrollDispatcher.scrolled().pipe(
      map(() => viewportRuler.getViewportScrollPosition().top),
      tap((_getScrollTop) => console.log('getScrollTop',_getScrollTop))
    );
  }

  scrollLimit(scrollDispatcher:ScrollDispatcher, viewportRuler:ViewportRuler, limit:number): Observable<boolean> {
    return this.getScrollTop(scrollDispatcher,viewportRuler).pipe(

      map((top) => top < limit),
      tap((_scrollLimit) => console.log('scrollLimit',_scrollLimit))
    );
  }

  directionScroll(scrollDispatcher:ScrollDispatcher, viewportRuler:ViewportRuler): Observable<boolean> {
    return this.getScrollTop(scrollDispatcher,viewportRuler).pipe(
      scan((acc: number[], current: number) => [acc[1], current], [viewportRuler.getViewportScrollPosition().top, 0]),
      skip(1), // On a besoin d'au moins deux valeurs pour savoir si le défilement monte ou descent
      map(([prev, current]) => prev >= current),
      tap((_directionScroll) => console.log('directionScroll',_directionScroll))
    );
  }
}

and

describe('HeaderComponent', () => {
  let component: HeaderComponent;
  let fixture: ComponentFixture<HeaderComponent>;
  let scrollDispatcher: ScrollDispatcher;
  let viewportRuler: ViewportRuler;
  let sidenavOpeningService: SidenavOpeningService;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [BrowserAnimationsModule,HeaderComponent,MatIconTestingModule],
      providers: [
        { provide: ActivatedRoute, useValue: {params: of([{id: 1}]),},},
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(HeaderComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();

    // Mock services
    scrollDispatcher = TestBed.inject(ScrollDispatcher);
    viewportRuler = TestBed.inject(ViewportRuler);
    sidenavOpeningService = TestBed.inject(SidenavOpeningService);
  });
  it('showToolbar should emit ture when scrolling down or up before the topLimit ', async() => {

    const topLimitShowToolbar = component['topLimitShowToolbar'];
    // Simuler la position de défilement et le comportement attendu
    spyOn(component, 'getScrollTop').and.returnValues(of(topLimitShowToolbar-100),of(topLimitShowToolbar+1), of(topLimitShowToolbar-50));
    fixture.detectChanges();
    expect(component.showToolbar()).toBeTrue();
  });
});

I expect the "returnValues()" to trigger the entire programming chain up to computed(), but this never happens, none of the console.log of the scollLimit and directionScroll functions triggers

1

There are 1 best solutions below

0
SaschaA1982 On

The component's constructor is running before the spyOn function is called. Therefore you are not able to provide the desired mock values. Try moving the constructor logic into the ngOnInit lifecycle method.

import {
  trigger,
  state,
  style,
  transition,
  animate,
} from '@angular/animations';
import { NgOptimizedImage } from '@angular/common';
import {
  Component,
  DestroyRef,
  OnInit,
  Signal,
  computed,
  effect,
  signal,
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
import { Observable, map, tap, scan, skip, combineLatest } from 'rxjs';

import { MatToolbar } from '@angular/material/toolbar';
import { MatIcon } from '@angular/material/icon';
import {
  MatAnchor,
  MatIconAnchor,
  MatIconButton,
} from '@angular/material/button';
import { ScrollDispatcher, ViewportRuler } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-header',
  standalone: true,
  imports: [
    MatToolbar,
    MatIcon,
    MatIconButton,
    NgOptimizedImage,
    MatAnchor,
    MatIconAnchor,
    RouterLink,
  ],
  templateUrl: './header.component.html',
  styleUrl: './header.component.scss',
  animations: [
    trigger('toolbarAnimation', [
      state('open', style({ transform: 'translateY(0px)' })),
      state('close', style({ transform: 'translateY(-100px)' })),
      transition('open => close', [animate('300ms ease-out')]),
      transition('close  => open', [animate('300ms ease-in')]),
    ]),
  ],
})
export class HeaderComponent implements OnInit {
  private readonly topLimitShowToolbar = 349;

  scrollLimitSig = signal(true);
  directionScrollSig = signal(true);

  showToolbar = computed(
    () => this.directionScrollSig() || this.scrollLimitSig(),
  );

  constructor(
    private scrollDispatcher: ScrollDispatcher,
    private viewportRuler: ViewportRuler,
    private destroyRef: DestroyRef,
  ) {}

  ngOnInit() {
    combineLatest([
      this.scrollLimit(
        this.scrollDispatcher,
        this.viewportRuler,
        this.topLimitShowToolbar,
      ),
      this.directionScroll(this.scrollDispatcher, this.viewportRuler),
    ])
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(([scrollLimit, directionScroll]) => {
        this.scrollLimitSig.set(scrollLimit);
        this.directionScrollSig.set(directionScroll);
      });
  }

  getScrollTop(
    scrollDispatcher: ScrollDispatcher,
    viewportRuler: ViewportRuler,
  ): Observable<number> {
    return scrollDispatcher.scrolled().pipe(
      map(() => viewportRuler.getViewportScrollPosition().top),
      tap((_getScrollTop) => console.log('getScrollTop', _getScrollTop)),
    );
  }

  scrollLimit(
    scrollDispatcher: ScrollDispatcher,
    viewportRuler: ViewportRuler,
    limit: number,
  ): Observable<boolean> {
    return this.getScrollTop(scrollDispatcher, viewportRuler).pipe(
      map((top) => top < limit),
      tap((_scrollLimit) => console.log('scrollLimit', _scrollLimit)),
    );
  }

  directionScroll(
    scrollDispatcher: ScrollDispatcher,
    viewportRuler: ViewportRuler,
  ): Observable<boolean> {
    return this.getScrollTop(scrollDispatcher, viewportRuler).pipe(
      scan(
        (acc: number[], current: number) => [acc[1], current],
        [viewportRuler.getViewportScrollPosition().top, 0],
      ),
      skip(1), // On a besoin d'au moins deux valeurs pour savoir si le défilement monte ou descent
      map(([prev, current]) => prev >= current),
      tap((_directionScroll) =>
        console.log('directionScroll', _directionScroll),
      ),
    );
  }
}

After that be sure to remove the fixture.detectChanges() call from the beforeEach section of the spec file as it would also call the ngOnInit lifecycle hook before the spyOn call.

Instead change the spec to something like this:

import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { HeaderComponent } from './header.component';
import { MatIconTestingModule } from '@angular/material/icon/testing';

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [BrowserAnimationsModule, HeaderComponent, MatIconTestingModule],
      providers: [
        { provide: ActivatedRoute, useValue: { params: of([{ id: 1 }]) } },
      ],
    });

    fixture = TestBed.createComponent(HeaderComponent);
    component = fixture.componentInstance;
  });

  it('showToolbar should emit ture when scrolling down or up before the topLimit ', async () => {
    const topLimitShowToolbar = component['topLimitShowToolbar'];
    // Simuler la position de défilement et le comportement attendu
    const getScrollTopSpy = spyOn(component, 'getScrollTop').and.returnValues(
      of(topLimitShowToolbar - 100),
      of(topLimitShowToolbar + 1),
      of(topLimitShowToolbar - 50),
    );

    fixture.detectChanges();

    expect(component.showToolbar()).toBeTrue();
    expect(getScrollTopSpy).toHaveBeenCalled();
  });
});