在 Angular 10+ 中测试具有 ViewChildren 的组件时,如何使用假/模拟/存根子组件? [英] How can I use a fake/mock/stub child component when testing a component that has ViewChildren in Angular 10+?

查看:12
本文介绍了在 Angular 10+ 中测试具有 ViewChildren 的组件时,如何使用假/模拟/存根子组件?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

在将此标记为

(请注意,来自 Sue 的你好"已显示为灰色,因为我在文本框中输入了sue"作为回应).

您可以在 StackBlitz 中使用此应用.

如果您查看应用程序的代码,您会发现有两个组件:AppComponentHelloComponent.AppComponent 为每个人"呈现一个 HelloComponent.

app.component.html

<ng-container *ngFor="let n of names"><你好名字="{{n}}></你好></ng-容器><小时/><h2>输入你想回复的人的名字:</h2>嗨 

AppComponent 类有一个名为hellos"的ViewChildren 属性.此属性用于 answer 方法,并在相应的 HelloComponent 上调用 answer 方法:

app.component.ts

@Component({选择器:'我的应用',模板网址:'./app.component.html'})导出类 AppComponent {公共名称 = ['Bob', 'Sue', 'Rita'];@ViewChildren(HelloComponent) 公共问候:QueryList;公共答案(名称:字符串):无效{const hello = this.hellos.find(h => h.name.toUpperCase() === name.toUpperCase());如果(你好){你好.answer();}}}

到目前为止,一切都很好 - 一切正常.但现在我想对 AppComponent...

进行单元测试

添加单元测试

因为我正在 unit 测试 AppComponent,我不希望我的测试依赖于 HelloComponent 的实现(以及我绝对不想依赖它可能使用的任何服务等),所以我将通过创建一个存根组件来模拟 HelloComponent:

@Component({选择器:你好",模板:",提供者:[{ 提供:HelloComponent,useClass:HelloStubComponent }]})类 HelloStubComponent {@Input() 公共名称:字符串;公共答案 = jasmine.createSpy("answer");}

有了这些,我的单元测试就可以创建 AppComponent 并验证三个hello".项目已创建:

it("应该有 3 个 hello 组件", () => {//如果我们自己进行查询,那么我们可以看到 ngFor 产生了 3 个项目const hellos = fixture.debugElement.queryAll(By.css("hello"));期望(你好).not.toBeNull();期望(hellos.length).toBe(3);});

...这很好.但是,如果我尝试测试组件的 answer() 方法的实际行为(检查它是否调用了正确 HelloComponent<的 answer() 方法/code>,则失败:

it("应该回答 Bob", () => {const hellos = fixture.debugElement.queryAll(By.css("hello"));const bob = hellos.find(h => h.componentInstance.name === "Bob");//bob.componentInstance 是一个 HelloStubComponent期望(bob.componentInstance.answer).not.toHaveBeenCalled();fixture.componentInstance.answer(Bob");期望(bob.componentInstance.answer).toHaveBeenCalled();});

当这个测试执行时,会发生错误:

<块引用>

TypeError: 无法读取未定义的属性toUpperCase"

这个错误出现在AppComponentanswer()方法中:

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

发生的事情是 lambda 中的 h.nameundefined.为什么?

我可以用另一个单元测试更简洁地说明这个问题:

it("应该可以作为ViewChildren访问3个hello组件", () => {期望(fixture.componentInstance.hellos).toBeDefined();期望(fixture.componentInstance.hellos.length).toBe(3);fixture.componentInstance.hellos.forEach(h => {期望(h).toBeDefined();期望(h.constructor.name).toBe(HelloStubComponent");//...但是没有设置名称属性期望(h.name).toBeDefined();//失败});});

这失败了:

<块引用>

错误:预期未定义.

错误:预期未定义.

错误:预期未定义.

虽然结果是 HelloStubComponent 类型,但未设置 name 属性.

我认为这是因为 ViewChildren 属性期望实例的类型为 HelloComponent 而不是 HelloStubComponent (这是公平的,因为这就是它的声明方式)-不知何故,这把事情搞砸了.

您可以在此替代方案 StackBlitz 中查看实际的单元测试.(它具有相同的组件,但设置为启动 Jasmine 而不是应用程序;在测试"模式和运行"模式之间切换,编辑 angular.json 并更改 ";main":"src/test.ts""main":"src/main.ts" 并重启).

问题

那么:如何让组件中的 QueryList 与我的存根组件一起正常工作?我看到了几个建议:

  1. 如果属性是使用 ViewChild 而不是 ViewChildren 的单个组件,只需在测试中覆盖该属性的值.这是相当丑陋的,无论如何它对 ViewChildren 没有帮助.

  2. 这个问题 有一个涉及 propMetadata 的答案,它有效地改变了 Angular 期望 QueryList 中的项目的类型.接受的答案一直有效,直到 Angular 5,还有另一个适用于 Angular 5 的答案(实际上我能够将它用于 Angular 9).然而,这在 Angular 10 中不再适用 - 大概是因为它所依赖的未记录的内部结构在 v10 中再次发生了变化.

所以,我的问题是:还有其他方法可以实现这一目标吗?或者有没有办法再次破解 Angular 10+ 中的 propMetadata?

解决方案

当你需要一个 mock 子组件时,考虑使用 ng-mocks.它支持所有 Angular 功能,包括 ViewChildren.

然后 HelloComponent 组件将被替换为它的模拟对象,并且不会在测试中产生任何副作用.这里最好的一点是不需要创建 stub 组件.

有一个工作示例:https://codesandbox.io/s/wizardly-shape-8wi3i?file=/src/test.spec.ts&initialpath=%3Fspec%3DAppComponent

beforeEach(() => TestBed.configureTestingModule({声明:[AppComponent, MockComponent(HelloComponent)],}).compileComponents());//更好,因为如果 HelloComponent 已从//AppModule,测试会失败.//beforeEach(() => MockBuilder(AppComponent, AppModule));//这里我们向 HelloComponent.answer 中注入一个 spybeforeEach(() => MockInstance(HelloComponent, 'answer', jasmine.createSpy()));//通常应该在测试中直接调用 MockRender.//它返回一个夹具beforeEach(() => MockRender(AppComponent));it("应该有 3 个 hello 组件", () => {//ngMocks.findAll 是查询的简写形式.const hellos = ngMocks.findAll(HelloComponent);期望(hellos.length).toBe(3);});it("应该能够以 ViewChildren 的身份访问 3 个 hello 组件", () => {//应用组件常量组件 = ngMocks.findInstance(AppComponent);//它的所有属性都已正确定义期望(component.hellos).toBeDefined();期望(component.hellos.length).toBe(3);//ViewChildren 正常工作组件.hellos.forEach(h => {expect(h).toEqual(jasmine.any(HelloComponent));期望(h.name).toBeDefined();//工作});});it("应该回答 Bob", () => {常量组件 = ngMocks.findInstance(AppComponent);const hellos = ngMocks.findAll(HelloComponent);const bob = hellos.find(h => h.componentInstance.name === "Bob");期望(bob.componentInstance.answer).not.toHaveBeenCalled();component.answer(鲍勃");//工作期望(bob.componentInstance.answer).toHaveBeenCalled();});

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

这篇关于在 Angular 10+ 中测试具有 ViewChildren 的组件时,如何使用假/模拟/存根子组件?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆