测试依赖于服务的组件

学习目标

  • 在需要与服务交互的组件之间选择单元测试还是集成测试。

  • 创建伪造服务以在隔离环境中测试组件。

  • 验证组件与服务正确地交互。

  • 了解不同的伪造服务依赖的方法。

我们已经成功地测试了独立的 CounterComponent 和容器 HomeComponent。接下来我们要测试的组件是 ServiceCounterComponent

顾名思义,该组件依赖于 CounterService。计数状态不存储在组件本身,而是存储在中央服务中。

共享中央状态

Angular 的依赖注入仅维护一个应用范围的服务实例,也就是所谓的单例。因此,多个 ServiceCounterComponent 实例共享相同的计数状态。如果用户在一个实例中增加计数,其他实例中的计数也会发生变化。

同样,对于该组件,有两种基本的测试方式:

  • 使用伪造对象替换 CounterService 依赖的单元测试。

  • 包含真实 CounterService 的集成测试。

本指南将演示这两种方式。对于您的组件,您需要根据实际情况进行选择。以下问题可能会给您提供一些指导:哪种测试类型更有益、更有意义?哪种测试在设置和长期维护方面更容易?

服务依赖的集成测试

对于 ServiceCounterComponent,集成测试比单元测试更容易设置。简单的 CounterService 几乎没有逻辑,也没有其他依赖项。它没有我们需要在测试环境中抑制的副作用,例如 HTTP 请求。它只是改变了它的内部状态。

集成测试与我们已经编写的 CounterComponent 测试几乎相同。

describe('ServiceCounterComponent: integration test', () => {
  let component: ServiceCounterComponent;
  let fixture: ComponentFixture<ServiceCounterComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ServiceCounterComponent],
      providers: [CounterService],
    }).compileComponents();

    fixture = TestBed.createComponent(ServiceCounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('shows the start count', () => {
    expectText(fixture, 'count', '0');
  });

  it('increments the count', () => {
    click(fixture, 'increment-button');
    fixture.detectChanges();
    expectText(fixture, 'count', '1');
  });

  it('decrements the count', () => {
    click(fixture, 'decrement-button');
    fixture.detectChanges();
    expectText(fixture, 'count', '-1');
  });

  it('resets the count', () => {
    const newCount = 456;
    setFieldValue(fixture, 'reset-input', String(newCount));
    click(fixture, 'reset-button');
    fixture.detectChanges();
    expectText(fixture, 'count', String(newCount));
  });
});

CounterComponent 的测试相比,这里没有什么新的东西,除了一行代码:

providers: [CounterService],
提供服务

这行代码将 CounterService 添加到测试模块中。Angular 创建一个服务的实例并将其注入到要测试的组件中。这个测试比较简短,因为 ServiceCounterComponent 没有要测试的输入或输出。

由于 CounterService 总是以 count 0 开始,测试需要默认使用这个初始计数。组件和服务都不允许使用不同的初始计数。

与服务的交互

集成测试不会检查组件的内部工作方式。它只提供了服务,但不检查组件和服务之间的交互。组件可能根本不与服务进行通信。

如果我们想要一个集成测试来验证组件是否将计数存储在服务中,我们需要编写一个包含两个 ServiceCounterComponent 的测试:使用一个组件增加计数时,另一个组件中显示的计数应相应更改。

虚拟服务依赖

让我们继续进行 ServiceCounterComponent单元测试。为了解决这个问题,我们需要学习如何虚拟化服务依赖。

有几种实际的方法,各有利弊。我们已经讨论了 伪造依赖 的两个主要要求:

  1. 虚拟与原始服务的等价性:虚拟必须具有派生自原始服务的类型。

  2. 有效的虚拟化:原始服务保持不变。

推荐的虚拟化方法

本指南将介绍一种实现这些要求的解决方案。请注意,其他解决方案也可能满足这些要求。

我们需要虚拟化的依赖项 CounterService 是一个简单的类,带有 @Injectable() 注解。这是 CounterService 的外部形式:

class CounterService {
  public getCount(): Observable<number> { /* … */ }
  public increment(): void { /* … */ }
  public decrement(): void { /* … */ }
  public reset(newCount: number): void { /* … */ }
  private notify(): void { /* … */ }
}

我们需要构建一个满足上述需求的虚拟对象。

虚拟实例

创建一个简单的虚拟对象的最简单方法是使用对象字面量 {…} 和方法:

const currentCount = 123;
const fakeCounterService = {
  getCount() {
    return of(currentCount);
  },
  increment() {},
  decrement() {},
  reset() {},
};

getCount 方法从名为 currentCount 的常量中返回一个固定值。我们稍后将使用该常量来检查组件是否正确使用了该值。

这个虚拟对象还远远不完美,但已经可以替代 CounterService 实例。它像原始对象一样行走和交谈。方法为空或返回固定数据。

类型等价性

上面的虚拟实现恰好具有与原始对象相同的形状。正如我们讨论的那样,让虚拟对象始终与原始对象保持同步非常重要。

目前,TypeScript还没有强制执行等价性。我们希望TypeScript能够检查虚拟对象是否正确地复制了原始对象。第一次尝试可以添加一个类型声明:

const fakeCounterService: CounterService = {
  getCount() {
    return of(currentCount);
  },
  increment() {},
  decrement() {},
  reset() {},
};

不幸的是,这样不起作用。TypeScript会报错说缺少私有方法和属性:

Type '{ getCount(): Observable<number>; increment(): void; decrement(): void; reset(): void; }' is missing the following properties from type 'CounterService': count, subject, notify

这是正确的。但我们不能在对象字面量中添加私有成员,也不应该这样做。

挑选公共成员

幸运的是,我们可以使用 TypeScript 的一个技巧来解决这个问题。使用 Pickkeyof,我们创建一个派生类型,该类型只包含公共成员:

const fakeCounterService:
  Pick<CounterService, keyof CounterService> = {
  getCount() {
    return of(currentCount);
  },
  increment() {},
  decrement() {},
  reset() {},
};
保持 fake 与原始内容同步

CounterService 改变其公共 API 时,依赖的 ServiceCounterComponent 需要进行调整。同样地,fakeCounterService 需要反映这些更改。类型声明会提醒您更新 fake。它防止 fake 与原始内容不同步。

只 fake 必要的部分

ServiceCounterComponent 调用了 CounterService 的所有公共方法,因此我们已将它们添加到了 fake 中。

如果被测试的代码并未使用整个 API,那么 fake 也无需复制整个 API。只声明被测试代码实际使用的方法和属性即可。

例如,如果被测试的代码只调用了 getCount,只提供这个方法即可。确保添加一个类型声明,从原始类型中挑选出该方法:

const fakeCounterService: Pick<CounterService, 'getCount'> = {
  getCount() {
    return of(currentCount);
  },
};

挑选和其他 映射类型有助于以一种 TypeScript 可以检查等效性的方式将伪造类型与原始类型绑定。

对方法进行监视

带有方法的普通对象是创建伪实例的一种简单方式。规范需要验证方法是否以正确的参数调用。

Jasmine 间谍 非常适合这项任务。首先的方法是用独立的间谍填充伪实例:

const fakeCounterService:
  Pick<CounterService, keyof CounterService> = {
  getCount:
    jasmine.createSpy('getCount').and.returnValue(of(currentCount)),
  increment: jasmine.createSpy('increment'),
  decrement: jasmine.createSpy('decrement'),
  reset: jasmine.createSpy('reset'),
};
createSpyObj

这样做是可以的,但过于冗长。Jasmine 提供了一个便捷的辅助函数 createSpyObj,用于创建带有多个间谍方法的对象。它需要一个描述性名称和一个包含方法名称和返回值的对象:

const fakeCounterService = jasmine.createSpyObj<CounterService>(
  'CounterService',
  {
    getCount: of(currentCount),
    increment: undefined,
    decrement: undefined,
    reset: undefined,
  }
);

上面的代码创建了一个对象,其中包含四个方法,它们都是间谍。它们返回给定的值:getCount 返回一个 Observable<number>。其他方法返回 undefined

类型等价性

createSpyObj 接受一个 TypeScript 类型变量,用于声明所创建对象的类型。我们在尖括号中传递 CounterService,这样 TypeScript 就会检查伪实例是否与原始对象匹配。

让我们让我们的伪实例开始工作。在 安排(Arrange) 阶段,我们创建了伪实例并将其注入到测试模块中。

describe('ServiceCounterComponent: unit test', () => {
  const currentCount = 123;

  let component: ServiceCounterComponent;
  let fixture: ComponentFixture<ServiceCounterComponent>;
  // Declare shared variable
  let fakeCounterService: CounterService;

  beforeEach(async () => {
    // Create fake
    fakeCounterService = jasmine.createSpyObj<CounterService>(
      'CounterService',
      {
        getCount: of(currentCount),
        increment: undefined,
        decrement: undefined,
        reset: undefined,
      }
    );

    await TestBed.configureTestingModule({
      declarations: [ServiceCounterComponent],
      // Use fake instead of original
      providers: [
        { provide: CounterService, useValue: fakeCounterService }
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(ServiceCounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  /* … */
});

测试模块的 提供者(providers) 部分出现了一种新的模式:

providers: [
  { provide: CounterService, useValue: fakeCounterService }
]
使用伪实例替代

这是关键时刻,我们告诉 Angular:对于 CounterService 依赖项,请使用 fakeCounterService 这个值。这就是我们如何用伪实例替换原始实例。

通常情况下,当组件、服务等请求 CounterService 时,Angular 会实例化并注入一个 CounterService 实例。通过使用 { provide: …, useValue: … },我们跳过了实例化过程,直接提供要注入的值。

现在,安排(Arrange) 阶段已经完成,让我们来编写实际的规范。

行动(Act) 阶段与其他计数器组件的测试相同:我们点击按钮并填写表单字段。

验证间谍

断言(Assert) 阶段,我们需要验证 Service 方法是否已被调用。由于使用了 jasmine.createSpyObjfakeCounterService 的所有方法都是间谍。我们使用 expect 结合适当的匹配器,如 toHaveBeenCalledtoHaveBeenCalledWith 等。

expect(fakeCounterService.getCount).toHaveBeenCalled();

应用于所有规范,测试套件的样子如下所示:

describe('ServiceCounterComponent: unit test', () => {
  const currentCount = 123;

  let component: ServiceCounterComponent;
  let fixture: ComponentFixture<ServiceCounterComponent>;
  // Declare shared variable
  let fakeCounterService: CounterService;

  beforeEach(async () => {
    // Create fake
    fakeCounterService = jasmine.createSpyObj<CounterService>(
      'CounterService',
      {
        getCount: of(currentCount),
        increment: undefined,
        decrement: undefined,
        reset: undefined,
      }
    );

    await TestBed.configureTestingModule({
      declarations: [ServiceCounterComponent],
      // Use fake instead of original
      providers: [
        { provide: CounterService, useValue: fakeCounterService }
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(ServiceCounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('shows the count', () => {
    expectText(fixture, 'count', String(currentCount));
    expect(fakeCounterService.getCount).toHaveBeenCalled();
  });

  it('increments the count', () => {
    click(fixture, 'increment-button');
    expect(fakeCounterService.increment).toHaveBeenCalled();
  });

  it('decrements the count', () => {
    click(fixture, 'decrement-button');
    expect(fakeCounterService.decrement).toHaveBeenCalled();
  });

  it('resets the count', () => {
    const newCount = 456;
    setFieldValue(fixture, 'reset-input', String(newCount));
    click(fixture, 'reset-button');
    expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
  });
});

具有最小逻辑的伪造服务

上面的规范检查了用户交互是否调用了 Service 的方法。它们没有检查在调用 Service 后,组件是否重新呈现了新的计数。

ServiceCountergetCount 方法返回一个 Observable<number>,并且在计数发生变化时通过 Observable 推送新值。规范 it('shows the count', …) 已经证明组件从 Service 获取了计数并进行了渲染。

此外,我们还将检查在推送新值时组件是否进行了更新。在我们的简单 ServiceCounterComponentCounterService 示例中,这并不是严格必要的。但是在组件与服务之间的更复杂交互中,这一点非常重要。

组件更新

伪造的 getCount 方法返回 of(currentCount),这是一个具有固定值 123 的 Observable。Observable 立即完成并且不会推送其他值。我们需要改变这种行为,以展示组件的更新。

迄今为止,缺乏逻辑的伪造 CounterService 需要获得一些逻辑。getCount 需要返回一个 Observable,在调用 incrementdecrementreset 时会发出新的值。

BehaviorSubject

我们使用 BehaviorSubject,而不是固定的 Observable,就像原始的 CounterService 实现一样。BehaviorSubject 有一个 next 方法用于推送新值。

我们在测试套件的第一个 beforeEach 块中,在作用域中声明了一个名为 fakeCount$ 的变量,并赋值为一个 BehaviorSubject

describe('ServiceCounterComponent: unit test with minimal Service logic', () => {
  /* … */
  let fakeCount$: BehaviorSubject<number>;

  beforeEach(async () => {
    fakeCount$ = new BehaviorSubject(0);
    /* … */
  });

  /* … */
});

然后我们更改 fakeCounterService,使得方法通过 fakeCount$ 推送新的值。

const newCount = 123;
/* … */
fakeCounterService = {
  getCount(): Observable<number> {
    return fakeCount$;
  },
  increment(): void {
    fakeCount$.next(1);
  },
  decrement(): void {
    fakeCount$.next(-1);
  },
  reset(): void {
    fakeCount$.next(Number(newCount));
  },
};

上面的伪实例是一个带有普通方法的对象。我们不再使用 createSpyObj,因为它不允许伪造方法的实现。

对方法进行监视

我们丢失了 Jasmine 间谍,需要将它们重新引入。有几种方法可以将方法包装为间谍。为了简单起见,我们使用 spyOn 在所有方法上安装间谍:

spyOn(fakeCounterService, 'getCount').and.callThrough();
spyOn(fakeCounterService, 'increment').and.callThrough();
spyOn(fakeCounterService, 'decrement').and.callThrough();
spyOn(fakeCounterService, 'reset').and.callThrough();

记得添加 .and.callThrough(),这样底层的伪造方法才会被调用。

现在我们的伪服务向组件发送新的计数。我们可以重新引入对组件输出的检查:

fixture.detectChanges();
expectText(fixture, 'count', '…');

将所有部分组装在一起,完整的 ServiceCounterComponent 单元测试如下所示:

describe('ServiceCounterComponent: unit test with minimal Service logic', () => {
  const newCount = 456;

  let component: ServiceCounterComponent;
  let fixture: ComponentFixture<ServiceCounterComponent>;

  let fakeCount$: BehaviorSubject<number>;
  let fakeCounterService: Pick<CounterService, keyof CounterService>;

  beforeEach(async () => {
    fakeCount$ = new BehaviorSubject(0);

    fakeCounterService = {
      getCount(): Observable<number> {
        return fakeCount$;
      },
      increment(): void {
        fakeCount$.next(1);
      },
      decrement(): void {
        fakeCount$.next(-1);
      },
      reset(): void {
        fakeCount$.next(Number(newCount));
      },
    };
    spyOn(fakeCounterService, 'getCount').and.callThrough();
    spyOn(fakeCounterService, 'increment').and.callThrough();
    spyOn(fakeCounterService, 'decrement').and.callThrough();
    spyOn(fakeCounterService, 'reset').and.callThrough();

    await TestBed.configureTestingModule({
      declarations: [ServiceCounterComponent],
      providers: [
        { provide: CounterService, useValue: fakeCounterService }
      ],
    }).compileComponents();

    fixture = TestBed.createComponent(ServiceCounterComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('shows the start count', () => {
    expectText(fixture, 'count', '0');
    expect(fakeCounterService.getCount).toHaveBeenCalled();
  });

  it('increments the count', () => {
    click(fixture, 'increment-button');
    fakeCount$.next(1);
    fixture.detectChanges();

    expectText(fixture, 'count', '1');
    expect(fakeCounterService.increment).toHaveBeenCalled();
  });

  it('decrements the count', () => {
    click(fixture, 'decrement-button');
    fakeCount$.next(-1);
    fixture.detectChanges();

    expectText(fixture, 'count', '-1');
    expect(fakeCounterService.decrement).toHaveBeenCalled();
  });

  it('resets the count', () => {
    setFieldValue(fixture, 'reset-input', newCount);
    click(fixture, 'reset-button');
    fixture.detectChanges();

    expectText(fixture, 'count', newCount);
    expect(fakeCounterService.reset).toHaveBeenCalledWith(newCount);
  });
});

再次强调,此示例有意冗长。伪实例重新实现了原始逻辑的大部分。这是因为原始的 CounterService 本身逻辑较少。

实际上,服务更加复杂,组件会处理从服务接收到的数据。因此,伪造基本逻辑的工作是值得的。

伪造服务:总结

在测试 Angular 应用程序时,创建伪造服务依赖项并验证其使用是最具挑战性的问题之一。本指南只能对此主题进行简要介绍。

可测试的服务

伪造服务需要付出努力并进行持续的实践。您编写的单元测试越多,您就会获得更多的经验。更重要的是,实践教会您编写简单的服务,这些服务易于伪造:具有明确的 API 和明显目的的服务。

不幸的是,对于伪造服务而言,并没有最佳实践。您会在网上找到很多方法,它们各有优势和劣势。相关的单元测试具有不同程度的准确性和完整性。

争论如何“正确“伪造服务是没有意义的。您需要根据具体情况决定适合服务的伪造方法。

准则

有两个准则可能对您有所帮助:

  1. 测试是否有价值?它是否涵盖了组件和服务之间的重要交互?决定是浅显地测试还是深入地测试交互。

  2. 无论选择哪种方法,请确保满足 基本要求

    • 1. 伪实例与原始实例等价:伪实例必须具有派生自原始实例的类型。

    • 2. 有效的伪造:原始实例保持不变。