Angular 拦截器单元测试

135 阅读2分钟

在本文中,我们首先展示一个用于缓存网络 get 请求的拦截器及其对应的缓存服务的代码,然后根据已有的代码逐渐构思我们的测试用例。测试网络通信的拦截器本身是不难的,只要抓住几个要点即可。

image.png

1. 拦截器及其缓存服务

// CacheInterceptor.ts
import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpResponse, HttpInterceptor } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { HttpCacheService } from './http-cache-service';

@Injectable()
export class CacheInterceptor implements HttpInterceptor {
constructor(private cacheService: HttpCacheService) { }

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  // pass along non-cacheable requests and invalidate cache  
  if (req.method !== 'GET') {
    console.log(
      '%cInvalidating cache:',
      'color:#fff;background:#008800;marign-right:12px;padding:2px 6px;display:inline-block;border-radius:4px;',
      `${ req.method } ${ req.url } `,
    );
    this.cacheService.invalidateCache();
    return next.handle(req);
  }

  const cachedResponse: HttpResponse<any> | undefined = this.cacheService.get(req.url);
  // return cached response  
  if (cachedResponse) {
    console.log(
      '%cReturning a cached response:',
      'color:#fff;background:#008800;marign-right:12px;padding:2px 6px;display:inline-block;border-radius:4px;',
      `${ cachedResponse.url } `
    );
    return of(cachedResponse);
  }

  // send request to server and add response to cache  
  return next.handle(req).pipe(
    tap(event => {
      if (event instanceof HttpResponse) {
        console.log(
          '%cAdding item to cache:',
          'color:#fff;background:#008800;marign-right:12px;padding:2px 6px;display:inline-block;border-radius:4px;',
          this.cacheService.put(req.url, event)
        )
      }
    })
  );
}
}

// http-cache-service.ts
import { Injectable } from "@angular/core";
import { HttpResponse } from "@angular/common/http";

@Injectable({
  providedIn: 'root'
})
export class HttpCacheService {
  private requests: any = {};

  constructor() { }

  put(url: string, response: HttpResponse<any>): void {
      this.requests[url] = response;
  }

  get(url: string): HttpResponse<any> | undefined {
      return this.requests[url];
  }

  invalidateUrl(url: string): void {
      this.requests[url] = undefined;
  }

  invalidateCache(): void {
      this.requests = {};
  }
}

上面的这个拦截器的作用就是每次发送网络请求的时候,对每一个请求进行检查。如果是 GET 请求,则去缓存池中查找是否有此 url 对应的数据,如果有则立刻返回,如果没有才发起 GET 请求从服务器端获取数据。

我们都知道,如果一直发送的都是 GET 请求的话,则不会改变数据库中的数据,反过来,一旦我们发出非 GET 请求,那么我们就认为相关数据一定改变,这个时候就需要清空我们的缓存池。

2. 单元测试构建思路

2.1 如何发起网络请求

如果我们要对上面的拦截器和缓存服务进行测试,首先我们需要保证能够成功的发出网络请求,而在测试环境中发送网络请求我们一般使用的是 HttpClientTestingModule, HttpTestingController;

configureTestingModule 中配置之后,我们还需要一个 MockComponent 并定义上面的方法,这些方法使用 HttpClient 的实例 http 发起 GET 或者 POST 请求。

2.2 如何检查缓存服务是否缓存成功

我们观察缓存服务的代码,发现我们可以通过实例方法 get 获取指定 url 的缓存内容。因此,我们只需要通过 TestBed.inject() 拿到缓存服务实例即可获取缓存的内容。

2.3 如何设计我们的测试用例

我们的测试用例最重要的是包含两个方面的内容:

  1. 当我们 get 请求 url 的时候,请求结束之后需要检查下面的内容(1. 成功发出了 get 请求 2. 请求回来的数据是正确的 3. 缓存池中此 url 对应有数据 4. 再次以 url get 请求的时候不会发起新的网络请求)
  2. 在 1 的基础之上,我们 post url2, 请求结束之后,应该检查下面的内容(1. 成功发出了 post 请求 2. 请求回来的数据是正确的 3. 缓存池被清空了 4. 再次以 url get 请求走的是网络而不是缓存)

3. 需要用到的技巧

  1. 如何构建成功发起网络请求的组件
@Component({
  selector: 'app-test-int',
  template: `<span></span>`,
})
class MockComponent {
  constructor(private http: HttpClient) { }
  post(url: string) {
    return this.http.post(url, null);
  }

  get(url: string) {
    return this.http.get(url);
  }
}
  1. 如何配置测试拦截器的编译环境
TestBed.configureTestingModule({
  declarations: [MockComponent],
  providers: [{
    provide: HTTP_INTERCEPTORS,
    useClass: CacheInterceptor,
    multi: true,
  }],
  imports: [HttpClientTestingModule],
})
  1. 如何获取组件示例相关
    fixture = TestBed.createComponent(MockComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  1. 如何获取缓存服务实例
service = TestBed.inject(HttpCacheService);
  1. 如何获取控制网络请求的实例
httpMock = TestBed.inject(HttpTestingController);
  1. 最先执行的测试 -- 确保编译环境的正确性
  it('Should be established', () => {
    expect(component).toBeTruthy();
    expect(service).toBeTruthy();
  })
  1. 发起网络请求并监视其符合预期 -- 这几乎是固定的写法
  it('Should request and cache successfully', () => {
    const url = 'http://www.baidu.com';
    component.get(url).subscribe((data: any) => {
      expect(data.data).toBe(`the result of ${url}`);
    });

    const req = httpMock.expectOne(url); // 不报错说明确实发起了此请求
    expect(req.request.method).toBe('GET');  // 验证请求的类型是否正确
    req.flush({ // mock 服务器的返回值
      success: true,
      data: `the result of ${url}`,
    });
    httpMock.verify();
  })
  1. 如何获取缓存实例中的数据
  expect(service.get(url)).toBeTruthy();
  expect(service.get(url)?.body.data).toBe(`the result of ${url}`)

先保证其存在,再验证其值是否正确。

  1. 如何证明没有发送网络请求而是走的缓存
expect(httpMock.match(url)).toEqual([]);

4. 完整的测试代码

// cache-interceptor.spec.ts
import { HTTP_INTERCEPTORS, HttpClient } from "@angular/common/http";
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { CacheInterceptor } from "./CacheInterceptor";
import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing";
import { HttpCacheService } from "./http-cache-service";

@Component({
  selector: 'app-test-int',
  template: `<span></span>`,
})
class MockComponent {
  constructor(private http: HttpClient) { }
  post(url: string) {
    return this.http.post(url, null);
  }

  get(url: string) {
    return this.http.get(url);
  }
}

describe('Test cache interceptor', () => {
  let fixture: ComponentFixture<MockComponent>;
  let component: MockComponent;
  let httpMock: HttpTestingController;
  let service: HttpCacheService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [MockComponent],
      providers: [{
        provide: HTTP_INTERCEPTORS,
        useClass: CacheInterceptor,
        multi: true,
      }],
      imports: [HttpClientTestingModule],
    })

    fixture = TestBed.createComponent(MockComponent);
    component = fixture.componentInstance;
    httpMock = TestBed.inject(HttpTestingController);
    service = TestBed.inject(HttpCacheService);
    fixture.detectChanges();
  })

  it('Should be established', () => {
    expect(component).toBeTruthy();
    expect(service).toBeTruthy();
  })

  it('Should request and cache successfully', () => {
    const url = 'http://www.baidu.com';
    component.get(url).subscribe((data: any) => {
      expect(data.data).toBe(`the result of ${url}`);
      expect(service.get(url)).toBeTruthy();
      expect(service.get(url)?.body.data).toBe(`the result of ${url}`)

      component.get(url).subscribe((data: any) => {
        expect(data.data).toBe(`the result of ${url}`);
      });
      expect(httpMock.match(url)).toEqual([]);
    });

    const req = httpMock.expectOne(url);
    expect(req.request.method).toBe('GET');
    req.flush({
      success: true,
      data: `the result of ${url}`,
    });
    httpMock.verify();
  })

  it('Should refresh the cache successfully', () => {
    const url = 'http://www.baidu.com';
    component.get(url).subscribe((data: any) => {
      expect(data.data).toBe(`the result of ${url}`);

      component.get(url).subscribe((data: any) => {
        expect(data.data).toBe(`the result of ${url}`);

        const url2 = 'http://www.google.com';
        component.post(url2).subscribe((data: any) => {
          expect(data.data).toBe(`the result of ${url2}`);
          expect(service.get(url)).toBeFalsy();
          expect(service.get(url2)).toBeFalsy();

          component.get(url).subscribe((data: any) => {
            expect(data.data).toBe(`the result of ${url}`);
            expect(service.get(url)).toBeTruthy();
            expect(service.get(url)?.body.data).toBe(`the result of ${url}`)
          })
          const req3 = httpMock.expectOne(url);
          expect(req3.request.method).toBe('GET');
          req3.flush({
            success: true,
            data: `the result of ${url}`,
          });
          httpMock.verify();
        });
        const req2 = httpMock.expectOne(url2);
        expect(req2.request.method).toBe('POST');
        req2.flush({
          success: true,
          data: `the result of ${url2}`,
        });
        httpMock.verify();
      });
      expect(httpMock.match(url)).toEqual([]);
    });

    const req = httpMock.expectOne(url);
    expect(req.request.method).toBe('GET');
    req.flush({
      success: true,
      data: `the result of ${url}`,
    });
    httpMock.verify();
  })
})

结尾

留下一个思考题:

如果我们仔细观察代码,就可以发现,在配置测试环境的时候没有显式在 configureTestingModule 中声明 HttpCacheService, 但是在之后的代码中,我们仍然可以使用 service = TestBed.inject(HttpCacheService); 的到 HttpCacheService 的实例,并且这个实例正是 CacheInterceptor 被注入的那个。这一点我觉得非常的神奇!那么这是不是说明,对于服务我们无需在配置测试的编译环境的时候专门声明呢?