如何简化Angular单元测试的编写工作

418 阅读5分钟

你在用Angular工作吗? 你是否已经写了几百个单元测试,而且你觉得写测试的时间比写应用程序代码的时间多得多? 让我们来谈谈如何加快Angular单元测试的编写速度,并重新开始做有趣的事情。

用线束简化DOM交互

Angular组件通过DOM与用户交互,你希望你的自动化测试也能这样做。 下面是一个点击保存按钮的测试代码的例子。

请参阅Angular组件库。更多关于组件如何工作的信息,请参见Angular组件库:不仅仅是材料帖子:

// Query the DOM for the save button
const cancelButton = fixture.debugElement.query(By.css('button.my-save-button')); 
// Click the save button
cancelButton.nativeElement.click();  
// Tell the test fixture to detect changes                                               
fixture.detectChanges();

就其本身而言,这段代码并不可怕。假设你有半打单元测试用例需要测试围绕点击保存按钮的场景:

it('scenario 1', () => {
  // set scenario 1 preconditions.
  const cancelButton = fixture.debugElement.query(By.css('button.my-save-button'));
  cancelButton.nativeElement.click();
  fixture.detectChanges();
  // test for side effects
});
it('scenario 2', () => {
  // set scenario 2 preconditions.
  const cancelButton = fixture.debugElement.query(By.css('button.my-save-button'));
  cancelButton.nativeElement.click();
  fixture.detectChanges();
  // test for side effects
});
it('scenario 3', () => {
  // set scenario 3 preconditions.
  const cancelButton = fixture.debugElement.query(By.css('button.my-save-button'));
  cancelButton.nativeElement.click();
  fixture.detectChanges();
  // test for side effects
});
it('scenario 4', () => {
  // set scenario 4 preconditions.
  const cancelButton = fixture.debugElement.query(By.css('button.my-save-button'));
  cancelButton.nativeElement.click();
  fixture.detectChanges();
  // test for side effects
});

如果组件的DOM发生了变化,看看你需要更新多少个保存按钮的CSS选择器。 是的,你可以创建一个clickSaveButton 方法,并在每个测试用例中使用,但你最终可能会在你的端到端测试中重复这个方法。 有一个更好的方法。

Angular CDK组件线束允许你为你的自动化测试创建一个API,以便在与组件的DOM交互时使用。 组件线束将保护你的单元测试和端到端测试免受DOM结构变化的影响。 这是我们为保存按钮创建线束后的第一个测试案例:

it('scenario 1', async () => {
  // set scenario 1 preconditions.
  await harness.clickSaveButton();
  // test for side effects
});

我们重构的测试案例不包含CSS查询,也不调用测试夹具来检测变化。 线束处理了所有这些。 线束也可以用于端到端测试,具有所有相同的好处。 请注意,对组件线束的调用是异步的,所以我的测试案例现在使用async/await。

使用线束从HTML元素中读取文本和属性,以及与表单输入交互。 所有与DOM的交互都应该通过线束完成。

简化单元测试的模拟组件的创建

当一个Angular组件包括子组件时,这些组件的依赖性应该在父组件的单元测试中被模拟。 这将确保单元测试真的是一个单元测试。 从开发人员的角度来看,它可以阻止一个组件的修改在其他数百个看似不相关的测试中产生涟漪。

模拟组件的维护是很麻烦的。 每个模拟组件都必须反映原始组件的输入和输出,否则被测试的父组件将无法与模拟组件交互,从而导致错误。

这里有一个组件的例子:

@Component({
  selector: 'my-component',
  templateUrl: './my.component.html',
  styleUrls: [ './my.component.css' ],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent implements OnInit  {
  @Input()
  name: string;

  @Input()
  item: Item;

  @Output()
  someEvent = new EventEmitter<string>();

  ngOnInit() {
    // Do some init stuff.
  }

  // view model builders, click handlers, etc.
}

这是该示例组件的一个模拟组件:

@Component({
  selector: 'my-component',
  template: ''
})
export class MyMockComponent {
  @Input()
  name: string;

  @Input()
  item: Item;

  @Output()
  someEvent = new EventEmitter<string>();
}

下面是一个使用该模拟组件来初始化一个名为MyParentComponent 的组件的单元测试的例子:

TestBed.configureTestingModule({
  declarations: [ MyParentComponent, MyMockComponent ]
}).compileComponents();

如果我想修改MyComponent 中的输入或输出,那么我也需要记得更新我的模拟组件。

ng-mocks库(网址:https://github.com/ike18t/ng-mocks)将帮助我们更快地编写单元测试,并使这些测试更容易维护。 当编写一个组件单元测试时,使用ng-mocks库中的MockComponent 工厂函数来模拟所有的依赖组件:

TestBed.configureTestingModule({
  declarations: [ MyParentComponent, MockComponent(MyComponent) ]
}).compileComponents();

就这样了。我根本不需要维护一个模拟组件。 随着MyComponent 的API的变化,上面测试配置中的模拟组件声明会自动改变。 另外,测试用例可以查询原始组件而不是模拟组件:

childDebugElement = fixture.debugElement.query(By.directive(MyComponent));

消除单元测试中的模板代码

为Angular组件编写良好的单元测试是很耗时的。 在单元测试环境的设置中,有一些模板代码:

@Component({
  template: `
    <my-component
      [name]="name"
      [item]="item"
      (someEvent)="someEventData = $event"
    ></my-component>
  `
})
class MyTestHostComponent {}

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ MyTestHostComponent, MyComponent ]
  }).compileComponents();

  hostFixture = TestBed.createComponent(MyTestHostComponent);
  hostComponent = fixture.componentInstance;
  component = hostFixture.debugElement.query(By.directive(MyComponent));
  hostFixture.detectChanges();
});

it('should respond to input changes', () => {
  hostComponent.name = 'new name',
  hostFixture.detectChanges();
  // ... look for DOM changes ...
});

ngneat/spectator库的创建是为了让你快速配置你的测试环境,并减少代码:

@Component({ template: '' })
class MyTestHostComponent {
  name: string;
  item: Item;

  someEventData: EventDataType | null = null;
}

const createHost = createHostFactory({
    component: MyComponent,
    host: MyTestHostComponent,
    template: `
    <my-component
      [name]="name"
      [item]="item"
      (someEvent)="someEventData = $event"
    ></my-component>
  `
});

it('should create', async () => {
  const spectator = createHost();
  expect(spectator.component).toBeTruthy();
});

it('should respond to input changes', () => {
  spectator.setHostInput('name', 'new name');
  // ... look for DOM changes ...
});

不仅TestBed的初始化被简化了,spectator ,在更新组件输入时,所有的变化检测调用也被处理了。 注意没有调用detectChanges(). 删除所有模板代码的副作用是代码审查更容易。 在审查中筛选设置代码的疲劳感减少了。

此外,Angular CDK组件线束可以与spectator ,与DOM交互。 注意在下面的例子中,主机组件被用来检测事件的发生:

async function getHarness(
  spectator: SpectatorHost<MyComponent, MyTestHostComponent>) {
    const harnessLoader = TestbedHarnessEnvironment.loader(spectator.hostFixture);
    return await harnessLoader.getHarness(MyComponentHarness);
}

it('should emit an event when something is clicked', async () => {
  // Create the spectator and pass data to the host component.
  const spectator = createHost(undefined, {
      hostProps: {
        name: 'First Last',
        item: { ... some item object ... }
      }
    });

  const myComponentHarness = await getHarness(spectator);

  // Interact with the harness…
  await myComponentHarness.clickSomething();

  // Look for some event data.
  expect(spectator.hostComponent.someEventData).toEqual({ ... some data ... ));
});

总结

使用线束、ng-mocks和spectator可以减少编写单元测试的时间,使你可以专注于创建高质量的测试。如果你还在原地踏步,并且你想尽早获得这些效率,请联系我们。我们经验丰富的工程师将帮助你制定一个计划和方法,使你和你的团队今天就能获得更好的测试实践!