Mock与Spy

单元测试时应该关注当前被测试对象,而往往被测试对象中需要依赖外部服务,为了避免测试代码的复杂度,最好采用mock的方式屏蔽外部依赖的逻辑。

  • 通过写一个替代class来mock外部依赖.
  • 通过继承并覆盖外部依赖的方法来mock.
  • 通过Spy直接使用真实的外部依赖对象.

LoginComponent依赖AuthService服务实现用户登录业务,代码如下

login.component.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import {Component} from '@angular/core';
import {AuthService} from "./auth.service";

@Component({
  selector: 'app-login',
  template: `<a [hidden]="needsLogin()">Login</a>`
})
export class LoginComponent {

  constructor(private auth: AuthService) {
  }

  needsLogin() {
    return !this.auth.isAuthenticated();
  }
}

AuthService服务通过DI注入LoginComponent,当用户未登录时login按钮显示

AuthService代码示例:

auth.service.ts

1
2
3
4
5
export class AuthService {
  isAuthenticated(): boolean {
    return !!localStorage.getItem('token');
  }
}

使用真实的AuthService服务直接测试

测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {LoginComponent} from './login.component';
import {AuthService} from "./auth.service";

describe('Component: Login', () => {

  let component: LoginComponent;
  let service: AuthService;

  beforeEach(() => { 
    service = new AuthService();//每个测试用例均实例化真实的AuthService
    component = new LoginComponent(service);//通过构造器注入到被测试对象中
  });

  afterEach(() => { //测试结束后清理测试数据
    localStorage.removeItem('token');
    service = null;
    component = null;
  });


  it('canLogin returns false when the user is not authenticated', () => {
    expect(component.needsLogin()).toBeTruthy();
  });

  it('canLogin returns false when the user is not authenticated', () => {
    localStorage.setItem('token', '12345'); //人为调整数据以达到测试用例所需条件
    expect(component.needsLogin()).toBeFalsy();
  });
});

由此可以看出,为了测试LoginComponent组件,我们需要了解AuthService中的业务逻辑,违背了单元测试的职责单一原则,给单元测试带来更多的外部依赖因素,增加了测试代码的复杂度。
为了解决这个问题,我们采用如下3种mock方法来屏蔽外部依赖,使得测试代码更为简洁。

一、替代类方式

我们创建一个MockAuthService类,定义相同的业务方法,根据测试用例所需条件自由调整业务方法的返回结果。 在测试代码中可以直接抹去真实AuthService的引用,用MockAuthService代替,完全消除对真实服务的依赖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import {LoginComponent} from './login.component';

class MockAuthService { 
  authenticated = false;//public 属性可以自由修改其值

  isAuthenticated() {
    return this.authenticated;
  }
}

describe('Component: Login', () => {

  let component: LoginComponent;
  let service: MockAuthService;

  beforeEach(() => { 
    service = new MockAuthService();//注入mock类
    component = new LoginComponent(service);
  });

  afterEach(() => {
    service = null;
    component = null;
  });


  it('canLogin returns false when the user is not authenticated', () => {
    service.authenticated = false; 
    expect(component.needsLogin()).toBeTruthy();
  });

  it('canLogin returns false when the user is not authenticated', () => {
    service.authenticated = true; //修改mock对象中的 数据满足测试条件
    expect(component.needsLogin()).toBeFalsy();
  });
});

通过mock类,我们消除了测试代码对外部服务的依赖,变得更清晰、健壮,即使真实的服务代码有变更也不会影响到测试代码的正确执行。

二、继承覆盖方式

写替代类自身在很多情况下也很复杂,并且耗时,有时甚至是毫无必要的。利用TypeScript面向对象的特性,我们的替代类可以直接继承真实的服务,通过覆盖特定的方法实现mock。

1
2
3
4
5
6
7
class MockAuthService extends AuthService {
  authenticated = false;

  isAuthenticated() {
    return this.authenticated;
  }
}

这种形式下,测试代码任然可以访问真实服务的属性及其它方法,仅仅是当前测试代码所需控制的业务方法被覆盖。 测试代码与方法一是一致的。

三、Spy方式直接使用真实服务

Spy是Jasmine框架提供的一个特性,让我们得以控制真实的类、方法及对象让其按我们的意图返回所需的数据。 这种方式我们不需要写过多的mock类,仅仅针对我们所需的部分进行精确控制。

使用Spy的测试代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import {LoginComponent} from './login.component';
import {AuthService} from "./auth.service";

describe('Component: Login', () => {

  let component: LoginComponent;
  let service: AuthService;
  let spy: any;

  beforeEach(() => { 
    service = new AuthService();//注入真实的业务对象
    component = new LoginComponent(service);
  });

  afterEach(() => { 
    service = null;
    component = null;
  });


  it('canLogin returns false when the user is not authenticated', () => {
    spy = spyOn(service, 'isAuthenticated').and.returnValue(false);//在真实服务对象上spy并控制方法的返回值
    expect(component.needsLogin()).toBeTruthy();
    expect(service.isAuthenticated).toHaveBeenCalled(); //验证服务的确是被调用过

  });

  it('canLogin returns false when the user is not authenticated', () => {
    spy = spyOn(service, 'isAuthenticated').and.returnValue(true);//在真实服务对象上spy并控制方法的返回值
    expect(component.needsLogin()).toBeFalsy();
    expect(service.isAuthenticated).toHaveBeenCalled(); //验证服务的确是被调用过
  });
});

不管使用哪种方式,唯一的目的就是保持测试代码简洁,并与外部依赖解耦