Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to mock window.screen.width in Angular Unit Test with Jasmine

I have a BreakpointService, which tells me - depending on the screen width - in which SidebarMode (closed - minified - open) I should display my Sidebar.

This is the main part of the service:

constructor(private breakpointObserver: BreakpointObserver) {
    this.closed$ = this.breakpointObserver.observe(['(min-width: 1024px)']).pipe(
      filter((state: BreakpointState) => !state.matches),
      mapTo(SidebarMode.Closed)
    );

    this.opened$ = this.breakpointObserver.observe(['(min-width: 1366px)']).pipe(
      filter((state: BreakpointState) => state.matches),
      mapTo(SidebarMode.Open)
    );

    const minifiedStart$: Observable<boolean> = this.breakpointObserver.observe(['(min-width: 1024px)']).pipe(map(state => state.matches));

    const minifiedEnd$: Observable<boolean> = this.breakpointObserver.observe(['(max-width: 1366px)']).pipe(map(state => state.matches));

    this.minified$ = minifiedStart$.pipe(
      flatMap(start => minifiedEnd$.pipe(map(end => start && end))),
      distinctUntilChanged(),
      filter(val => val === true),
      mapTo(SidebarMode.Minified)
    );

    this.observer$ = merge(this.closed$, this.minified$, this.opened$);
  }

with this line I can subscribe to the events:

this.breakpointService.observe().subscribe();

Now, I would like to test the different modes within a unit test, but I don't know

how to mock the window.screen.width property within a test

I tried several things, but nothing worked out for me.

This is my test-setup so far:

describe('observe()', () => {
    function resize(width: number): void {
      // did not work
      // window.resizeTo(width, window.innerHeight);
      // (<any>window).screen = { width: 700 };
      // spyOn(window, 'screen').and.returnValue(...)
    }

    let currentMode;
    beforeAll(() => {
      service.observe().subscribe(mode => (currentMode = mode));
    });

    it('should return Observable<SidebarMode>', async () => {
      resize(1000);

      expect(Object.values(SidebarMode).includes(SidebarMode[currentMode])).toBeTruthy();
    });

    xit('should return SidebarMode.Closed', async () => {
      resize(600);

      expect(currentMode).toBe(SidebarMode.Closed);
    });

    xit('should return SidebarMode.Minified', async () => {
      resize(1200);

      expect(currentMode).toBe(SidebarMode.Minified);
    });

    xit('should return SidebarMode.Open', async () => {
      resize(2000);

      expect(currentMode).toBe(SidebarMode.Open);
    });
  });
like image 675
kauppfbi Avatar asked Nov 08 '18 09:11

kauppfbi


3 Answers

I'm guessing the BreakPointObserver listen to the resize event so maybe you could try something like mocking the window.innerWidth / window.outerWidth with jasmine ?

spyOnProperty(window, 'innerWidth').and.returnValue(760);

Then you manually trigger a resize event :

window.dispatchEvent(new Event('resize'));

It would look like that :

    it('should mock window inner width', () => {
        spyOnProperty(window, 'innerWidth').and.returnValue(760);
        window.dispatchEvent(new Event('resize'));
    });
like image 183
Mickael B. Avatar answered Oct 20 '22 16:10

Mickael B.


Mocking Angular Material BreakpointObserver

I'm guessing you don't really want to mock window.screen, you actually want to mock BreakpointObserver. After all, no need to test their code, you just want to test that your code responds properly to the observable returned by BreakpointObserver.observe() with different screen sizes.

There are a lot of different ways to do this. To illustrate one method, I put together a STACKBLITZ with your code showing how I would approach this. Things to note that differ from what your code is above:

  • Your code sets up the observables in the constructor. Because of this the mock has to be changed BEFORE the service is instantiated, so you will see the call to resize() happens before the service = TestBed.get(MyService); call.
  • I mocked BreakpointObserver with a spyObj, and called a fake function in place of the BreakpointObserver.observe() method. This fake function uses a filter I had set up with the results I wanted from the various matches. They all started as false, because the values would change depending on what screen size is desired to be mocked, and that is set up by the resize() function you were using in the code above.

Note: there are certainly other ways to approach this. Check out the angular material's own breakpoints-observer.spec.ts on github. This is a much nicer general approach than what I outline here, which was just to test the function you provided.

Here is a snip from the StackBlitz of the new suggested describe function:

describe(MyService.name, () => {
  let service: MyService;
  const matchObj = [
    // initially all are false
    { matchStr: '(min-width: 1024px)', result: false },
    { matchStr: '(min-width: 1366px)', result: false },
    { matchStr: '(max-width: 1366px)', result: false },
  ];
  const fakeObserve = (s: string[]): Observable<BreakpointState> =>
    from(matchObj).pipe(
      filter(match => match.matchStr === s[0]),
      map(match => ({ matches: match.result, breakpoints: {} })),
    );
  const bpSpy = jasmine.createSpyObj('BreakpointObserver', ['observe']);
  bpSpy.observe.and.callFake(fakeObserve);
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [],
      providers: [MyService, { provide: BreakpointObserver, useValue: bpSpy }],
    });
  });

  it('should be createable', () => {
    service = TestBed.inject(MyService);
    expect(service).toBeTruthy();
  });

  describe('observe()', () => {
    function resize(width: number): void {
      matchObj[0].result = width >= 1024;
      matchObj[1].result = width >= 1366;
      matchObj[2].result = width <= 1366;
    }

    it('should return Observable<SidebarMode>', () => {
      resize(1000);
      service = TestBed.inject(MyService);
      service.observe().subscribe(mode => {
        expect(
          Object.values(SidebarMode).includes(SidebarMode[mode]),
        ).toBeTruthy();
      });
    });

    it('should return SidebarMode.Closed', () => {
      resize(600);
      service = TestBed.inject(MyService);
      service
        .observe()
        .subscribe(mode => expect(mode).toBe(SidebarMode.Closed));
    });

    it('should return SidebarMode.Minified', () => {
      resize(1200);
      service = TestBed.inject(MyService);
      service
        .observe()
        .subscribe(mode => expect(mode).toBe(SidebarMode.Minified));
    });

    it('should return SidebarMode.Open', () => {
      resize(2000);
      service = TestBed.inject(MyService);
      service.observe().subscribe(mode => expect(mode).toBe(SidebarMode.Open));
    });
  });
});
like image 8
dmcgrandle Avatar answered Oct 20 '22 15:10

dmcgrandle


If you look at the tests for BreakpointObserver you will get you answer. You don't need to mock BreakpointObserver you need to mock the MediaMatcher that is injected into it. This is from one of my tests.

        let mediaMatcher: FakeMediaMatcher;

        class FakeMediaQueryList {
            /** The callback for change events. */
            private listeners: ((mql: MediaQueryListEvent) => void)[] = [];

            constructor(public matches: boolean, public media: string) {}

            /** Toggles the matches state and "emits" a change event. */
            setMatches(matches: boolean): void {
                this.matches = matches;

                /** Simulate an asynchronous task. */
                setTimeout(() => {
                    // tslint:disable-next-line: no-any
                    this.listeners.forEach((listener) => listener(this as any));
                });
            }

            /** Registers a callback method for change events. */
            addListener(callback: (mql: MediaQueryListEvent) => void): void {
                this.listeners.push(callback);
            }

            /** Removes a callback method from the change events. */
            removeListener(callback: (mql: MediaQueryListEvent) => void): void {
                const index = this.listeners.indexOf(callback);

                if (index > -1) {
                    this.listeners.splice(index, 1);
                }
            }
        }

        @Injectable()
        class FakeMediaMatcher {
            /** A map of match media queries. */
            private queries = new Map<string, FakeMediaQueryList>();

            /** The number of distinct queries created in the media matcher during a test. */
            get queryCount(): number {
                return this.queries.size;
            }

            /** Fakes the match media response to be controlled in tests. */
            matchMedia(query: string): FakeMediaQueryList {
                const mql = new FakeMediaQueryList(true, query);
                this.queries.set(query, mql);
                return mql;
            }

            /** Clears all queries from the map of queries. */
            clear(): void {
                this.queries.clear();
            }

            /** Toggles the matching state of the provided query. */
            setMatchesQuery(query: string, matches: boolean): void {
                const mediaListQuery = this.queries.get(query);
                if (mediaListQuery) {
                    mediaListQuery.setMatches(matches);
                } else {
                    throw Error('This query is not being observed.');
                }
            }
        }

        beforeEach(async () => {
            await TestBed.configureTestingModule({
                providers: [
                    { provide: MediaMatcher, useClass: FakeMediaMatcher },
                ],
            });
        });

        beforeEach(inject([MediaMatcher], (mm: FakeMediaMatcher) => {
            mediaMatcher = mm;
        }));

        afterEach(() => {
            mediaMatcher.clear();
        });

        describe('get isSideNavClosable$', () => {
            beforeEach(() => {
                // (Andrew Alderson Jan 1, 2020) need to do this to register the query
                component.isSideNavClosable$.subscribe();
            });
            it('should emit false when the media query does not match', (done) => {
                mediaMatcher.setMatchesQuery('(max-width: 1280px)', false);

                component.isSideNavClosable$.subscribe((closeable) => {
                    expect(closeable).toBeFalsy();
                    done();
                });
            });
            it('should emit true when the media query does match', (done) => {
                mediaMatcher.setMatchesQuery('(max-width: 1280px)', true);

                component.isSideNavClosable$.subscribe((closeable) => {
                    expect(closeable).toBeTruthy();
                    done();
                });
            });
        });
like image 1
Andrew Alderson Avatar answered Oct 20 '22 16:10

Andrew Alderson