How can I use a fake/mock/stub child component when testing a component that has ViewChildren in Angular 10+?

馋奶兔 提交于 2021-02-10 14:32:23

问题


Before marking this as a duplicate of this question please note that I'm asking specifically about Angular 10+, because the answers to that question no longer work as of Angular 10.


Background

I've created a simple example app that helps illustrates my question. The idea with this app is that several "people" will say "hello", and you can respond to any or all of them by typing their name. It looks like this:

(Note that the 'hello' from Sue has been greyed out because I responded by typing "sue" in the text box).

You can play with this app in a StackBlitz.

If you look at the code for the app, you'll see that there are two components: AppComponent and HelloComponent. The AppComponent renders one HelloComponent for each "person".

app.component.html

<ng-container *ngFor="let n of names">
  <hello name="{{n}}"></hello>
</ng-container>
<hr/>
<h2>Type the name of whoever you want to respond to:</h2>
Hi <input type='text' #text (input)="answer(text.value)" />

The AppComponent class has a ViewChildren property called 'hellos'. This property is used in the answer method, and calls the answer method on the appropriate HelloComponent:

app.component.ts

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html'
})
  export class AppComponent  {
  public names = ['Bob', 'Sue', 'Rita'];

  @ViewChildren(HelloComponent) public hellos: QueryList<HelloComponent>;

  public answer(name: string): void {
    const hello = this.hellos.find(h => h.name.toUpperCase() === name.toUpperCase());
    if (hello) {
      hello.answer();
    }
  }
}

So far, so good - and that all works. But now I want to unit-test the AppComponent...

Adding Unit Tests

Because I'm unit testing the AppComponent, I don't want my test to depend on the implementation of the HelloComponent (and I definitely don't want to depend on any services etc. that it might use), so I'll mock out the HelloComponent by creating a stub component:

@Component({
  selector: "hello",
  template: "",
  providers: [{ provide: HelloComponent, useClass: HelloStubComponent }]
})
class HelloStubComponent {
  @Input() public name: string;
  public answer = jasmine.createSpy("answer");
}

With that in place, my unit tests can create the AppComponent and verify that three "hello" items are created:

it("should have 3 hello components", () => {
  // If we make our own query then we can see that the ngFor has produced 3 items
  const hellos = fixture.debugElement.queryAll(By.css("hello"));
  expect(hellos).not.toBeNull();
  expect(hellos.length).toBe(3);
});

...which is good. But, if I try to test the actual behaviour of the component's answer() method (to check that it calls the answer() method of the correct HelloComponent, then it fails:

it("should answer Bob", () => {
  const hellos = fixture.debugElement.queryAll(By.css("hello"));
  const bob = hellos.find(h => h.componentInstance.name === "Bob");
  // bob.componentInstance is a HelloStubComponent

  expect(bob.componentInstance.answer).not.toHaveBeenCalled();
  fixture.componentInstance.answer("Bob");
  expect(bob.componentInstance.answer).toHaveBeenCalled();
});

When this test executes, an error occurs:

TypeError: Cannot read property 'toUpperCase' of undefined

This error occurs in the answer() method of AppComponent:

public answer(name: string): void {
  const hello = this.hellos.find(h => h.name.toUpperCase() === name.toUpperCase());
  if (hello) {
    hello.answer();
  }
}

What's happening is that h.name in the lambda is undefined. Why?

I can illustrate the problem more succinctly with another unit test:

it("should be able to access the 3 hello components as ViewChildren", () => {
  expect(fixture.componentInstance.hellos).toBeDefined();
  expect(fixture.componentInstance.hellos.length).toBe(3);

  fixture.componentInstance.hellos.forEach(h => {
    expect(h).toBeDefined();
    expect(h.constructor.name).toBe("HelloStubComponent");
    // ...BUT the name property is not set
    expect(h.name).toBeDefined(); // FAILS
  });
});

This fails:

Error: Expected undefined to be defined.

Error: Expected undefined to be defined.

Error: Expected undefined to be defined.

Although the results are of type HelloStubComponent, the name property is not set.

I assume that this is because the ViewChildren property is expecting the instances to be of type HelloComponent and not HelloStubComponent (which is fair, because that's how it's declared) - and somehow this is messing things up.

You can see the unit tests in action in this alternative StackBlitz. (It has the same components but is set up to launch Jasmine instead of the app; to switch between "test" mode and "run" mode, edit angular.json and change "main": "src/test.ts" to "main": "src/main.ts" and restart).

Question

So: how can I get the QueryList within the component to work properly with my stub components? I've seen several suggestions:

  1. Where the property is a single component using ViewChild rather than ViewChildren, simply overwrite the value of the property in the test. This is rather ugly, and in any case it doesn't help with ViewChildren.

  2. This question has an answer involving propMetadata that effectively changes what type Angular expects the items in the QueryList to be. The accepted answer worked up until Angular 5, and there's another answer that worked with Angular 5 (and in fact I was able to use that for Angular 9). However, this no longer works in Angular 10 - presumably because the undocumented internals that it relies on have changed again with v10.

So, my question is: is there another way to achieve this? Or is there a way to once again hack the propMetadata in Angular 10+?


回答1:


When you need a mock child component, consider usage of ng-mocks. It supports all Angular features including ViewChildren.

Then HelloComponent component will be replaced with its mock object and won't cause any side effects in the test. The best thing here is that there is no need in creating stub components.

There is a working example: https://codesandbox.io/s/wizardly-shape-8wi3i?file=/src/test.spec.ts&initialpath=%3Fspec%3DAppComponent

beforeEach(() => TestBed.configureTestingModule({
  declarations: [AppComponent, MockComponent(HelloComponent)],
}).compileComponents());

// better, because if HelloComponent has been removed from
// AppModule, the test will fail.
// beforeEach(() => MockBuilder(AppComponent, AppModule));

// Here we inject a spy into HelloComponent.answer 
beforeEach(() => MockInstance(HelloComponent, 'answer', jasmine.createSpy()));

// Usually MockRender should be called right in the test.
// It returns a fixture
beforeEach(() => MockRender(AppComponent));

it("should have 3 hello components", () => {
  // ngMocks.findAll is a short form for queries.
  const hellos = ngMocks.findAll(HelloComponent);
  expect(hellos.length).toBe(3);
});

it("should be able to access the 3 hello components as ViewChildren", () => {
  // the AppComponent
  const component = ngMocks.findInstance(AppComponent);

  // All its properties have been defined correctly
  expect(component.hellos).toBeDefined();
  expect(component.hellos.length).toBe(3);

  // ViewChildren works properly
  component.hellos.forEach(h => {
    expect(h).toEqual(jasmine.any(HelloComponent));
    expect(h.name).toBeDefined(); // WORKS
  });
});

it("should answer Bob", () => {
  const component = ngMocks.findInstance(AppComponent);
  const hellos = ngMocks.findAll(HelloComponent);
  const bob = hellos.find(h => h.componentInstance.name === "Bob");
  
  expect(bob.componentInstance.answer).not.toHaveBeenCalled();
  component.answer("Bob"); // WORKS
  expect(bob.componentInstance.answer).toHaveBeenCalled();
  });



回答2:


I was able to get something to "work", but I don't like it.

Since the QueryList class has a reset() method that allows us to change the results, I can do this at the start of my test to change the results to point at the stub components that were created:

const hellos = fixture.debugElement.queryAll(By.css('hello'));
const components = hellos.map(h => h.componentInstance);
fixture.componentInstance.hellos.reset(components);

This "fixes" the tests, but I'm not sure how brittle it is. Presumably anything that subsequently does detectChanges will re-calculate the results of the QueryList and we'll be back to square one.

Here's a StackBlitz where I've put this code in the beforeEach method so that it applies to all the tests (which now pass).



来源:https://stackoverflow.com/questions/65956284/how-can-i-use-a-fake-mock-stub-child-component-when-testing-a-component-that-has

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!