为 shark 项目提供搭建测试环境的工具。
$ npm install --save-dev shark-test
$ npm install --save classlist
$ npm install --save @types/jasmine
在package.json文件中,添加测试指令
{
"scripts": {
"test": "./node_modules/shark-test/dist/src/index.js test"
},
}
执行以下初始化命令,自动创建四个测试相关的配置文件
st init
或者
shark-test init
在项目根目录下创建配置文件shark-test-conf.json,所有配置项都设了默认值
{
//项目的源码路径--所有配置基于该路径
"basePath": "src/main/webapp",
// 测试入口文件(.spec.ts)
"main": "test.ts",
// 测试环境的polyfills
"polyfills": "polyfills",
// karma的配置文件
"configFile": "karma.conf.js",
// 项目组件目录
"componentPath": "app",
// 项目资源
"assets": ["assets", "favicon.ico"],
// �项目模板
"indexTemplate": "index.ejs"
}在配置文件中设置的
basePath路径下添加用于测试入口文件test.ts
// This file is required by karma.conf.js and loads recursively all the .spec and framework files import "zone.js/dist/long-stack-trace-zone"; import "zone.js/dist/proxy"; import "zone.js/dist/sync-test"; import "zone.js/dist/jasmine-patch"; import "zone.js/dist/async-test"; import "zone.js/dist/fake-async-test"; import { getTestBed } from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; declare const require: any; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting() ); // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. context.keys().map(context);
在配置文件中设置的
basePath路径下添加用于测试环境的polyfills.ts文件
/** * This file includes polyfills needed by Angular and is loaded before the app. * You can add your own extra polyfills to this file. * * This file is divided into 2 sections: * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. * 2. Application imports. Files imported after ZoneJS that should be loaded before your main * file. * * The current setup is for so-called "evergreen" browsers; the last versions of browsers that * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. * * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html */ /*************************************************************************************************** * BROWSER POLYFILLS */ /** IE9, IE10 and IE11 requires all of the following polyfills. **/ import 'core-js/es6/symbol'; import 'core-js/es6/object'; import 'core-js/es6/function'; import 'core-js/es6/parse-int'; import 'core-js/es6/parse-float'; import 'core-js/es6/number'; import 'core-js/es6/math'; import 'core-js/es6/string'; import 'core-js/es6/date'; import 'core-js/es6/array'; import 'core-js/es6/regexp'; import 'core-js/es6/map'; import 'core-js/es6/weak-map'; import 'core-js/es6/set'; /** IE10 and IE11 requires the following for NgClass support on SVG elements */ import 'classlist.js'; // Run `npm install --save classlist.js`. /** IE10 and IE11 requires the following for the Reflect API. */ import 'core-js/es6/reflect'; /** Evergreen browsers require these. **/ // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. import 'core-js/es7/reflect'; /** * Required to support Web Animations `@angular/platform-browser/animations`. * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation **/ // import 'web-animations-js'; // Run `npm install --save web-animations-js`. /*************************************************************************************************** * Zone JS is required by default for Angular itself. */ import 'zone.js/dist/zone'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS */
在项目根目录下创建karma配置文件karma.conf.js
// Karma configuration file, see link for more information // https://karma-runner.github.io/1.0/config/configuration-file.html module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', 'shark'], plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('shark-test/dist/src/plugin/karma') ], client: { clearContext: false // leave Jasmine Spec Runner output visible in browser }, coverageIstanbulReporter: { reports: ['html', 'lcovonly'], fixWebpackSourcePaths: true }, reporters: ['progress', 'kjhtml'], port: 9888, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], singleRun: false }); };
npm run test
或者
st test
或者
shark-test test
- 组件类测试
像测试服务类一样测试组件类,单独测试组件类本身而不必涉及DOM。
例子1:下面是�一个列表组件ProduceScheduleComponent(带路由的组件、带依赖的组件、嵌套组件)
import { Component, ViewChild } from '@angular/core'; import { Router, ActivatedRoute } from '@angular/router'; import { ProduceScheduleService } from './produce-schedule.service'; @Component({ styleUrls: ['./produce-schedule.component.scss'], templateUrl: './produce-schedule.component.html' }) export class ProduceScheduleComponent { purchaseOrderList: Array<any>;//采购单列表 pagination: any; constructor( private router: Router, private route: ActivatedRoute, private produceScheduleService: ProduceScheduleService ) { } ngOnInit() { let params={ page:1, size:20 } this.getPurchaseList(params) } getPurchaseList(params) { this.produceScheduleService.getProceedingList(params).then(data => { this.purchaseOrderList = data.result this.pagination = data.pagination }).catch(err => { }) } }
组件对应的测试代码
import { TestBed, async } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SharkValid, SharkValidForm, Common } from "@shark/shark-angularX"; import { RouterTestingModule } from '@angular/router/testing'; import { ProduceScheduleComponent } from './produce-schedule.component'; import { ProduceScheduleService } from './produce-schedule.service'; describe('#ProduceScheduleComponent', () => { let produceScheduleService; let fixture; let component; let getProceedingListSpy; let selectDom; let data = { code: 200, data: { pagination: { page: 1, size: 10, total: 354, totalPage: 36, offset: 0 }, result: [{}] } }; beforeEach(() => { // 创建 ProduceScheduleService实例,并伪造了`getProceedingList()`方法 const produceScheduleService = jasmine.createSpyObj('ProduceScheduleService', ['getProceedingList']); // 设置伪造的`getProceedingList()`方法的返回值 getProceedingListSpy = produceScheduleService.getProceedingList.and.returnValue(new Promise<any>((resolve, reject) => { resolve(data) })); // 引入组件测试环境的依赖 TestBed.configureTestingModule({ // RouterTestingModule:建立测试�需要的路由 imports: [RouterTestingModule], declarations: [ProduceScheduleComponent, SharkValidForm, SharkValid], providers: [{ provide: ProduceScheduleService, useValue: produceScheduleService }, Common], // 对嵌套组件,可能包含更多组件 //NO_ERRORS_SCHEM会要求 Angular 编译器忽略不认识的那些元素和属性 schemas: [NO_ERRORS_SCHEMA] }); fixture = TestBed.createComponent(ProduceScheduleComponent); component = fixture.componentInstance; }); // 测试组件是否创建成成功 it('should create the ProduceScheduleComponent', async(() => { expect(component).toBeTruthy(); })); // 测试getProceedingList方法有没有被调用 it('should call getProceedingList after component initialized', () => { fixture.detectChanges(); // sync spy result shows getProceedingList immediately after init expect(getProceedingListSpy.calls.any()).toBe(true, 'getProceedingList called'); }); });
例子2:下面是�一个列表组件ProduceScheduleComponent(带输入的组件)
调用组件代码
<schedule-item [purchaseItem]="item"></schedule-item>
ScheduleItemComponent组件的测试代码
import { TestBed, async } from '@angular/core/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { SharkToastrService, SharkModalService } from "@shark/shark-angularX"; import { RouterTestingModule } from '@angular/router/testing'; import { ScheduleItemComponent } from './schedule-item.component'; import { PurchaseStatePipe } from '../purchase-state.pipe' import { SharedModule } from '../../../shared/shared.module' describe('#ScheduleItemComponent', () => { let fixture; let component; let purchaseOrderEl; let purchaseItem = { purchaseOrder: "1375870000011112-20171024-32", allowModify: true, isVersion1_0: 0, purchaseOrderType: 2, purchaseOrderSaleModel: 1, } beforeEach(() => { TestBed.configureTestingModule({ imports: [RouterTestingModule, SharedModule], declarations: [ScheduleItemComponent, PurchaseStatePipe], providers: [SharkToastrService, SharkModalService], // test will not load sub components schemas: [NO_ERRORS_SCHEMA] }); fixture = TestBed.createComponent(ScheduleItemComponent); component = fixture.componentInstance; // 给组件传入purchaseItem数据 component.purchaseItem = purchaseItem; fixture.detectChanges(); }); // 测试输入数据中采购单号在页面显示的正确性 it('should display same purchaseOrder as purchaseItem.purchaseOrder ', () => { // �查找显示采购单号的标签元素 purchaseOrderEl = fixture.nativeElement.querySelectorAll('b')[0]; const purchaseOrder = purchaseItem.purchaseOrder; expect(purchaseOrderEl.textContent).toContain(purchaseOrder); }); });
- 组件Dom测试
测试组件是否能正常渲染出来、响应用户的输入和查询或与它的父组件和子组件相集成
�例子:略
Directive作为宿主元素的属性来被使用的, 需要创建宿主元素来对指令的行为进行测试
例子:�ShowAndHideListDirective的功能是控制table行数展示,并有展开、收起功能
import { Directive, ElementRef, HostListener, Input } from '@angular/core' @Directive({ selector: '[showAndHideList]' }) export class ShowAndHideListDirective { private el: HTMLElement; @Input('showAndHideList') initNum: number; constructor(el: ElementRef) { this.el = el.nativeElement; this.el.style.backgroundColor = '#f4f4f4'; } @HostListener('click') onClick() { var isHide = false; var groups = this.el.parentElement.parentElement.getElementsByTagName('tr'); for (var i = this.initNum; i < groups.length; i++) { var item = groups[i]; if (item.id == 'showAndHide') { break; } if (item.classList.contains('hide')) { item.classList.remove('hide'); isHide = true; } else { item.classList.add('hide'); isHide = false; } } if (isHide) { this.el.innerHTML = "收起商品<i class = 'icon-up'></i>" } else { this.el.innerHTML = "全部商品<i class = 'icon-down'></i>" } } }
ShowAndHideListDirective指令的测试代码
import { Component, OnInit } from '@angular/core' import { TestBed } from '@angular/core/testing'; import { ShowAndHideListDirective } from './showAndHideList.directive' @Component({ template: ` <div class="table-wrap margin-b-4x"> <table class="table table-full text-center"> <thead> <tr> <th>严选SKU ID</th> <th>商品名称</th> <th>规格</th> </tr> </thead> <tbody class="text-center"> <tr *ngFor="let item of skuItems;index as i " [ngClass]="{'hide':i > initNum-1}"> <td>{{item.skuId}}</td> <td>{{item.itemName}}</td> <td>{{item.specValueDesc}}</td> </tr> <tr [ngClass]="{'hide': initNum >= skuItems.length}" id="showAndHide"> <td [showAndHideList]="initNum" [attr.colspan]="3" class="text-center text-gold text-cursor-pointer ">全部商品 <i class="icon-down"></i> </td> </tr> </tbody> </table> </div>`, styleUrls: ['./test.component.scss'] }) class TestComponent { initNum: any = 3; skuItems: any = [{ itemId: 10843001, itemName: "简约收纳盒", specValueDesc: "尺寸:大", skuId: 10924002 }, { itemId: 10843001, itemName: "简约收纳盒", specValueDesc: "尺寸:中", skuId: 10924003 }, { itemId: 10843001, itemName: "简约收纳盒", specValueDesc: "尺寸:小", skuId: 10924004 }, { itemId: 10843001, itemName: "女士水桶包", specValueDesc: "颜色:红", skuId: 10924005 }, { itemId: 10843001, itemName: "女士水桶包", specValueDesc: "颜色:黑", skuId: 10924006 }, { itemId: 10843001, itemName: "女士水桶包", specValueDesc: "颜色:白", skuId: 10924007 }, { itemId: 10843001, itemName: "男士T恤", specValueDesc: "尺寸:M", skuId: 10924008 }, { itemId: 10843001, itemName: "男士T恤", specValueDesc: "尺寸:L", skuId: 10924009 } ]; } describe('ShowAndHideListDirective', () => { let fixture; beforeEach(() => { fixture = TestBed.configureTestingModule({ declarations: [ShowAndHideListDirective, TestComponent] }).createComponent(TestComponent); }); // 测试表格原本的行数 it('should have 10 <tr>', () => { fixture.detectChanges(); const tr: any = fixture.nativeElement.querySelectorAll('tr'); expect(tr.length).toBe(10); }); // 测试表格隐藏的行数 it('should have 5 <tr> with hide', () => { fixture.detectChanges(); const tr: any = fixture.nativeElement.querySelectorAll('.hide'); expect(tr.length).toBe(5); }); // 测试点击展开全部按钮后隐藏的行数 it('should have 0 <tr> with hide', () => { const display = fixture.nativeElement.querySelector('#showAndHide'); display.click(); const tr: any = fixture.nativeElement.querySelectorAll('.hide'); expect(tr.length).toBe(0); }); });
service的测试 非http服务基本上不需要依赖Angular的测试工具集.按普通类对待就好,我们以下面的例子说明
eg .目录结构如下
|-- demo
|-- master.service.js
|-- master.service.spec.js
|-- value.service.js
|-- value.service.spec.ts
valueService 是一个简单的Service。包含有若干方法
// demo/value.service.js import { Injectable } from '@angular/core' import { Observable } from 'rxjs' @Injectable() export class ValueService { constructor() { } getValue() { return { data: 123 } } getValue1() { return Observable.of({ data: 456 }) } getValue2() { return new Promise((resolve, reject) => { resolve({ data: 789 }) }) } }
对ValueService的测试如下:你会发现,和我们平时写代码是一样的,只不过多了一个断言判断
import { ValueService } from './value.service' describe('MasterService', () => { let service: ValueService; beforeEach(() => { service = new ValueService(); }) it('#getValue should return real value', () => { expect(service.getValue()).toEqual({ data: 123 }) }) it('#getValue1 should return value from observable', async () => { service.getValue1().subscribe(value => { expect(value).toEqual({ data: 456 }) }) }) it('#getValue2 should return value from promise', async () => { service.getValue2().then(value => { expect(value).toEqual({ data: 789 }) }) }) })
MasterService 依赖了ValueService
// demo/master.service.js import { Injectable } from '@angular/core'; import { ValueService } from './value.service'; @Injectable() export class MasterService { constructor(private valueService: ValueService) { } getValue() { return this.valueService.getValue(); } }
对于有依赖的服务。有很多种测试方案:
- 如果依赖的服务相对简单,而且可靠,这时候可以直接引入真实依赖。做法和产品代码环境一样:
// demo/master.service.spec.js import { ValueService } from './value.service' import { MasterService } from './demo' describe('MasterService real', () => { let service: ValueService; let masterServie: MasterService; beforeEach(() => { service = new ValueService(); masterServie = new MasterService(service); }) it('#getValue should return real value', () => { expect(masterServie.getValue()).toEqual({ data: 123 }) }) })
- Spy:利用spyObj来替换真实的依赖,并借助testBed创建类并注入服务
import { ValueService } from './value.service' import { MasterService } from './master.service' import { TestBed } from '@angular/core/testing'; describe('', () => { let masterService: MasterService; let valueServiceSpy: jasmine.SpyObj<ValueService>; // 每个it测试前都会运行的前置条件 beforeEach(() => { // 创建一个spy对象 const spy = jasmine.createSpyObj('ValueService', ['getValue']); // 在TestBed提供并注入服务 TestBed.configureTestingModule({ providers: [ MasterService, { provide: ValueService, useValue: spy } ] }); // 获取服务 masterService = TestBed.get(MasterService); valueServiceSpy = TestBed.get(ValueService); }); it('#getValue should return stubbed value from a spy', () => { const stubValue = { data: 123 }; valueServiceSpy.getValue.and.returnValue(stubValue); // 数据 断言 expect(masterService.getValue()) .toBe(stubValue, 'service returned stub value'); // 调用次数 断言 expect(valueServiceSpy.getValue.calls.count()) .toBe(1, 'spy method was called once'); // 最近一次返回的数据 断言 expect(valueServiceSpy.getValue.calls.mostRecent().returnValue) .toBe(stubValue); }); })
如果我们要测的服务含有http请求,一般是采用mock的�方式,考虑到我们项目中的的�实际情况,这里的测试验证点在于:
1、是否发出了请求(代码是否执行)
2、是否发出了预期的请求(请求路径是否正确、请求方法是否正确)
3、是否发出了非预期的请求(有没有胡乱发送请求)
4、对返回code的处理是否符合预期(code = 200正确预期是否成立 ,code = 400,错误的预期是否成立)
eg.
以大制造家中的supplier-info.service.ts为例
import { Injectable } from '@angular/core' import { Ajax } from '@shark/shark-angularX' import { Observable } from 'rxjs' @Injectable() export class SupplierInfo { private getSupplierInfoUrl = '/login/getSupplierInfo.json' infoData: any = void 0 constructor( private ajax: Ajax ) { } getSupplierInfo(obj: Object = {}) { if (this.infoData) { return Observable.of(this.infoData); } else { return Observable.fromPromise<any>( this.ajax.get(this.getSupplierInfoUrl, obj).then(data => { this.infoData = data return this.infoData }) ) } } }
其测试用例的写法为:具体见注释
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { Common, Ajax, Cookie } from '@shark/shark-angularX' import { TestBed, tick } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { SupplierInfo } from './supplier-info.service'; describe('#SupplierInfo', () => { let httpClient: HttpClient; let httpTestingController: HttpTestingController; let supplierInfo: SupplierInfo; let ajax: Ajax; beforeEach(() => { TestBed.configureTestingModule({ // http test 依赖 HttpClientTestingModule imports: [HttpClientTestingModule], // SupplierInfo 依赖 Ajax,而Ajax依赖Common, Cookie,这些都必须在providers里注明 providers: [Ajax, Common, Cookie, SupplierInfo], schemas: [NO_ERRORS_SCHEMA] }); // 各服务获取 httpClient = TestBed.get(HttpClient); httpTestingController = TestBed.get(HttpTestingController); ajax = TestBed.get(Ajax); supplierInfo = TestBed.get(SupplierInfo); // 成功数据 this.expectedSupplierInfoSuccess = { code: 200, data: { } }; // 失败数据 this.expectedSupplierInfoFail = { code: 400, errorCode: 'xxxx' } }); afterEach(() => { // 验证没有发起过预期之外的请求 httpTestingController.verify(); }); it('SupplierInfo service should be defined', () => { expect(supplierInfo).toBeDefined; } ) it('infoData should be undefinded before the first call for getSupplierInfo', () => { expect(supplierInfo.infoData).toBeUndefined; } ) it('infoData should be the supplierInfo after the first call for getSupplierInfo', () => { supplierInfo.getSupplierInfo(); const req1 = httpTestingController.expectOne('/login/getSupplierInfo.json'); req1.flush(this.expectedSupplierInfoSuccess); supplierInfo.getSupplierInfo(); const req2 = httpTestingController.expectOne('/login/getSupplierInfo.json'); req2.flush(this.expectedSupplierInfoSuccess); expect(supplierInfo.infoData).toBeDefined; } ) it('getSupplierInfo should return value from observable when the response is success', () => { // 操作 supplierInfo.getSupplierInfo().subscribe(value => { expect(value).toEqual(this.expectedSupplierInfoSuccess, 'should return expected expectedSupplierInfoSuccess'), data => fail('should have successed with the data') }); // 请求路径验证 const req = httpTestingController.expectOne('/login/getSupplierInfo.json'); // 请求method验证 expect(req.request.method).toEqual('GET', 'the request method shoud be GET'); // 指定返回数据 resolve Observable req.flush(this.expectedSupplierInfoSuccess); }); it('getSupplierInfo should return value from observable when the response is fail', () => { // 操作 supplierInfo.getSupplierInfo().subscribe( data => fail('should have failed with the errorCode'), value => { expect(value).toEqual(this.expectedSupplierInfoFail, 'should return expected expectedSupplierInfoFail') }); // 请求路径验证 const req = httpTestingController.expectOne('/login/getSupplierInfo.json'); // 请求method验证 expect(req.request.method).toEqual('GET', 'the request method shoud be GET'); // 指定返回数据 resolve Observable req.flush(this.expectedSupplierInfoFail); }); });
- pipe
pipe 除了 @Pipe元数据和一个接口基本上不依赖angular,所以它的测试很简单
eg. 以一个大��制造家中的OrderBySkuId管道为例:
import { Pipe, PipeTransform } from '@angular/core'; /* * 按skuid排序 */ @Pipe({ name: 'orderBySkuId' }) export class OrderBySkuId implements PipeTransform { constructor( ) { } transform(list = []): any { if (list && list.length > 0) { return list.sort((a, b) => a.skuId - b.skuId); } else { return [] } } }
测试代码只需对其transform调用并断言即可:
import { OrderBySkuId } from './order-by-skuid.pipe'; import { each } from 'fpb'; describe('OrderBySkuIdPipe', () => { const orderBySkuIdPipe = new OrderBySkuId(); const skuList = [{ skuId: 3 }, { skuId: 4 }, { skuId: 1 }, { skuId: 2 }]; const expectedData = [{ skuId: 1 }, { skuId: 2 }, { skuId: 3 }, { skuId: 4 }]; const expectedToEqualEmptyArr = each((v) => { expect(orderBySkuIdPipe.transform(v)).toEqual([]); }) it('transforms null、 undefiend、""、[] to []', () => { expectedToEqualEmptyArr([null, undefined, '', []]) }); it('transforms list orderBy it\'s skuid property', () => { expect(orderBySkuIdPipe.transform(skuList)).toEqual(expectedData); }); })
常用测试技巧罗列:可参见 https://angular.cn/guide/testing
- 改写组件provider
- Dom测试封装(见angular测试指导 page类)
- spy的使用
- jasmine-marbles的使用