4

I have this root AppComponent that listenens for a change on a service, and then adds or removes a CSS class on the document.body

import { Component, OnInit, Renderer2 } from '@angular/core';
import { SideMenuService } from './core/side-menu/side-menu.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
  static readonly sideMenuClass: string = 'side-menu-open';

  constructor(public sideMenuService: SideMenuService, private renderer2: Renderer2) { }

  ngOnInit(): void {
    this.sideMenuService.isOpenChange.subscribe((value: boolean) => {
      if (value) {
        this.renderer2.addClass(document.body, AppComponent.sideMenuClass);
      } else {
        this.renderer2.removeClass(document.body,  AppComponent.sideMenuClass);
      }
    });
  }
}

And then I have this in my *.spec.ts file, much of which I took from reading this SO answer

import { TestBed, async, ComponentFixture, tick } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { CoreModule } from './core/core.module';
import { AppComponent } from './app.component';
import { Renderer2, Type } from '@angular/core';

describe('AppComponent', () => {
  let fixture: ComponentFixture<AppComponent>;
  let app: AppComponent;
  let renderer2: Renderer2;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        CoreModule
      ],
      declarations: [
        AppComponent
      ],
      providers: [Renderer2]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(AppComponent);
    app = fixture.debugElement.componentInstance;
    //Spy on the renderer
    renderer2 = fixture.componentRef.injector.get<Renderer2>(Renderer2 as Type<Renderer2>);
    spyOn(renderer2, 'addClass').and.callThrough();
 });

  it(`should toggle a class on the <body> tag when opening/closing the side-menu via the side-menu service`, () => {
    app.sideMenuService.open();
    fixture.detectChanges();
    console.log(fixture.debugElement.nativeElement, document.body)
    expect(renderer2.addClass).toHaveBeenCalledWith(jasmine.any(Object), AppComponent.sideMenuClass);
  });
});

However, right now it gives me the error message

Expected spy addClass to have been called with [ , 'side-menu-open' ] but it was never called.

What do I need to do to properly test this component? Am I even on the right track here?


Edit:

Here is the side-menu.service.ts

import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class SideMenuService {
  isOpen: boolean = false;

  isOpenChange: Subject<boolean> = new Subject<boolean>();

    constructor()  {
        this.isOpenChange.subscribe((value: boolean) => {
            this.isOpen = value;
        });
    }

  open(): void {
    this.isOpenChange.next(true);
  }
  close(): void {
    this.isOpenChange.next(false);
  }
}
Chris Barr
  • 29,851
  • 23
  • 95
  • 135
  • `this.sideMenuService.isOpenChange.subscribe` will likely leak memory if the stream does not complete. – Reactgular Mar 14 '19 at 14:39
  • Can you please elaborate on that? – Chris Barr Mar 14 '19 at 14:45
  • When you call `subscribe()` it will continue to listen after the component has been destroyed. It will listen until the stream completes. You can use operators or subscriber to manage the life cycle of a subscription. https://blog.angularindepth.com/why-you-have-to-unsubscribe-from-observable-92502d5639d0 – Reactgular Mar 14 '19 at 14:49
  • This is the root component, and the sidebar is something that never goes away, so I don't really see a situation in which it would need to be destroyed/unsubscribed for our use case. – Chris Barr Mar 14 '19 at 14:55
  • Well, I have no response for that comment. Good luck. – Reactgular Mar 14 '19 at 17:43
  • I'm a little new to working with subscriptions, and your tone makes it sound like I'm doing something terribly wrong here. I'd love to learn more about this and how to improve it. However... that seems unrelated form my actual question here – Chris Barr Mar 14 '19 at 17:58
  • No, you're not doing something terrible. I just found your comment unexpected. You can do what you want. I can't answer your question. So I wished you luck. I just didn't know what to say. – Reactgular Mar 14 '19 at 18:29
  • Can we see the code of your service? – Fabian Küng Mar 15 '19 at 08:47
  • Sure, i'ts been added now. perhaps I should be testing the service directly? Do i even need to worry about the test on the `` class? – Chris Barr Mar 15 '19 at 12:30

1 Answers1

2

I ended up not using the Renderer and injecting a window service that I can mock in testing.

in my app.module.ts I have

@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [ ... ],
    providers: [
        {provide: 'Window', useValue: window},
        ...
    ],
    bootstrap: [AppComponent]
})
export class AppModule { }

in app.component.ts:

import { Component, OnInit, Inject } from '@angular/core';
import { SideMenuService } from './services/side-menu/side-menu.service';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
    static readonly sideMenuClass: string = 'side-menu-open';

    constructor(@Inject('Window') private window: Window, public sideMenuService: SideMenuService) { }

    ngOnInit(): void {
        this.sideMenuService.isOpen$.subscribe((value: boolean) => {
            if (value) {
                this.window.document.body.classList.add(AppComponent.sideMenuClass);
            } else {
                this.window.document.body.classList.remove(AppComponent.sideMenuClass);
            }
        });
    }
}

And here's the magic, I found the karma-viewport project to add the ability for karma tests to modify the testing window (for things like responsive screen sizes, etc.) https://github.com/squidfunk/karma-viewport

Adding this allows me to use the karma testig iframe as the window here in app.component.spec.ts and the tests pass!

describe('Component: App', () => {
    let fixture: ComponentFixture<AppComponent>;
    let app: AppComponent;

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            imports: [ ... ],
            declarations: [
                AppComponent
            ],
            providers: [
                {provide: 'Window', useValue: viewport.context.contentWindow},
            ]
        }).compileComponents();
    }));

    beforeEach(() => {
        fixture = TestBed.createComponent(AppComponent);
        app = fixture.debugElement.componentInstance;
    });

    it(`should toggle a class on the <body> tag when opening/closing the side-menu via the side-menu service`, () => {
        if (viewport.context && viewport.context.contentDocument) {
            const testBody = viewport.context.contentDocument.body;
            fixture.detectChanges(); //do this initially to trigger `ngOnInit()`

            app.sideMenuService.open();
            fixture.detectChanges();
            expect(testBody.className).toContain(AppComponent.sideMenuClass);

            app.sideMenuService.close();
            fixture.detectChanges();
            expect(testBody.className).not.toContain(AppComponent.sideMenuClass);
        } else {
            fail('Could not locate the karma testing iframe document!');
        }
    });

});
Chris Barr
  • 29,851
  • 23
  • 95
  • 135