Angular2: How to test a component function that so

2019-07-24 15:33发布

问题:

I'm still developing an app based on the Angular2 Heroes tutorial. At this point I have a component where the user makes an edit, clicks on Save and on success the user is spirited to the parent page (route "../"). If there is an error in saving then the routing doesn't occur, and the page displays the error information.

The spiriting occurs in the component's save function:

private gotoParent(): void {
  this.router.navigate(['../'], { relativeTo: this.route });
}

public save(): void {
  this.error = null;
  let that = this;

  this.orgService
      .save(that.org)
      .subscribe(
          (org: Org): void => {
            that.org = org; 
            that.savedOrg = new Org(that.org);
            that.gotoParent();
        },
        error => this.error = error
      );

}

The test I have so far is:

routeStub = { data: Observable.of( { org: org1 } ), snapshot: {} };

TestBed.configureTestingModule({
    imports: [ FormsModule, RouterTestingModule ],
    providers : [
        { provide: DialogService, useClass: MockDialogService },
        { provide: GlobalsService, useClass: MockGlobalsService },
        { provide: OrgService, useClass: MockOrgService },
        { provide: ActivatedRoute, useValue: routeStub }          
    ],
    declarations: [ OrgDetailComponent ],
  })
  .compileComponents();
}));

...

it('responds to the Save click by saving the Org and refilling the component', async(() => {
  fixture.detectChanges();
  fixture.whenStable().then(() => {
    comp = fixture.componentInstance;
    comp.org = new Org(org1);
    comp.org.id = 2;
    comp.org.name = 'Another Org';

    let elButton = fixture.debugElement.query(By.css('#save'));
    elButton.nativeElement.click();

    fixture.detectChanges();
    fixture.whenStable().then(() => {
      expect(comp.error).toBeNull();
      expect(comp.savedOrg.id).toEqual(2);
      expect(comp.savedOrg.name).toEqual('Another Org');
      expect(routeStub).toHaveBeenCalledWith(['../']);
    });
  });    

}));

When the expect(routeStub) is called I get "Error: expected a spy, but got Object ...".

Most tutorials regarding testing routing set up a routing table and test that. I'm not sure if I need a route class (replacing ActivatedRoute?) or not.

Thanks,

Jerome.

UPDATE on 3/25

The answer by snorkpete, and in other threads by peeskillet, aren't solving my issues. I think that this is because I've two different things going on in my code, and I've shared only one here.

My component has an ngOnInit() that relies on a resolver to deliver data to the subscribe() within the ngOnInit(). In my tests this is provided by the (renamed) activatedRouteStub instance:

activatedRouteStub = { data: Observable.of( { org: org1 } ) }

In testing the ngOnInit() I get the provided Org object just fine.

Now I need to also process a Save button, which also causes the browser to display the parent page. The component calls:

this.router.navigate(['../'], {relativeTo: this.route});

If I remove the activatedRouteStub, replacing it with a routerStub, everything breaks.

If I use both activatedRouteStub and routerStub, the call

expect(routerStub.navigate).toHaveBeenCalled()

fails, complaining about expecting a spy and getting an Object.

If I add the navigate: jasmineCreateSpy('navigate') to the activatedRouteStub and do the expect() on activatedRouteStub.navigate() I'm told that that wasn't navigated against.

I'm puzzled.

Jerome.

SOLUTION ON 3/25, 17:00 CDT

Thanks to prior help by peeskillet and immediate help from snorkpete I've an answer to my issues.

I happen to need both an ActivatedRoute and a router. What is more, when I call toHaveBeenCalledWith() I need to provide ALL of what the this.router.navigate() call was provided. A "DUH" obvervation on my part, but not realizing it wasted tons of my time.

To get a complete solution into one place, here is pertinent code for my component and its test spec.

For the component:

public ngOnInit(): void {
  this.error = null;
  this.stateOptions = this.globalsService.getStateOptions();
  let that = this;

  this.route.data
    .subscribe((data: { org: Org }) => {
      that.org = data.org;
      that.savedOrg = new Org(that.org);
    });
}

private gotoParent(): void {
  this.router.navigate(['../'], { relativeTo: this.route });
}

public save(): void {
  this.error = null;
  let that = this;

  this.orgService
      .save(that.org)
      .subscribe(
          (org: Org): void => {
            that.org = org; 
            that.savedOrg = new Org(that.org);
            that.gotoParent();
        },
        error => this.error = error
      );

}

Note that goToParent() uses a route string and a relativeTo: parameter.

In the testing:

@Injectable()
export class MockActivatedRoute {
  constructor() { }

  data: Observable<Org> = null;
}

@Injectable()
export class MockRouter {
  constructor() { }

  navigate: any = () => {};

  snapshot: any = {};
}

describe("...", () => {

  ...

  let router: Router = null;
  let activatedRoute: ActivatedRoute = null;

  beforeEach(async(() => {

    TestBed.configureTestingModule({
      imports: [ FormsModule, RouterTestingModule ],
      providers : [
        { provide: DialogService, useClass: MockDialogService },  // don't worry about these three in this example...
        { provide: GlobalsService, useClass: MockGlobalsService },
        { provide: OrgService, useClass: MockOrgService },
        { provide: Router, useClass: MockRouter },
        { provide: ActivatedRoute, useClass: MockActivatedRoute }          
      ],
      declarations: [ OrgDetailComponent ],
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(OrgDetailComponent);

    dialogService = fixture.debugElement.injector.get(DialogService);
    globalsService = fixture.debugElement.injector.get(GlobalsService);
    orgService = fixture.debugElement.injector.get(OrgService);
    router = fixture.debugElement.injector.get(Router);
    activatedRoute = fixture.debugElement.injector.get(ActivatedRoute);
  });

  it('responds to the Save click by saving the Org and refilling the component', async(() => {
    activatedRoute.data = Observable.of( { org: org1 } ); // The org1 is an instance of Org.
    let spy = spyOn(router, 'navigate');
    fixture.detectChanges();
    fixture.whenStable().then(() => {
      comp = fixture.componentInstance;
      comp.org = new Org(org1);
      comp.org.id = 2;
      comp.org.name = 'Another Org';

      let elButton = fixture.debugElement.query(By.css('#save'));
      elButton.triggerEventHandler('click', null);

      fixture.detectChanges();
      fixture.whenStable().then(() => {
        expect(comp.error).toBeNull();
        expect(comp.savedOrg.id).toEqual(2);
        expect(comp.savedOrg.name).toEqual('Another Org');
        expect(router.navigate).toHaveBeenCalled();
        expect(router.navigate).toHaveBeenCalledWith(['../'], { relativeTo: activatedRoute });
      });
    });    

  }));

});

回答1:

In your example, you're stubbing the wrong thing. The easiest way to approach what you're trying to do is to realise that your component has a dependency on the router service. (remember, it calls router.navigate). This is the 'complicated dependency' that you want to replace with your mock/stub object.

So you should change your list of providers in your test module to provide a stub for Router that returns a dummy object that has a navigate method. Then you can confirm if that navigate method in your stub is called when you expect it to be called.

 providers : [
        { provide: DialogService, useClass: MockDialogService },
        { provide: GlobalsService, useClass: MockGlobalsService },
        { provide: OrgService, useClass: MockOrgService },
        //{ provide: ActivatedRoute, useValue: routeStub }  <-- remove this   
        { provide: Router, useValue: routerStub }       <-- add this  
    ],

As stated previously, your router stub is a dummy object with a single 'navigate' method on it. You'll have to spyOn that method.

let fakeRouter = TestBed.get(Router);  // you must retrieve your router fake through dependency injection
spyOn(fakeRouter, 'navigate');

Then in your test,

 expect(fakeRouter.navigate).toHaveBeenCalledWith(['../']);

Note that the 'router' object that you're spying and testing against can't be the routerStub that you es6 imported into your test file. You have to ensure that you retrieve your fakeRouter through dependency injection.

EDIT

The extra information is helpful - you are going okay with stubbing the ActivatedRoute with your routeStub - as you may have realised, that routeStub is used as a replacement for getting the data from the resolver. So that part works fine. But since you also want to confirm that the router.navigate method is called with what you expect, then you also have to stub that.

So, your test module's provider list should have:

providers : [
  { provide: DialogService, useClass: MockDialogService },
  { provide: GlobalsService, useClass: MockGlobalsService },
  { provide: OrgService, useClass: MockOrgService },
  { provide: ActivatedRoute, useValue: routeStub }, //<-- to simulate the resolver passing data to your component          
  { provide: Router, useValue: routerStub },  //<-- dummy object with navigate method that you spy on to ensure you navigate when you expect to 
],

As mentioned before, the routerStub is a simple object with a single navigate method that you are going to spy on to see if it gets called correctly.

So, in your test,

it('responds to the Save click by saving the Org and refilling the component', async(() => {

  // get an instance of your router from your TestBed.
  // but because of how your providers are configured,
  // when you ask for an instance of Router, TestBed will instead
  // return an instance of your routerStub.
  // You MUST get your routerStub through dependency injection -
  // either using TestBed.get or the inject function or some other means
  let fakeRouter = TestBed.get(Router);


  // This is jasmine at work now.
  // Later on, we want to confirm that the navigate method on
  // our fakeRouter is called, so we tell jasmine to monitor that method
  // Jasmine won't allow that spyOn call to work unless 
  // fakeRouter actually has a navigate method - hence the reason
  // our routerStub needed to implement one
  spyOn(fakeRouter,'navigate');

  fixture.detectChanges();
  fixture.whenStable().then(() => {
    comp = fixture.componentInstance;
    comp.org = new Org(org1);
    comp.org.id = 2;
    comp.org.name = 'Another Org';

    let elButton = fixture.debugElement.query(By.css('#save'));
    elButton.nativeElement.click();

    fixture.detectChanges();
    fixture.whenStable().then(() => {
      expect(comp.error).toBeNull();
      expect(comp.savedOrg.id).toEqual(2);
      expect(comp.savedOrg.name).toEqual('Another Org');

      // we set up our spy on our navigate method above.
      // now, we check that the method in question has actually been called.
      // note that i'm checking the method itself -
      // in spying, jasmine replaces that 'navigate' method 
      // with something else that it can later call assertions with
      // Hence, we check against that property explicitly
      expect(fakeRouter.navigate).toHaveBeenCalledWith(['../']);
    });
  });    

}));