好一阵子没写单元测试与整合测试了,大家是否觉得有些生疏了呢?
之前的测试都写得很简单,正好昨天好好地写了搜寻输入框还有呼叫 API ,可以藉由撰写这个功能的测试来分享一些小技巧给大家。
小提醒:昨天的程序码大家可以从 Github - Branch: day24 上 Clone 或者是 Fork 下来。
这次要撰写测试的档案比较多,有三个 Pipe
、 一个 Service
与一个 Component
的测试需要撰写。
不过虽然档案比较多,但要撰写的测试其实不会比较难,相反地,由於我们昨天在开发的时候有把逻辑切到各个 Pipe
与 Service
,因此凡而在撰写测试上会显得更加地好写。
首先,我们来看看最简单的 BooleanInZhTwPipe
,其程序码如下:
export class BooleanInZhTwPipe implements PipeTransform {
transform(value: boolean, ...args: unknown[]): string {
return value ? '是' : '否';
}
}
BooleanInZhTwPipe
只有一个函式 transform
,因此我们只要验证:
value
为 true
时,则回传 是
。value
为 false
时,则回传 否
。够简单了吧?
测试程序码如下:
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);
});
});
});
});
测试结果:
GoogleMapLinkPipe
的部份也很简单,其程序码如下:
export class GoogleMapLinkPipe implements PipeTransform {
transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `https://www.google.com/maps?q=${PositionLat},${PositionLon}&z=7`;
}
}
而其验证项目只需要验证将传入的第一个参数的 PositionLat
跟 PositionLong
是否有与 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);
});
});
});
});
测试结果:
最後一个 Pipe ─ LocationStringPipe
的程序码如下:
export class LocationStringPipe implements PipeTransform {
transform({ PositionLat, PositionLon }: StationPosition, ...args: unknown[]): string {
return `${PositionLat}, ${PositionLon}`;
}
}
其验证项目只需要验证将传入的第一个参数的 PositionLat
跟 PositionLong
是否有变成字串并在其中加上逗号即可。
其测试程序码如下:
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);
});
});
});
});
测试结果:
至此, Pipe 的部份就全测完了,相信大家这部份一定没什麽问题。
而大家应该也有发现,我们在今天在验 Pipe 的时候跟在验 Component 的时候有一个满明显的不同,那就是我们今天没有 TestBed
。
其实这是因为我们的这几个 Pipe 很乾净,没有依赖任何其他的 Class ,所以在撰写测试时,其实就把它当成一般的 Class ,用 new xxxPipe()
的方式产生出实体就行了。
刚刚前面的 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 一样,都只有一个函式,不过在这个函式里我们会需要验两个情境,四个案例:
searchStation
所带入的参数是空字串时
Observable
(单元测试)
httpClient
的 get
函式,并带入参数 https://ptx.transportdata.tw/MOTC/v2/Rail/Metro/Station/TRTC?$format=JSON
(整合测试)
searchStation
所带入的参数是有效字串时
Observable
(单元测试)
httpClient
的 get
函式,并带入参数 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);
});
});
});
测试结果:
最後要测的是 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 要验的情境有:
searchingInputControl
是不是 FormControl
stations$
是不是 Observable
stations$
被订阅时, ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
会不会被呼叫并传入空字串searchingInputControl
的值变动时, ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
会不会被呼叫并传入 searchingInputControl
的值searchingInputControl
的值快速变动两次时,ReactiveFormsAutoCompleteSearchingService
的函式 searchStation
是否只被呼叫一次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 ,也就是 Dependency Injection ,依赖注入。
这点大家应该知道,而 DI 抽换是 Angular 提供的一个很有趣的功能,让我们可以用以下三种方式替换掉想替换的 Provider :
useClass
─ 提供一个继承於想替换掉的 Provider 的 Class ,然後用新的 Class 取代原本的 Provider
像是:
class MyRouter extends Router {
// ...
}
@NgModule({
// ...
providers: [
{
provide: Router,
useClass: MyRouter
}
]
})
export class AbcModule { }
useValue
─ 像刚刚在测试程序码里所写的那样,直接用物件抽换掉想换掉的 Provider
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);
}));
});
})
});
测试结果:
在上述的测试程序码中,我们可以看到今天要分享给大家的最後一个技巧:非同步测试。
在验证非同步事件处理逻辑如 Promise
与 Observable
时,最简单的方式当然就是直接 then
或是 subscribe
之後再验证。
而这时我们会在传入 it
的函式里,多一个名为 done
的参数 (你要取名为别的名字也可以) ,如此我们就可以让测试知道我们要等非同步事件完成後再行验证。
像这样:
it('description', (done) => {
observable.subscribe(() => {
done();
});
});
但除了这个方式外,Angular 还有提供另一个方式是是永 fakeAsync
与 tick
的组合。
使用方式是将原本要传入 it
里的函式传入 fakeAsync()
里并用它来做替代,接着就可以在 it
里面使用 tick()
这个函式来代表时间的流逝。
例如:
it('description', fakeAsync(() => {
// Do A
tick(300) // ms
// Assert A
}));
而且这个时间的流逝是假的,又或者是说,有种「时间加速器的概念」。
假设 Do A
到 Assert A
之间相隔十年,用了 tick(10年)
之後,瞬间就过完了十年,厉害吧!
简直媲美萨诺斯收集完无限宝石之後,一弹指就让全宇宙的一半人口都灰飞湮灭的帅度
今天差不多就到这边,讯息量应该满大的,至於剩下 Template 的测试没什麽太特别的地方,就让大家练习做做看罗!
今天的重点:
new XXX()
来产生实体即可( Component 除外)HttpClientTestingModule
,而不是 HttpClientModule
以上技巧会在大家实际撰写测时非常大量的使用,记得要多加练习才会熟能生巧噢!
今天的程序码会放在 Github - Branch: day25 上供大家参考,建议大家在看我的实作之前,先按照需求规格自己做一遍,之後再跟我的对照,看看自己的实作跟我的实作不同的地方在哪里、有什麽好处与坏处,如此反覆咀嚼消化後,我相信你一定可以进步地非常快!
如果有任何的问题或是回馈,还请麻烦留言给我让我知道!
>>: 【从实作学习ASP.NET Core】Day28 | 前台 | 管理我的订单
嘿~~ 各位好,我是菜市场阿龙! 这集要介绍的是「物件导向程序设计」 频道:https://www....
今天来在网页上显示出 MySQL资料表 的内容 1.连线到资料库,首先要新建立1个.php档(我取名...
从发布第一篇什麽是 Render 至今,Render 增加了很多新功能,像是 SSH、Redis、D...
第一步:将昨天下载完的压缩档解压缩,取出line-bot-sdk-python-master\exa...
Hello, 各位 iT邦帮忙 的粉丝们大家好~~~ 本篇是 Re: 从零开始用 Xamarin 技...