简体   繁体   中英

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

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+?

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();
  });

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).

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM