学习如何使用NgRx和Jest对Angular组件进行单元测试。
下载
在这篇文章中,我将引用一个使用NgRx的非常基础的演示应用中的代码。您可以克隆存储库或下载源代码的zip文件:
系列
本文是使用Jest测试NgRx系列文章的一部分:
堆栈
此应用程序的堆栈将是:
- 角6李><李>NgRx 6李><李>Jest和angular的Jest预置模块李><李>jasmine-marbles模块李><李>伪造伪造数据李>
Jest测试运行器
我们将使用Jest为一个使用NgRx进行状态管理的Angular应用编写单元测试。它比Karma快得多(即使使用无头铬),并且使用类似jasmine的API。
如果你是笑话新手,<一个href=”//www.nxtmastery.com/2018/05/26/angular-jest-testing/">看看我关于在Angular中使用Jest的文章一个>。
jasmine-marbles
我们将使用jasmine-marbles模块,以模拟可观察对象。
这里有一些链接到更多关于用NgRx测试大理石的信息:
总之,我们可以使用类似大理石图的字符串来描述一个随时间变化的可观察流。这使我们能够同步地测试异步可观察流。
我们将使用两个主要函数:
热()
创建一个热的可观察流。李><李>冷()
创建一个冷的可观察流。李>
我们将使用这些函数断言应用程序中特定操作的结果将产生我们所描述的预期的热或冷可观察流。
大理石测试使用一个字符串来描述可观察流:
-
一个破折号表示一段虚拟的时间,也就是10毫秒,但这并不是很重要。李><李>a-z0-9
任何字母数字字符都表示一个值。我们可以使用第二个参数热()
和冷()
函数来指定每个标记所代表的值。李><李>()
对数将值组合到一个单独的帧中。李><李>^
胡萝卜表示一个冰冷的可观察流中订阅的开始。李><李>|
管道表示完成通知。李><李>#
井号(或哈希符号)表示错误通知。李>
容器和演讲
在示例应用程序中,我使用了将“容器”组件与“表示”组件分离开来的组件体系结构。总而言之:
- 容器组件检索数据并更改应用程序的状态。李><李>表示组件是高度可重用的组件,不依赖于应用程序的状态。它们依赖于输入到组件并将事件作为输出发出的数据。李>
索引组件
让我们来看看如何在一个使用NgRx的应用中测试一个容器组件。
首先,让我们在src / app /用户/集装箱/ index.component.spec.ts文件:
描述(“IndexComponent”,()= >{让组件:IndexComponent;让夹具:ComponentFixture<IndexComponent>;让退订=新主题<无效>();beforeEach(异步(()= >{试验台。configureTestingModule({声明:[IndexComponent,UserListComponent],进口:[RouterTestingModule],供应商:[{提供:商店,useValue:{调度:开玩笑。fn(),管:开玩笑。fn()}}]})。compileComponents();}));beforeEach(()= >{夹具=试验台。createComponent(IndexComponent);组件=夹具。componentInstance;});afterEach(()= >{退订。下一个();退订。完整的();});});
让我们回顾一下测试的设置:
- 如您所见,Jest使用类似jasmine的方法来设置测试套件,使用
描述()
,以及全局方法,如beforeEach ()
和afterEach ()
。李><李>首先,我们设置试验台
在第一个beforeEach ()
方法。李><李>我们添加子组件IndexComponent
和UserListComponent
到宣言
数组,就像我们在模块中添加的那样。李><李>我们正在进口RouterTestingModule
嘲笑的< router-outlet >
元素。李><李>我们正在模拟一个对象商店
,具有两个性质:调度()
和管()
。李><李>然后,在第二个beforeEach ()
方法创建组件夹具
并存储对组件的引用实例
。李><李>最后,我们使用afterEach ()
方法取消测试中的任何订阅takeUntil
在RxJS运营商。李>
标准的“it should create”测试如下:
它(“应该创建”,()= >{预计(组件)。toBeTruthy();});
没什么特别的。当我们使用Angular CLI生成组件时,这是直接开箱即用的。
让我们看看测试ngOnInit ()
方法:
ngOnInit(){这。商店。调度(新LoadUsers());这。用户=这。商店。管(选择(selectAllUsers));}
我们只是在发送LoadUsers
操作,并使用selectAllUsers
选择器来获取存储中的用户数组,它恰好是@ngrx/entity的集合用户
对象。
让我们测试它:
描述(“ngOnInit()”,()= >{它('应该在ngOnInit生命周期中调度LoadUsers动作',()= >{常量行动=新LoadUsers();常量商店=试验台。得到(商店);常量间谍=开玩笑。spyOn(商店,“调度”);夹具。detectChanges();预计(间谍)。toHaveBeenCalledWith(行动);});它(“应该selectAllUsers”,()= >{常量商店=试验台。得到(商店);常量用户=generateUsers();商店。管=开玩笑。fn(()= >热(“——”,{一个:用户}));夹具。detectChanges();常量预期=冷(“——”,{一个:用户});预计(组件。用户)。toBeObservable(预期);});});
我们想测试这两个调度()
和管()
控件中调用的方法ngOnInit ()
方法。
首先,我们创建一个测试来分派LoadUsers
行动:
- 首先,我们更新
LoadUsers
action类。李><李>然后,我们得到对商店
使用TestBed.get ()
方法。李><李>然后我们安排了一个间谍调度()
方法使用jest.spyOn ()
函数。李><李>然后我们调用ngOnInit ()
方法触发变更检测detectChanges ()
方法对夹具。李><李>最后,我们期待调度()
方法来调用LoadUsers
行动。李>
然后,我们创建一个从state对象中选择用户的测试:
- 首先,我们得到一个引用
商店
使用TestBed.get ()
方法。李><李>接下来,我使用导出generateUsers ()
函数生成一个假数组用户
对象。我正在使用faker库来生成一些假数据。李><李>接下来,我们要嘲笑管()
方法商店
实例。模拟函数返回一个新的热()
可观察到的流。可观察流由一个帧(虚拟“时间”已经通过)和一个值组成,一个
,也就是用户
数组中。李>
最后,我们应该编写一个单元测试来验证用户
属性是一个数组的冷观察对象用户
对象:
描述(“用户”,()= >{它('应该是用户对象数组的可观察对象',完成= >{常量用户=generateUsers();常量商店=试验台。得到(商店);商店。管=开玩笑。fn(()= >冷(“——|”,{一个:用户}));夹具。detectChanges();组件。用户。订阅(componentUsers= >{预计(componentUsers)。toEqual(用户);完成();});getTestScheduler()。冲洗();});});
在这个测试:
- 首先,我们生成一个假数组
用户
对象的使用generateUsers ()
函数。李><李>我们得到一个引用商店
对象注入到我们的组件中TestBed.get ()
方法。李><李>我们要模拟管
方法返回的冷可观测流用户
对象。我们正在创造一个冷的可见物体,因为HttpClient
返回一个冷观测值。李><李>然后我们调用ngOnInit ()
方法触发变更检测detectChanges ()
方法对夹具。李><李>后调用ngOnInit ()
在我们的方法组件
,我们可以订阅()
到用户
性质,期望最后一个()
notification是用户数组。李><李>注意,我们使用(完成)
Jest提供的信号异步测试完成的函数。李><李>最后,我们需要冲洗()
测试调度程序中的任务。这将触发cold observable发出通知。李>
用户列表组件
现在我们已经测试了我们的容器IndexComponent
类,让我们看一个测试示例UserListComponent
。元素的子元素IndexComponent
并被设计成无状态的表示组件。
让我们来看看组件模板src / app /用户/组件/用户列表/ user-list.component.html:
<ul><李* ngFor=”让用户的用户”trackBy=”user.id”><一个href=”javascript:无效(0)”(点击)=”selectUser.emit(用户)”>{{用户。firstName}} {{user.lastName}}一个>李>ul>
如您所见,我们只是呈现了一个无序的用户列表。这里没有什么特别的。
首先,我们想测试用户列表是否呈现在其中src / app /用户/组件/用户列表/ user-list.component.spec.ts:
它(应该显示一个无序的英雄列表,()= >{常量ulDebugEl=夹具。debugElement。查询(通过。css(“ul”));常量ulEl=ulDebugEl。nativeElement作为HTMLUListElement;组件。用户=用户;夹具。detectChanges();预计(ulEl。childElementCount)。托比(用户。长度);常量firstLi=ulEl。querySelector(“李:第一个孩子”);预计(firstLi。textContent)。toEqual(”$ {用户[0]。firstName}$ {用户[0]。姓}”);});
这个测试非常直接:
- 首先,我们得到引用
DebugElement
对于无序列表元素。李><李>然后,我们得到对HTMLUListElement
对象,即nativeElement
财产的DebugElement
。注意,我们必须强制转换类型,以显式地告诉TypeScript DOM元素是具有类型的HTMLUListElement
。李><李>然后我们设置用户
属性。在这个例子中,我有一个数组用户
对象,该对象存储在名为用户
调用的结果generateUsers ()
函数,该函数使用faker生成假数据。李><李>接下来,我们在Angular中使用detectChanges ()
方法对夹具。李><李>我们期望()
这一childElementCount
的无序列表的长度应该与用户数组的长度匹配。李><李>我们进一步获得第一个列表项元素,并期望textContent
等于呈现的第一个用户的姓和名。李>
现在,我们断言EventEmitter
触发被点击的用户:
它(“点击时应选择用户”,()= >{常量用户=用户[0];组件。用户=用户;夹具。detectChanges();常量anchorDebugEl=夹具。debugElement。查询(通过。css('ul > li:第一个孩子> a'));让selectedUser:用户;组件。selectUser。订阅(用户= >(selectedUser=用户));anchorDebugEl。triggerEventHandler(“点击”,用户);预计(selectedUser)。toEqual(用户);});
让我们回顾一下测试:
- 首先,存储对第一个对象的引用
用户
(我们将断言它是被点击/触摸的用户)。李><李>和之前的测试一样,我们设置了用户
的数组用户
我们使用faker生成的对象。李><李>然后,我们在Angular中触发变更检测。李><李>使用DebugElement
我们得到一个引用<一>
元素,该元素是列表中第一个列表项的直接后代。李><李>我们订阅EventEmitter
为selectedUser
输出事件。李><李>使用triggerEventHandler ()
方法,我们可以触发点击
事件,提供用户
事件对象。李><李>最后,我们期望()
这一selectedUser
等于用户
在单击事件中触发的对象。李>
运行测试
现在是时候停下来运行我们的测试,以确保一切都通过了:
添加用户组件
好了,现在我们有测试IndexComponent
它的孩子UserListComponent
,让我们看看测试AddComponent
。
这里是模板src / app /用户/集装箱/添加/ add.component.html:
<app-user-form(userChange)=”onUserChange(事件)”>app-user-form>
我们将使用UserFormComponent
若要显示用于添加新用户的表单,请附加@Output ()
绑定的userChange
,调用onUserChange ()
方法,当触发值时。
在AddComponent
我们定义的onUserChange ()
方法src / app /用户/集装箱/添加/ add.component.ts:
出口类AddComponent{构造函数(私人商店:商店<fromRoot。状态>){}onUserChange(用户:用户){这。商店。调度(新AddUser({用户:用户}));}}
在onUserChange ()
方法我们调度()
的AddUser
行动到商店。
好的,我们来测试一下AddUser
动作被分派时onUserChange
发出的src / app /用户/集装箱/添加/ add.component.spec.ts:
它(当onUserChange被调用时应该调度AddUser动作,()= >{常量用户=generateUser();常量行动=新AddUser({用户});常量间谍=开玩笑。spyOn(商店,“调度”);夹具。detectChanges();组件。onUserChange(用户);预计(间谍)。toHaveBeenCalledWith(行动);});
- 首先,我们用
generateUser ()
函数,该函数从模型中导入以创建假的用户
对象使用伪造者。李><李>我们重新开始AddUser
行动,指定有效载荷
这是用户
对象。李><李>然后,制造一个间谍调度()
方法在存储中使用jest.spyOn ()
。李><李>然后触发变更检测并调用onUserChange ()
方法与用户
对象。李><李>最后,我们断言调度()
方法调用AddUser
行动。李>
编辑用户组件
除了能够添加用户之外,我们还希望能够编辑现有用户。的EditComponent
模板非常类似于AddComponent
,加上用户
输入绑定:
<app-user-form(用户)=”$ |用户异步”(userChange)=”onUserChange(事件)”>app-user-form>
的EditComponent
由id
参数,以指定要编辑的用户。我们将使用id
参数来分派两个动作:SelectUser
和LoadUser
。
这是EditComponent
源在src / app /用户/集装箱/编辑/ edit.component.ts:
出口类EditComponent实现了OnInit{用户美元:可观测的<用户>;构造函数(私人activatedRoute:ActivatedRoute,私人商店:商店<fromRoot。状态>){}ngOnInit(){常量PARAM_ID=“id”;这。用户美元=这。activatedRoute。paramMap。管(过滤器(paramMap= >paramMap。有(PARAM_ID)),地图(paramMap= >paramMap。得到(PARAM_ID)),利用(id= >{这。商店。调度(新SelectUser({id:+id}));这。商店。调度(新LoadUser({id:+id}));}),switchMap(id= >这。商店。管(选择(selectSelectedUser))));}onUserChange(用户:用户){这。商店。调度(新UpdateUser({用户:用户}));}}
在开始测试组件之前,我们需要连接试验台
:
beforeEach(异步(()= >{试验台。configureTestingModule({声明:[EditComponent,UserFormComponent],进口:[FormsModule,RouterTestingModule,ReactiveFormsModule],供应商:[{提供:ActivatedRoute,useValue:{paramMap:新BehaviorSubject(convertToParamMap({id:用户。id}))}},{提供:商店,useValue:{调度:开玩笑。fn(),管:开玩笑。fn(()= >热(“——”,{一个:用户}))}}]})。compileComponents();}));
我们来回顾一下beforeEach ()
方法:
- 首先,我们指定
EditComponent
和UserFormComponent
就像我们在模块里做的那样。李><李>我们添加必要的进口
用于处理响应式表单和路由器。李><李>在供应商
数组,我们需要模拟ActivatedRoute
和商店
。我们使用一个BehaviorSubject
嘲笑的paramMap
财产的ActivatedRoute
,指定id
假用户的。我们模仿了这两个调度()
和管()
方法商店
。请注意,管()
mock返回一个hot observable,它会在单个帧之后发出user对象。李>
与我们的试验台
准备好了,我们来测试一下ngOnInit ()
生命周期方法:
描述(“ngOnInit”,()= >{它('should dispatch SelectUser action for specified id parameter',()= >{常量行动=新SelectUser({id:用户。id});常量间谍=开玩笑。spyOn(商店,“调度”);夹具。detectChanges();预计(间谍)。toHaveBeenCalledWith(行动);});它(应该为指定的id参数调度LoadUser动作,()= >{常量行动=新LoadUser({id:用户。id});常量间谍=开玩笑。spyOn(商店,“调度”);夹具。detectChanges();预计(间谍)。toHaveBeenCalledWith(行动);});});
我们的“ngOnInit”测试套件包含两个测试SelectUser
和LoadUser
行动。
接下来,让我们测试onUserChange ()
方法时调用的userChange
EventEmitter
对象发出更新后的user对象<用户表单>
组件。
描述(“onUserChange”,()= >{它(当onUserChange被调用时应该分派UpdateUser动作,()= >{常量用户=generateUser();常量行动=新UpdateUser({用户});常量间谍=开玩笑。spyOn(商店,“调度”);夹具。detectChanges();组件。onUserChange(用户);预计(间谍)。toHaveBeenCalledWith(行动);});});
这与AddComponent
。
用户表单组件
与AddComponent
和EditComponent
测试后,下一个逻辑步骤是为UserFormComponent
。
这里是模板src / app /用户/组件/用户表单/ user-form.component.html:
<形式(formGroup)=”形式”><输入类型=”文本”占位符=”第一个名字”formControlName=”firstName”/><输入类型=”文本”占位符=”姓”formControlName=”姓”/><按钮(点击)=”onSave ()”>保存按钮>形式>
我用的是ReactiveFormsModule
为用户创建一个简单的表单,其中包含两个输入:姓和名。当点击“保存”按钮时,onSave ()
方法将被调用。
在UserFormComponent
我们定义的onSave ()
方法:
onSave(){如果(这。形式。无效的){返回;}这。userChange。发出({…这。用户,…这。形式。价值});}
首先,让我们断言表单值在ngOnChanges ()
调用生命周期方法:
它(“应该将值修补到表单中”,()= >{常量用户=generateUser();组件。ngOnChanges({用户:新SimpleChange(零,用户,真正的)});预计(组件。形式。价值)。toEqual({firstName:用户。firstName,姓:用户。姓});});
在这个测试中,我们:
- 生成一个假用户李><李>调用
ngOnChanges ()
方法,提供一个新的SimpleChange
对象的一个用户
。李><李>预计,价值
的FormGroup
包括用户的名和姓。李>
然后,我们断言userChange
当表单被提交时,输出事件被触发:
它('提交时应该触发userChange事件',()= >{常量用户=generateUser();常量firstName=“布莱恩”;常量firstNameDebugEl=夹具。debugElement。查询(通过。css(“输入(formControlName =“firstName”)”));常量firstNameEl=firstNameDebugEl。nativeElement作为HTMLInputElement;常量buttonDebugEl=夹具。debugElement。查询(通过。css(“按钮”));夹具。detectChanges();让updatedUser:用户;组件。userChange。订阅(用户= >(updatedUser=用户));组件。用户=用户;组件。ngOnChanges({用户:新SimpleChange(零,用户,真正的)});firstNameEl。价值=firstName;firstNameEl。dispatchEvent(newEvent(“输入”));buttonDebugEl。triggerEventHandler(“点击”,零);预计(updatedUser)。toEqual({…用户,firstName});});
为了测试我们的组件,我们需要:
- 提供一个假用户作为组件的输入。李><李>触发
ngOnChanges ()
方法来填充表单patchValue ()
方法FormGroup
。李><李>订阅userChange
输出。李><李>的值更新firstName
输入。李><李>触发点击
事件。李>
在用初始用户数据填充表单之后,更新第一个名称输入值,并订阅userChange
EventEmitter
我们可以期望()
的更新用户
发出的值等于提供的用户,带有update first name值。
结论
单元测试非常有价值,使用像Jest这样的测试运行器可以使快速迭代的体验变得愉快。
非常感谢NgRx社区,以及所有关于在Angular中使用Rx弹珠对observable和异步事件进行单元测试的文档。