测试服务

学习目标

  • 编写具有内部状态的服务的测试

  • 测试服务返回的可观察对象

  • 验证HTTP请求和有效负载处理

  • 涵盖HTTP成功和错误情况

在Angular应用程序中,服务负责获取、存储和处理数据。服务是单例,意味着在运行时只有一个服务实例。它们适用于中央数据存储、HTTP和WebSocket通信以及数据验证。

单例

单个服务实例在组件和其他应用程序部分之间共享。因此,在不是父子关系的组件之间需要通信和交换数据时,使用服务。

可注入

“服务”是指任何作为依赖项注入并提供特定功能的对象的总称。从技术上讲,服务没有什么共同之处。没有关于服务结构或行为的规则。

通常,服务是类,但不一定。虽然模块、组件和指令带有相应的装饰器(@Module@Component@Directive),但服务使用通用的 @Injectable 装饰器。

职责

那么服务是做什么的,我们如何测试它呢?服务是多样化的,但一些模式是普遍的。

  • 服务有公共方法返回值。

    在测试中,我们检查方法是否返回正确的数据。

  • 服务存储数据。它们保持一种内部状态。我们可以获取或设置该状态。

    在测试中,我们检查状态是否正确地更改。由于状态应该保存在私有属性中,因此我们不能直接访问状态。我们通过调用公共方法来测试状态变化。我们不应窥视 黑盒

  • 服务与依赖项交互。这些通常是其他服务。例如,服务可能通过Angular的HttpClient发送HTTP请求。

    在单元测试中,我们用返回预先设定响应的虚拟对象替换依赖项。

测试一个具有内部状态的服务

让我们从测试 CounterService 开始。现在,您应该对服务已经很熟悉了。作为提醒,这里是一段包括私有成员的代码:

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

我们需要确定该服务的功能,需要测试什么以及如何进行测试。

它的功能
  • 该服务保存一个内部状态,即私有 计数(count)主题(subject) 属性。我们不能也不应该在测试中访问这些属性。

  • 为了读取状态,该服务有一个名为getCount的方法。它不返回同步值,而是一个RxJS可观察对象。我们将使用getCount来获取当前计数,并订阅更改。

  • 为了改变状态,该服务提供了 增加(increment)减少(decrement)重置(reset) 的方法。我们将调用它们并检查状态是否相应地改变。

让我们编写测试代码!我们创建一个名为counter.service.spec.ts的文件,并填充测试套件样板代码:

describe('CounterService', () => {
  /* … */
});

我们已经知道该服务的功能以及需要测试什么。因此,我们为所有功能添加规范:

describe('CounterService', () => {
  it('returns the count', () => { /* … */ });
  it('increments the count', () => { /* … */ });
  it('decrements the count', () => { /* … */ });
  it('resets the count', () => { /* … */ });
});
不使用 TestBed 实例化

Arrange阶段,每个规范都需要创建CounterService的一个实例。最简单的方法是:

const counterService = new CounterService();

对于没有依赖关系的简单服务,这样做是可以的。对于具有依赖关系的服务进行测试,稍后我们将使用TestBed

我们在beforeEach块中创建新实例,因为每个规范都需要它:

describe('CounterService', () => {
  let counterService: CounterService;

  beforeEach(() => {
    counterService = new CounterService();
  });

  it('returns the count', () => { /* … */ });
  it('increments the count', () => { /* … */ });
  it('decrements the count', () => { /* … */ });
  it('resets the count', () => { /* … */ });
});

让我们从编写规范 it('returns the count', /* … */) 开始。它测试返回Observable的getCount方法。

更改变量值

为了测试Observable,我们使用了与 测试组件输出 相同的模式:

  1. 我们声明一个变量actualCount,最初为undefined。

  2. 我们订阅Observable。我们将发射的值赋给actualCount变量。

  3. 最后,在订阅函数的外部,我们将实际值与期望值进行比较。

it('returns the count', () => {
  let actualCount: number | undefined;
  counterService.getCount().subscribe((count) => {
    actualCount = count;
  });
  expect(actualCount).toBe(0);
});

这是行得通的,因为Observable由一个BehaviorSubject支持,它存储最新的值并立即将其发送给新的订阅者。

状态更改

下一个规范测试increment方法。我们调用该方法并验证计数状态是否已更改。

如前所述,出于此目的,我们无法访问私有属性。就像上面的规范一样,我们需要使用公共的getCount方法来读取计数。

it('increments the count', () => {
  counterService.increment();

  let actualCount: number | undefined;
  counterService.getCount().subscribe((count) => {
    actualCount = count;
  });
  expect(actualCount).toBe(1);
});
期望更改的值

这里的顺序很重要:首先,我们调用increment,然后我们订阅Observable以读取并验证更改后的值。同样,BehaviorSubject将当前值同步地发送给新的订阅者。

剩下的两个规范几乎相同。我们只是调用了相应的方法。

it('decrements the count', () => {
  counterService.decrement();

  let actualCount: number | undefined;
  counterService.getCount().subscribe((count) => {
    actualCount = count;
  });
  expect(actualCount).toBe(-1);
});

it('resets the count', () => {
  const newCount = 123;
  counterService.reset(newCount);

  let actualCount: number | undefined;
  counterService.getCount().subscribe((count) => {
    actualCount = count;
  });
  expect(actualCount).toBe(newCount);
});
重复的模式

我们很快就会注意到这些规范非常重复且嘈杂。在每个规范的Assert阶段,我们都使用这个模式来检查服务状态:

let actualCount: number | undefined;
counterService.getCount().subscribe((count) => {
  actualCount = count;
});
expect(actualCount).toBe(/* … */);

这是一个很好的助手函数候选项。我们称其为expectCount

function expectCount(count: number): void {
  let actualCount: number | undefined;
  counterService.getCount().subscribe((actualCount2) => {
    actualCount = actualCount2;
  });
  expect(actualCount).toBe(count);
}

这个模式有一个可变的位,即期望的数量,这就是为什么助手函数只有一个参数的原因。

取消订阅

现在我们已经将代码提取到一个中央助手函数中,我们应该添加一个优化。 RxJS Observables的第一条规则是:“任何订阅者都必须取消订阅”。

expectCount中,我们只需要获取当前计数一次。我们不想创建一个长期持续的订阅。我们对未来的更改不感兴趣。

如果我们每个规范只调用一次expectCount,这不是一个很大的问题。如果我们编写一个更复杂的规范,有几个expectCount调用,我们将创建无意义的订阅。这很可能在调试订阅函数时引起混淆。

简而言之,我们想要获取计数,然后取消订阅以减少不必要的订阅。

手动取消订阅

一个可能的解决方案是立即在订阅后取消订阅。 subscribe方法返回一个Subscription,其中包含有用的unsubscribe方法。

function expectCount(count: number): void {
  let actualCount: number | undefined;
  counterService
    .getCount()
    .subscribe((actualCount2) => {
      actualCount = actualCount2;
    })
    .unsubscribe();
  expect(actualCount).toBe(count);
}
RxJS操作符

更符合惯用方式的方法是使用一个RxJS操作符,它在第一个值之后就完成Observable: first

import { first } from 'rxjs/operators';

function expectCount(count: number): void {
  let actualCount: number | undefined;
  counterService
    .getCount()
    .pipe(first())
    .subscribe((actualCount2) => {
      actualCount = actualCount2;
    });
  expect(actualCount).toBe(count);
}

如果您不熟悉这个神秘的RxJS魔法,不要担心。在简单的CounterService测试中,取消订阅并不是必须的。但这是一个良好的实践,可以避免在测试使用Observables的更复杂的服务时出现奇怪的错误。

完整的测试套件现在如下所示:

describe('CounterService', () => {
  let counterService: CounterService;

  function expectCount(count: number): void {
    let actualCount: number | undefined;
    counterService
      .getCount()
      .pipe(first())
      .subscribe((actualCount2) => {
        actualCount = actualCount2;
      });
    expect(actualCount).toBe(count);
  }

  beforeEach(() => {
    counterService = new CounterService();
  });

  it('returns the count', () => {
    expectCount(0);
  });

  it('increments the count', () => {
    counterService.increment();
    expectCount(1);
  });

  it('decrements the count', () => {
    counterService.decrement();
    expectCount(-1);
  });

  it('resets the count', () => {
    const newCount = 123;
    counterService.reset(newCount);
    expectCount(newCount);
  });
});

测试发送HTTP请求的服务

没有依赖关系的服务,如 CounterService,相对容易测试。让我们来看一个具有依赖关系的更复杂的服务。

Flickr搜索 中,https://github.com/9elements/angular-flickr-search/blob/main/src/app/services/flickr.service.ts[FlickrService]负责通过Flickr API搜索照片。它向www.flickr.com发送一个HTTP GET请求。服务器以JSON格式响应。以下是完整的代码:

@Injectable()
export class FlickrService {
  constructor(private http: HttpClient) {}

  public searchPublicPhotos(searchTerm: string): Observable<Photo[]> {
    return this.http
      .get<FlickrAPIResponse>(
        'https://www.flickr.com/services/rest/',
        {
          params: {
            tags: searchTerm,
            method: 'flickr.photos.search',
            format: 'json',
            nojsoncallback: '1',
            tag_mode: 'all',
            media: 'photos',
            per_page: '15',
            extras: 'tags,date_taken,owner_name,url_q,url_m',
            api_key: 'XYZ',
          },
        }
      )
      .pipe(map((response) => response.photos.photo));
  }
}

该服务标有@Injectable(),因此它参与Angular的依赖注入。它依赖于Angular标准的HTTP库,即 @angular/common/http 包中的HttpClient。大多数Angular应用程序使用HttpClient与HTTP API通信。

有两种测试FlickrService的方法:集成测试或单元测试。

针对生产环境的请求

集成测试提供真正的HttpClient。这会导致在运行测试时向Flickr API发送HTTP请求。这使整个测试不可靠。

网络或Web服务可能会很慢或不可用。此外,Flickr API端点针对每个请求返回不同的响应。如果输入未知,则很难期望某个FlickrService的行为。

在测试环境中,针对第三方生产API的请求意义不大。如果要为进行HTTP请求的服务编写集成测试,请使用返回固定数据的专用测试API。此API可以在同一台计算机或本地网络上运行。

拦截请求

FlickrService的情况下,我们最好编写单元测试。Angular具有用于测试依赖于HttpClient的代码的强大辅助程序: HttpClientTestingModule

对于具有依赖关系的服务进行测试,使用new实例化服务是很繁琐的。相反,我们使用TestBed来设置一个测试模块。

我们导入HttpClientTestingModule来代替HttpClient

TestBed.configureTestingModule({
  imports: [HttpClientTestingModule],
  providers: [FlickrService],
});

HttpClientTestingModule提供了一个假的HttpClient实现。它实际上不会发送HTTP请求,而只是拦截并在内部记录它们。

在测试中,我们检查HTTP请求的记录。我们使用虚假数据手动响应未决请求。

查找、响应、验证

我们的测试将执行以下步骤:

  1. 调用发送HTTP请求的被测方法

  2. 查找未决请求

  3. 使用虚假数据响应这些请求

  4. 检查方法调用的结果

  5. 验证所有请求都已得到回答

调用被测方法

在第一步中,我们调用被测方法searchPublicPhotos。搜索词仅为固定字符串。

const searchTerm = 'dragonfly';

describe('FlickrService', () => {
  let flickrService: FlickrService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [FlickrService],
    });
    flickrService = TestBed.inject(FlickrService);
  });

  it('searches for public photos', () => {
    flickrService.searchPublicPhotos(searchTerm).subscribe(
      (actualPhotos) => {
        /* … */
      }
    );
    /* … */
  });
});

我们订阅了由searchPublicPhotos返回的Observable,从而发送了(虚假的)HTTP请求。我们稍后会在第四步中调查响应actualPhotos

查找挂起的请求

在第二步中,我们使用 HttpTestingController查找挂起的请求。这个类是 HttpClientTestingModule的一部分。我们通过调用 TestBed.inject(HttpTestingController) 来获取实例。

expectOne

该控制器具有按不同条件查找请求的方法。最简单的是expectOne。它查找与给定条件匹配的请求,并期望恰好有一个匹配项。

在我们的情况下,我们搜索Flickr API的给定URL的请求。

const searchTerm = 'dragonfly';
const expectedUrl = `https://www.flickr.com/services/rest/?tags=${searchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=XYZ`;

describe('FlickrService', () => {
  let flickrService: FlickrService;
  let controller: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [FlickrService],
    });
    flickrService = TestBed.inject(FlickrService);
    controller = TestBed.inject(HttpTestingController);
  });

  it('searches for public photos', () => {
    flickrService.searchPublicPhotos(searchTerm).subscribe(
      (actualPhotos) => {
        /* … */
      }
    );

    const request = controller.expectOne(expectedUrl);
    /* … */
  });
});

expectOne返回找到的请求,它是TestRequest的实例。如果没有匹配URL的挂起请求,expectOne会抛出异常,导致测试失败。

使用虚假数据响应

现在我们手头有挂起的请求,我们使用一个对象来响应它,这个对象模仿了原始的API响应。Flickr API返回一个复杂的对象,其中深度包含一组照片对象。在FlickrService测试中,我们只关心有效载荷,即照片数组。

Flickr搜索存储库包含用于整个测试的 虚假照片对象。对于FlickrService测试,我们导入具有两个虚假照片对象的照片(photos)数组。

我们使用请求的flush方法来响应虚假数据。这模拟了一个成功的“200 OK”服务器响应。

request.flush({ photos: { photo: photos } });

检查方法调用的结果

该规范已经证明searchPublicPhotos向预期的URL发出了请求。它仍然需要证明该方法传递了所需的API响应部分。特别是,它需要证明Observable发出了照片(photos)数组。

我们已经订阅了Observable:

flickrService.searchPublicPhotos(searchTerm).subscribe(
  (actualPhotos) => {
    /* … */
  }
);

我们期望Observable发出的照片数组与API响应中的数组相等:

flickrService.searchPublicPhotos(searchTerm).subscribe(
  (actualPhotos) => {
    expect(actualPhotos).toEqual(photos);
  }
);

这会导致一个已知的 测试输出 问题:如果正在测试的代码出现问题,那么Observable将不会发出任何内容。因此,expect中的 下一个(next) 回调函数将不会被调用。尽管出现了缺陷,Jasmine仍然认为一切正常。

期望值的变化

有几种方法可以解决这个问题。我们选择了一个最初为undefined的变量,并分配了一个值。

let actualPhotos: Photo[] | undefined;
flickrService.searchPublicPhotos(searchTerm).subscribe(
  (otherPhotos) => {
    actualPhotos = otherPhotos;
  }
);

const request = controller.expectOne(expectedUrl);
// Answer the request so the Observable emits a value.
request.flush({ photos: { photo: photos } });

// Now verify emitted valued.
expect(actualPhotos).toEqual(photos);

expect调用位于next回调函数之外,以确保它被确实调用。如果Observable没有发出任何值或错误的值,那么该规范(spec)将会失败。

验证所有请求都已得到回应

在最后一步,我们确保没有任何未处理的请求。我们期望被测试的方法向特定的URL发起一个请求。我们使用expectOne找到请求并使用flush回答它。

最后,我们调用:

controller.verify();

如果有任何未处理的请求,这会导致测试失败。

verify保证被测试的代码不会发出多余的请求。但它也保证您的规范(spec)检查所有请求,例如通过检查它们的URL。

将所有部分整合起来,完整的测试套件:

const searchTerm = 'dragonfly';
const expectedUrl = `https://www.flickr.com/services/rest/?tags=${searchTerm}&method=flickr.photos.search&format=json&nojsoncallback=1&tag_mode=all&media=photos&per_page=15&extras=tags,date_taken,owner_name,url_q,url_m&api_key=XYZ`;

describe('FlickrService', () => {
  let flickrService: FlickrService;
  let controller: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [FlickrService],
    });
    flickrService = TestBed.inject(FlickrService);
    controller = TestBed.inject(HttpTestingController);
  });

  it('searches for public photos', () => {
    let actualPhotos: Photo[] | undefined;
    flickrService.searchPublicPhotos(searchTerm).subscribe(
      (otherPhotos) => {
        actualPhotos = otherPhotos;
      }
    );

    const request = controller.expectOne(expectedUrl);
    request.flush({ photos: { photo: photos } });
    controller.verify();

    expect(actualPhotos).toEqual(photos);
  });
});

测试错误情况

我们是否已经完成了对searchPublicPhotos的测试?我们已经在服务器返回200 OK的成功情况下进行了测试。但我们尚未测试错误情况!

不良路径

searchPublicPhotos通过从HttpClient传递错误。如果this.http.get返回的Observable失败,则由searchPublicPhotos返回的Observable也会因相同的错误而失败。

无论服务中是否有自定义错误处理,都应该测试不良路径

让我们模拟“500内部服务器错误”。我们不会使用flush响应请求,而是通过调用error来让其失败。

const status = 500;
const statusText = 'Internal Server Error';
const errorEvent = new ErrorEvent('API error');
/* … */
const request = controller.expectOne(expectedUrl);
request.error(
  errorEvent,
  { status, statusText }
);

TestRequesterror方法需要一个ErrorEvent和一个可选的options对象。

ErrorEvent是一种特殊的 错误(Error) 类型。为了测试目的,我们使用 new ErrorEvent('…') 创建一个实例。构造函数参数是描述错误情况的字符串消息。

第二个参数,options对象,允许我们设置HTTP 状态(status)(如500),状态文本(statusText)(如 'Internal Server Error')和响应头。在上面的示例中,我们设置了 状态(status)状态文本(statusText)

期望Observable出错

现在我们检查返回的Observable是否正确地行为。它不应该发出下一个值,也不应该完成。它必须失败并出现错误。

我们通过订阅nexterrorcomplete事件来实现这一点:

flickrService.searchPublicPhotos(searchTerm).subscribe(
  () => {
    /* next handler must not be called! */
  },
  (error) => {
    /*
    error handler must be called!
    Also, we need to inspect the error.
    */
  },
  () => {
    /* complete handler must not be called! */
  },
);
fail

当调用nextcomplete处理程序时,规范必须立即失败。为此,有一个方便的全局Jasmine函数:fail

为了检查错误,我们使用与上面相同的模式,在外部作用域中将错误保存在变量中。

let actualError: HttpErrorResponse | undefined;

flickrService.searchPublicPhotos(searchTerm).subscribe(
  () => {
    fail('next handler must not be called');
  },
  (error) => {
    actualError = error;
  },
  () => {
    fail('complete handler must not be called');
  },
);

在回答请求时发生服务器错误后,我们会检查错误是否被传递。error 处理程序接收到一个包含ErrorEvent和状态信息的HttpErrorResponse对象。

if (!actualError) {
  throw new Error('Error needs to be defined');
}
expect(actualError.error).toBe(errorEvent);
expect(actualError.status).toBe(status);
expect(actualError.statusText).toBe(statusText);
类型守卫

由于actualError被定义为 HttpErrorResponse | undefined,我们需要在访问属性之前先排除undefined的情况。

使用 expect(actualError).toBeDefined() 可以实现这一点。但是TypeScript编译器不知道这将排除undefined的情况。因此,我们需要手动抛出异常。

以下是完整的错误情况规范:

it('passes through search errors', () => {
  const status = 500;
  const statusText = 'Server error';
  const errorEvent = new ErrorEvent('API error');

  let actualError: HttpErrorResponse | undefined;

  flickrService.searchPublicPhotos(searchTerm).subscribe(
    () => {
      fail('next handler must not be called');
    },
    (error) => {
      actualError = error;
    },
    () => {
      fail('complete handler must not be called');
    },
  );

  controller.expectOne(expectedUrl).error(
    errorEvent,
    { status, statusText }
  );

  if (!actualError) {
    throw new Error('Error needs to be defined');
  }
  expect(actualError.error).toBe(errorEvent);
  expect(actualError.status).toBe(status);
  expect(actualError.statusText).toBe(statusText);
});

这个例子有意地冗长。它向您展示了如何测试所有细节。它会快速失败并提供有用的错误消息。

这种方法适用于具有专门错误处理的服务方法。例如,服务可能区分成功的响应(如“200 OK”)、客户端错误(如“404 Not Found”)和服务器错误(如“500 Server error”)。

查找待处理请求的替代方法

我们使用了controller.expectOne来查找与预期URL匹配的请求。有时需要指定更多的条件,如方法(GETPOST等)、头部或请求体。

expectOne有几种签名。我们使用了最简单的签名,一个被解释为URL的字符串:

controller.expectOne('https://www.example.org')

要搜索具有特定方法和URL的请求,请传递一个包含这些属性的对象:

controller.expectOne({
  method: 'GET',
  url: 'https://www.example.org'
})

如果您需要通过查看请求的详细信息来查找一个请求,可以传递一个函数:

controller.expectOne(
  (requestCandidate) =>
    requestCandidate.method === 'GET' &&
    requestCandidate.url === 'https://www.example.org' &&
    requestCandidate.headers.get('Accept') === 'application/json',
);

这个判断函数会对每个请求进行调用,决定候选请求是否匹配,并返回一个布尔值。

这使您能够以编程方式筛选所有请求并检查所有条件。候选请求是一个具有诸如methodurlheadersbodyparams等属性的 HttpRequest 实例。

有两种可能的方法:要么您使用具有多个条件的expectOne,就像谓词示例中一样。如果某个请求细节不匹配,expectOne会抛出异常并导致测试失败。

要么您使用具有少量条件的expectOne,传递 { method: '…', url: '…' }。要检查请求的详细信息,仍然可以使用Jasmine的断言。

expectOne返回一个TestRequest实例。该对象只有用于响应请求的方法,但没有关于请求的直接信息。使用request属性来访问底层的HttpRequest

// Get the TestRequest.
const request = controller.expectOne({
  method: 'GET',
  url: 'https://www.example.org'
});
// Get the underlying HttpRequest. Yes, this is confusing.
const httpRequest = request.request;
expect(httpRequest.headers.get('Accept')).toBe('application/json');
request.flush({ success: true });

这与上面判断的示例相等,但如果标头不正确,则会给出更具体的错误消息。

match

除了expectOne之外,还有一个match方法可用于找到满足特定条件的多个请求。它返回一个请求数组。如果没有匹配项,数组为空,但规范不会失败。因此,您需要添加Jasmine的断言来检查数组和其中的请求。

假设存在一个名为CommentService的服务,其中有一个名为postTwoComments的方法。被测试的代码发起两个请求到相同的URL,但请求体不同。

@Injectable()
class CommentService() {
  constructor(private http: HttpClient) {}
  public postTwoComments(firstComment: string, secondComment: string) {
    return combineLatest([
      this.http.post('/comments/new', { comment: firstComment }),
      this.http.post('/comments/new', { comment: secondComment }),
    ]);
  }
}

规范可以包含以下内容:

const firstComment = 'First comment!';
const secondComment = 'Second comment!';
commentService
  .postTwoComments(firstComment, secondComment)
  .subscribe();

const requests = controller.match({
  method: 'POST',
  url: '/comments/new',
});
expect(requests.length).toBe(2);
expect(requests[0].request.body).toEqual({ comment: firstComment });
expect(requests[1].request.body).toEqual({ comment: secondComment });
requests[0].flush({ success: true });
requests[1].flush({ success: true });

我们验证请求的数量以及每个请求的请求体。如果这些检查通过,我们回答每个请求。

测试服务:概述

总体而言,测试服务比测试其他Angular应用程序部分更容易。大多数服务都有明确的目的和明确定义的公共API。

如果要测试的服务依赖于另一个服务,单元测试需要对依赖进行虚拟化。这可能是最困难的部分,但与虚拟化组件依赖的服务所需的工作量相同。

预定义的测试模块

Angular提供了一些关键的服务,这些服务通常在您自己的服务中使用。由于Angular旨在具有可测试性,Angular还提供了用于将这些服务替换为虚拟化版本的工具。

我们在测试依赖于HttpClient的服务时使用了HttpClientTestingModule。再举一个例子,还有 RouterTestingModule 用于测试依赖于RouterLocation的服务。