Angular 深入浅出三十天:表单与测试 Day25 - 测试进阶技巧 - DI 抽换

Day25

好一阵子没写单元测试与整合测试了,大家是否觉得有些生疏了呢?

之前的测试都写得很简单,正好昨天好好地写了搜寻输入框还有呼叫 API ,可以藉由撰写这个功能的测试来分享一些小技巧给大家。

小提醒:昨天的程序码大家可以从 Github - Branch: day24 上 Clone 或者是 Fork 下来。

实作开始

这次要撰写测试的档案比较多,有三个 Pipe 、 一个 Service 与一个 Component 的测试需要撰写。

不过虽然档案比较多,但要撰写的测试其实不会比较难,相反地,由於我们昨天在开发的时候有把逻辑切到各个 PipeService ,因此凡而在撰写测试上会显得更加地好写。

测试单元 - BooleanInZhTwPipe

首先,我们来看看最简单的 BooleanInZhTwPipe ,其程序码如下:

export class BooleanInZhTwPipe implements PipeTransform {

  transform(value: boolean, ...args: unknown[]): string {
    return value ? '是' : '否';
  }

}

BooleanInZhTwPipe 只有一个函式 transform ,因此我们只要验证:

  1. 当传入的 valuetrue 时,则回传
  2. 当传入的 valuefalse 时,则回传

够简单了吧?

测试程序码如下:

describe('BooleanInZhTwPipe', () => {
  let pipe: BooleanInZhTwPipe;
  beforeEach(() => {
    pipe = new BooleanInZhTwPipe();
  });

  it('create an instance', () => {
    expect(pipe).toBeTruthy();
  });

  describe('transform', () => {
    describe('when the first parameter is `true`', () => {
      it('should return "是"', () => {
        // Arrange
        const firstParameter = true;
        const expectedResult = '是';
        // Acc
        const actualResult = pipe.transform(firstParameter);
        // Assert
        expect(actualResult).toBe(expectedResult);
      });
    });

    describe('when the first parameter is `false`', () => {
      it('should return "否"', () => {
        // Arrange
        const firstParameter = false;
        const expectedResult = '否';
        // Acc
        const actualResult = pipe.transform(firstParameter);
        // Assert
        expect(actualResult).toBe(expectedResult);
      });
    });
  });
});

测试结果:

Testing Result

测试单元 - GoogleMapLinkPipe

GoogleMapLinkPipe 的部份也很简单,其程序码如下:

export class GoogleMapLinkPipe implements PipeTransform {

  transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
    return `https://www.google.com/maps?q=${PositionLat},${PositionLon}&z=7`;
  }

}

而其验证项目只需要验证将传入的第一个参数的 PositionLatPositionLong 是否有与 URL 相结合即可。

其测试程序码如下:

describe('GoogleMapLinkPipe', () => {
  let pipe: GoogleMapLinkPipe;
  beforeEach(() => {
    pipe = new GoogleMapLinkPipe();
  });

  it('create an instance', () => {
    expect(pipe).toBeTruthy();
  });

  describe('transform', () => {
    describe('when the first parameter is `true`', () => {
      it('should return "https://www.google.com/maps?q=2.34567,12.34567&z=7"', () => {
        // Arrange
        const firstParameter: StationPosition = {
          PositionLon: 12.34567,
          PositionLat: 2.34567,
          GeoHash: 'abcdefg'
        };
        const expectedResult = 'https://www.google.com/maps?q=2.34567,12.34567&z=7';
        // Acc
        const actualResult = pipe.transform(firstParameter);
        // Assert
        expect(actualResult).toBe(expectedResult);
      });
    });
  });
});

测试结果:

Testing Result

测试单元 - LocationStringPipe

最後一个 Pipe ─ LocationStringPipe 的程序码如下:

export class LocationStringPipe implements PipeTransform {

  transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
    return `${PositionLat}, ${PositionLon}`;
  }

}

其验证项目只需要验证将传入的第一个参数的 PositionLatPositionLong 是否有变成字串并在其中加上逗号即可。

其测试程序码如下:

describe('LocationStringPipe', () => {
  let pipe: LocationStringPipe;
  beforeEach(() => {
    pipe = new LocationStringPipe();
  });

  it('create an instance', () => {
    const pipe = new LocationStringPipe();
    expect(pipe).toBeTruthy();
  });

  describe('transform', () => {
    describe('when the first parameter is `true`', () => {
      it('should return "2.34567, 12.34567"', () => {
        // Arrange
        const firstParameter: StationPosition = {
          PositionLon: 12.34567,
          PositionLat: 2.34567,
          GeoHash: 'abcdefg'
        };
        const expectedResult = '2.34567, 12.34567';
        // Acc
        const actualResult = pipe.transform(firstParameter);
        // Assert
        expect(actualResult).toBe(expectedResult);
      });
    });
  });
});

测试结果:

Testing Result

至此, Pipe 的部份就全测完了,相信大家这部份一定没什麽问题。

而大家应该也有发现,我们在今天在验 Pipe 的时候跟在验 Component 的时候有一个满明显的不同,那就是我们今天没有 TestBed

其实这是因为我们的这几个 Pipe 很乾净,没有依赖任何其他的 Class ,所以在撰写测试时,其实就把它当成一般的 Class ,用 new xxxPipe() 的方式产生出实体就行了。

ReactiveFormsAutoCompleteSearchingService

刚刚前面的 Pipe 只是先让大家热热身,抓抓手感,接下来我们要为 ReactiveFormsAutoCompleteSearchingService 撰写测试,算是今天的重头戏之一。

虽然 ReactiveFormsAutoCompleteSearchingService 的程序码也很简单,但为什麽会是今天的重头戏呢?

这是因为 ReactiveFormsAutoCompleteSearchingService 有用到我们之前没有用过的 httpClient

先来看看它的程序码:

export class ReactiveFormsAutoCompleteSearchingService {

  constructor(private httpClient: HttpClient) { }

  searchStation(stationName: string): Observable<MetroStationDTO[]> {
    let url = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON';
    if (stationName) {
      url += `&$filter=contains(StationName/Zh_tw,'${stationName}')`;
    }
    return this.httpClient.get<MetroStationDTO[]>(url);
  }
}

ReactiveFormsAutoCompleteSearchingService 跟上面的 Pipe 一样,都只有一个函式,不过在这个函式里我们会需要验两个情境,四个案例:

  1. 呼叫 searchStation 所带入的参数是空字串时
    1. 该函式会回传一个 Observable (单元测试)
    2. 要呼叫 httpClientget 函式,并带入参数 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON (整合测试)
  2. 呼叫 searchStation 所带入的参数是有效字串时
    1. 该函式会回传一个 Observable (单元测试)
    2. 要呼叫 httpClientget 函式,并带入参数 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON&$filter=contains(StationName/Zh_tw,'xxx') (整合测试)

开始撰写测试之前,我们一样先把 ReactiveFormsAutoCompleteSearchingService 所依赖的项目准备好:

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

准备好依赖项目之後,就可以开始撰写测试程序罗。

看仔细噢!原本 Service 要使用 HttpClient 的话,正常要在模组内引入 HttpClientModule

但在撰写测试时,我们要引入的是 HttpClientTestingModule 这个 Angular 帮我们准备好专门给撰写测试所要引入的 Module 。

我的测试程序码如下:

describe('searchStation', () => {
  describe('When the stationName is a empty string', () => {
    const stationName = '';
    it('should return a Observable', () => {
      // Act
      const result = service.searchStation(stationName);
      // Assert
      expect(result).toBeInstanceOf(Observable);
    });

    it('should call function "get" of the "HttpClient" with the correct API\'s URL', () => {
      // Arrange
      const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON';
      const httpClient = TestBed.inject(HttpClient);
      spyOn(httpClient, 'get');
      // Act
      service.searchStation(stationName);
      // Assert
      expect(httpClient.get).toHaveBeenCalledWith(apiUrl);
    });
  });

  describe('When the stationName is a valid string', () => {
    const stationName = 'Leo';
    it('should return a Observable', () => {
      // Act
      const result = service.searchStation(stationName);
      // Assert
      expect(result).toBeInstanceOf(Observable);
    });

    it('should call function "get" of the "HttpClient" with the correct API\'s URL', () => {
      // Arrange
      const apiUrl = 'https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON&$filter=contains(StationName/Zh_tw,\'Leo\')';
      const httpClient = TestBed.inject(HttpClient);
      spyOn(httpClient, 'get');
      // Act
      service.searchStation(stationName);
      // Assert
      expect(httpClient.get).toHaveBeenCalledWith(apiUrl);
    });
  });
});

测试结果:

Testing Result

ReactiveFormsAutoCompleteSearchingComponent

最後要测的是 ReactiveFormsAutoCompleteSearchingComponent ,由於是 Component 的关系,基本上除了 Class 本身之外,我们还要来验证 Template 的部份。

先来看看 Class 的程序码:

export class ReactiveFormsAutoCompleteSearchingComponent {

  searchingInputControl = new FormControl();
  stations$ = this.searchingInputControl.valueChanges.pipe(
    startWith(''),
    debounceTime(500),
    switchMap(value => this.service.searchStation(value))
  );

  constructor(private service: ReactiveFormsAutoCompleteSearchingService) { }

}

这个 Component 要验的情境有:

  1. 验证 searchingInputControl 是不是 FormControl
  2. 验证 stations$ 是不是 Observable
  3. 验证 stations$ 被订阅时, ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 会不会被呼叫并传入空字串
  4. 验证 searchingInputControl 的值变动时, ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 会不会被呼叫并传入 searchingInputControl 的值
  5. 验证 searchingInputControl 的值快速变动两次时,ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 是否只被呼叫一次
  6. 验证 searchingInputControl 的值变动两次的间隔时间超过 500 毫秒时,ReactiveFormsAutoCompleteSearchingService 的函式 searchStation 是否被呼叫两次

开始测试前,一样先把依赖的项目准备好:

describe('ReactiveFormsAutoCompleteSearchingComponent', () => {
  let component: ReactiveFormsAutoCompleteSearchingComponent;
  let fixture: ComponentFixture<ReactiveFormsAutoCompleteSearchingComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ReactiveFormsAutoCompleteSearchingComponent],
      providers: [
        {
          provide: ReactiveFormsAutoCompleteSearchingService,
          useValue: {
            searchStation: () => EMPTY
          }
        }
      ]
    })
    .compileComponents();

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

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

从上述程序码中,大家可能会发现以前从来没看过的程序码:

{
  provide: ReactiveFormsAutoCompleteSearchingService,
  useValue: {
    searchStation: () => EMPTY
  }
}

而这也是我们今天文章的主轴, DI 抽换

DI 抽换

DI ,也就是 Dependency Injection ,依赖注入

这点大家应该知道,而 DI 抽换是 Angular 提供的一个很有趣的功能,让我们可以用以下三种方式替换掉想替换的 Provider :

  1. useClass ─ 提供一个继承於想替换掉的 Provider 的 Class ,然後用新的 Class 取代原本的 Provider

    像是:

    class MyRouter extends Router {
      // ...
    }
    
    @NgModule({
      // ...
      providers: [
        {
          provide: Router,
          useClass: MyRouter
        }
      ]
    })
    export class AbcModule { }
    
  2. useValue ─ 像刚刚在测试程序码里所写的那样,直接用物件抽换掉想换掉的 Provider

  3. useFactory ─ 用函式来抽换,像是:

    const abcServiceFactory = () => {
      return new AbcService();
    }
    
    @NgModule({
      // ...
      providers: [
        {
          provide: AbcService,
          useClass: abcServiceFactory
        }
      ]
    })
    export class ABCModule { }
    

关於这部份,真的要讲很细的话可以写一整篇,不过我今天只是想让大家知道我们可以透过 DI 抽换的方式,把不可控的依赖变成可控的,这样才能写出优秀的测试

关於 DI 抽换的部分,如果想了解更多可以参考官方的 Dependency providers
文件。

知道 DI 抽换是什麽概念之後,我们就来开始撰写测试案例吧!

我的测试程序码如下:

describe('Property searchingInputControl', () => {
  it('should be a instance of FormControl', () => {
    // Assert
    expect(component.searchingInputControl).toBeInstanceOf(FormControl);
  });
});

describe('Property stations$', () => {
  it('should be a instance of FormControl', () => {
    // Assert
    expect(component.stations$).toBeInstanceOf(Observable);
  });

  describe('when it be subscribed', () => {
    let service: ReactiveFormsAutoCompleteSearchingService;
    beforeEach(() => {
      service = TestBed.inject(ReactiveFormsAutoCompleteSearchingService);
      spyOn(service, 'searchStation').and.returnValue(of([]));
    });

    it('should call function "searchStation" of the service with empty string', (done) => {
      // Act
      component.stations$.subscribe(() => {
        // Assert
        expect(service.searchStation).toHaveBeenCalledOnceWith('');
        done();
      });
    });

    describe('when the input value changes', () => {
      it('should call function "searchStation" of the service with the value', (done) => {
        // Arrange
        const value = 'Leo'
        // Act
        component.stations$.subscribe(() => {
          // Assert
          expect(service.searchStation).toHaveBeenCalledOnceWith(value);
          done();
        });
        component.searchingInputControl.patchValue(value);
      });
    });

    describe('when the input value changes twice quickly', () => {
      it('should call function "searchStation" of the service once with the last value', (done) => {
        // Arrange
        const firstValue = 'Leo'
        const secondValue = 'Chen'
        // Act
        component.stations$.subscribe(() => {
          // Assert
          expect(service.searchStation).toHaveBeenCalledOnceWith(secondValue);
          done();
        });
        component.searchingInputControl.patchValue(firstValue);
        component.searchingInputControl.patchValue(secondValue);
      });
    });

    describe('when the input value changes twice slowly', () => {
      it('should call function "searchStation" of the service twice', fakeAsync(() => {
        // Arrange
        const firstValue = 'Leo'
        const secondValue = 'Chen'
        // Act
        component.stations$.subscribe();
        component.searchingInputControl.patchValue(firstValue);
        tick(600);
        component.searchingInputControl.patchValue(secondValue);
        tick(600);
        // Assert
        expect(service.searchStation).toHaveBeenCalledTimes(2);
        expect(service.searchStation).toHaveBeenCalledWith(firstValue);
        expect(service.searchStation).toHaveBeenCalledWith(secondValue);
      }));
    });
  })
});

测试结果:

Testing Result

在上述的测试程序码中,我们可以看到今天要分享给大家的最後一个技巧:非同步测试。

Angular 的非同步测试技巧

在验证非同步事件处理逻辑如 PromiseObservable 时,最简单的方式当然就是直接 then 或是 subscribe 之後再验证。

而这时我们会在传入 it 的函式里,多一个名为 done 的参数 (你要取名为别的名字也可以) ,如此我们就可以让测试知道我们要等非同步事件完成後再行验证。

像这样:

it('description', (done) => {
  observable.subscribe(() => {
    done();
  });
});

但除了这个方式外,Angular 还有提供另一个方式是是永 fakeAsynctick 的组合。

使用方式是将原本要传入 it 里的函式传入 fakeAsync() 里并用它来做替代,接着就可以在 it 里面使用 tick() 这个函式来代表时间的流逝。

例如:

it('description', fakeAsync(() => {
  // Do A

  tick(300) // ms

  // Assert A
}));

而且这个时间的流逝是假的,又或者是说,有种「时间加速器的概念」。

假设 Do AAssert A 之间相隔十年,用了 tick(10年) 之後,瞬间就过完了十年,厉害吧!

简直媲美萨诺斯收集完无限宝石之後,一弹指就让全宇宙的一半人口都灰飞湮灭的帅度

今天差不多就到这边,讯息量应该满大的,至於剩下 Template 的测试没什麽太特别的地方,就让大家练习做做看罗!

本日小结

今天的重点:

  1. 如果被测试的 Class 没有任何依赖,则只需使用 new XXX() 来产生实体即可( Component 除外)
  2. 如果有使用到 HttpClient 的话,撰写测试时要引入的是 HttpClientTestingModule ,而不是 HttpClientModule
  3. DI 抽换
  4. 非同步的处理

以上技巧会在大家实际撰写测时非常大量的使用,记得要多加练习才会熟能生巧噢!

今天的程序码会放在 Github - Branch: day25 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!

如果有任何的问题或是回馈,还请麻烦留言给我让我知道!


<<:  Day26 子元件与父元件

>>:  【从实作学习ASP.NET Core】Day28 | 前台 | 管理我的订单

小学生学程序设计 Day 27:「夜市的鸡蛋糕」

嘿~~ 各位好,我是菜市场阿龙! 这集要介绍的是「物件导向程序设计」 频道:https://www....

第08天 - PHP 列印出MySQL资料表的内容

今天来在网页上显示出 MySQL资料表 的内容 1.连线到资料库,首先要新建立1个.php档(我取名...

[Deploy to Render] 用免费方案部署 LINE Bot

从发布第一篇什麽是 Render 至今,Render 增加了很多新功能,像是 SSH、Redis、D...

[DAY8]将范例上传(2)

第一步:将昨天下载完的压缩档解压缩,取出line-bot-sdk-python-master\exa...

EP 26: MockData come back by (a little bit) DI design

Hello, 各位 iT邦帮忙 的粉丝们大家好~~~ 本篇是 Re: 从零开始用 Xamarin 技...