ATDD: Acceptance Test Driven Development(验收测试驱动开发)
TDD 只是开发人员的职责,通过单元测试用例来驱动功能代码的实现。在准备实施一个功能或特性之前,首先团队需要定义出期望的质量标准和验收细则,以明确而且达成共识的验收测试计划(包含一系列测试场景)来驱动开发人员的TDD实践和测试人员的测试脚本开发。面向开发人员,强调如何实现系统以及如何检验。
TDD: Test-driven development (测试驱动开发)
是一种使用自动化单元测试来推动软件设计并强制依赖关系解耦的技术。使用这种做法的结果是一套全面的单元测试,可随时运行,以提供软件可以正常工作的反馈。
测试驱动开发是敏捷开发中的一项核心实践和技术,也是一种设计方法论。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。TDD的基本思路就是通过测试来推动整个开发的进行,但测试驱动开发并不只是单纯的测试工作,而是把需求分析,设计,质量控制量化的过程。TDD首先考虑使用需求(对象、功能、过程、接口等),主要是编写测试用例框架对功能的过程和接口进行设计,而测试框架可以持续进行验证。。
BDD:Behavior-Driven Development (行为驱动开发)
行为驱动开发是一种敏捷软件开发的技术,它鼓励软件项目中的开发者、QA和非技术人员或商业参与者之间的协作。主要是从用户的需求出发,强调系统行为。BDD最初是由Dan North在2003年命名,它包括验收测试和客户测试驱动等的极限编程的实践,作为对测试驱动开发的回应。
DDD:领域驱动开发(Domain Drive Design)
DDD指的是Domain Drive Design,也就是领域驱动开发,DDD实际上也是建立在这个基础之上,因为它关注的是Service层的设计,着重于业务的实现,将分析和设计结合起来,不再使他们处于分裂的状态,这对于我们正确完整的实现客户的需求,以及建立一个具有业务伸缩性的模型。
Unit Test(单元测试)
对正式的项目进行单元测试是必须的,如果选择使用TDD(测试驱动开发)方法,则无关紧要,否则使用它将会产生很多好处。
在本文中,我们首先简单地提到单元测试的好处,然后我们将创建一个Angular单元测试的完整示例,使用jasmine
和karma
。
Karma是Testacular的新名字,在2012年google开源了Testacular,2013年Testacular改名为Karma。Karma是一个让人感到非常神秘的名字,表示佛教中的缘分,因果报应,比Cassandra这种名字更让人猜不透!
Karma是一个基于Node.js的JavaScript测试执行过程管理工具(Test Runner)。该工具可用于测试所有主流Web浏览器,也可集成到CI(Continuous integration)工具,也可和其他代码编辑器一起使用。这个测试工具的一个强大特性就是,它可以监控(Watch)文件的变化,然后自行执行,通过console.log显示测试结果。
TDD(Test Driven Development)测试驱动开发,是敏捷开发中提出的最佳实践之一。jasmine很有意思的提出了BDD(Behavior Driven Development)行为驱动开发.
测试驱动开发,对软件质量起到了规范性的控制。未写实现,先写测试,一度成为Java领域研发的圣经。随着Javascript兴起,功能越来越多,代码量越来越大,开发人员素质相差悬殊,真的有必要建立对代码的规范性控制。jasmine就是为团队合作而生。
Jasmine是一个用来编写Javascript测试的框架,它不依赖于任何其它的javascript框架,也不需要对DOM。它有拥有灵巧而明确的语法可以让你轻松的编写测试代码。
jasmine的结构很简单:
describe("A suite", function() {
var foo;
beforeEach(function() {
foo = 0;
foo += 1;
});
afterEach(function() {
foo = 0;
});
it("contains spec with an expectation", function() {
expect(true).toBe(true);
});
});
每个测试都在一个测试集中运行,Suite就是一个测试集,用describe函数封装。 Spec表示每个测试用例,用it函数封装。通过expect函数,作为程序断言来判断相等关系。setup过程用beforeEach函数封装,tearDown过程用afterEach封装。
我们先来看看我认为在解决方案中使用单元测试的主要原因…
改进实现的设计
开始编写一个功能而不给设计带来太多的思考是开发人员非常常见的错误。使用单元测试将强制思考并重新考虑设计,如果您使用TDD,则影响会更大。
允许重构
既然你已经有测试确保你所有的东西都能按预期工作,你可以很容易地添加对代码的修改,确保你没有添加任何错误。
添加新功能而不会破坏任何内容
当您添加新功能时,您可以运行测试以确保您不会破坏应用程序的任何其他部分。
还有更多,但这三个在任何项目上都是如此巨大的胜利,对于我来说,这些赢利是封闭式的。但如果你不相信,让我们再提几个。
你可以说他们所有的好处都是以很高的成本来实现的,但是这完全是错误的。所有使用单元测试可能花费的时间与以后在您引入新功能或进行任何重构时要节省的时间相比将会很小。花在解决错误上的时间要比没有使用单元测试时大大缩短。
我们将创建一个使用Angular,Jasmine和Karma的应用程序的小而完整的例子。
这些是我们要谈论的一些事情:
正如Angular团队建议我们要用Angular cli来创建我们的应用程序。通过这样做,jasmine和karma的配置可以帮我们解决,比较方便。
安装angular-cli并创建一个新项目:
当你创建项目时,所有的依赖关系都会安装,包括你需要创建测试的所有东西。
"@types/jasmine": "~2.8.6",
"@types/jasminewd2": "~2.0.3",
"@types/node": "~8.9.4",
"codelyzer": "~4.2.1",
"jasmine-core": "~2.99.1",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~1.7.1",
"karma-chrome-launcher": "~2.2.0",
"karma-coverage-istanbul-reporter": "~1.4.2",
"karma-jasmine": "~1.1.1",
"karma-jasmine-html-reporter": "^0.2.2",
要运行测试,只需运行命令“ng test”。该命令将执行测试,打开浏览器,显示控制台和浏览器报告,同样重要的是,将测试执行保留为监视模式。也就是当我们修改过后,可以自动更新测试结果。
ng test
提示:如果想要终止,需要在终端内按CTRL+C
让我们来看看由angular-cli创建的karma配置文件。
// 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', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, '../coverage'),
reports: ['html', 'lcovonly'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};
你大概可以猜到这些配置属性的大部分用途,但我们来看看其中的一些。
karma的angular-cli配置使用文件“test.ts”作为应用程序测试的入口点。我们来看看这个文件;
// This file is required by karma.conf.js and loads recursively all the .spec and framework files
import 'zone.js/dist/zone-testing';
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);
你可能永远不需要改变这个文件,但是有时候还是会更改的,比如:某一个spec文件排除测试等。
我们来创建我们的第一个测试。修改app.component.ts。这个组件只有一个属性“text”,其值为“Angular Unit Testing”,它是在HTML中的“h1”标记中呈现的,它还包含路由根元素和一些路由链接。让我们创建一个测试文件来检查组件是否实际具有该属性,并且实际上是在HTML中呈现的。
app.component.html
文件
<h1>{{text}}h1>
<router-outlet>router-outlet>
app.component.ts
文件
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
text = 'Angular Unit Testing';
}
app.component.spec.ts
文件
import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
it(`should have as title 'app'`, async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.text).toEqual('Angular Unit Testing');
}));
it('should render title in a h1 tag', async(() => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('h1').textContent).toContain('Welcome to Angular Unit Testing!');
}));
});
此时执行:
ng test
或
npm test
ng test的常用参数.
- –code-coverage -cc 代码覆盖率报告, 默认这个是不开启的, 因为生成报告的速度还是比较慢的.
- –colors 输出结果使用各种颜色 默认开启
- –single-run -sr 执行测试, 但是不检测文件变化 默认不开启
- –progress 把测试的过程输出到控制台 默认开启
- –sourcemaps -sm 生成sourcemaps 默认开启
- –watch -w 运行测试一次, 并且检测变化 默认开启
在弹出的chrome浏览器窗口中显示:
我们详细介绍一下这个测试代码
导入测试文件的所有依赖项
这里要注意,你在组件内使用的依赖,这里面同样需要导入,否则会无法运行。
使用describe
开始我们的测试
describe是一个函数,Jasmine 就是使用 describe 全局函数来测试的。
declare function describe(description: string, specDefinitions: () => void): void;
表示分组类似测试套,也就是一组测试用例,支持description
嵌套。
例子:
describe('测试显示/隐藏筛选条件', ()=>{
})
describe('demo test', () => {
const VALUE = true;
it('should be true', () => {
expect(VALUE).toBe(VALUE);
})
});
如果有很多需要测试的,可以多个it:
describe('AppComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
}));
it('should create the app', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
});
断言,使用 expect
全局函数来表示,只接收一个代表要测试的实际值,并且需要与 Matcher
代表期望值。
代码中有3个it
第一个为异步测试app是否true或false
如果app是0;两次取反当然是false;
如果app是null;两次取反是false;
如果app是undefined;两次取法是false;
其余的,两次取反是true;
第二个为异步测试app是否有text属性,并且判断值是否和预期相同
h1
标签中的显示值为预期值ng g c contact
<div>
{{text}}
div>
<form id="contact-form" [formGroup]="contactForm" (ngSubmit)="onSubmit()" novalidate>
<div class="form-group">
<label class="center-block">Name:
<input class="form-control" formControlName="name">
label>
<label class="center-block">Email:
<input class="form-control" formControlName="email">
label>
<label class="center-block">Text:
<input class="form-control" formControlName="text">
label>
div>
<button type="submit"
[disabled]="!contactForm.valid" class="btn btn-success">Savebutton>
form>
这很简单,针对代码不做任何解释了。
contact.component.ts
文件import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
@Component({
selector: 'app-contact',
templateUrl: './contact.component.html',
styleUrls: ['./contact.component.css']
})
export class ContactComponent {
text = 'contact page';
contactForm: FormGroup;
contact = {
name: '',
email: '',
text: ''
};
submitted = false;
constructor() {
this.createForm();
}
createForm(): void {
this.contactForm = new FormGroup({
'name': new FormControl(this.contact.name, [
Validators.required,
Validators.minLength(4)
]),
'email': new FormControl(this.contact.email, [
Validators.required,
Validators.email
]),
'text': new FormControl(this.contact.text, Validators.required)
});
}
onSubmit(): void {
this.submitted = true;
}
}
这个组件也很容易理解。 onSubmit提交函数只是将提交的属性更改为true。
import { ContactComponent } from './contact/contact.component';
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{
path: '',
redirectTo: 'contact',
pathMatch: 'full'
},
{
path: 'contact',
component: ContactComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
此时终端执行:
npm start
contact.component.spec.ts
import { BrowserModule, By } from '@angular/platform-browser';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ContactComponent } from './contact.component';
import { DebugElement } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
describe('ContactComponent', () => {
let comp: ContactComponent;
let fixture: ComponentFixture;
let de: DebugElement;
let el: HTMLElement;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
ContactComponent
],
imports: [
BrowserModule,
FormsModule,
ReactiveFormsModule
]
})
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(ContactComponent);
comp = fixture.componentInstance;
de = fixture.debugElement.query(By.css('form'));
el = de.nativeElement;
});
}));
it(`should have as text 'contact page'`, async(() => {
expect(comp.text).toEqual('contact page');
}));
it('should set submitted to true', async(() => {
comp.onSubmit(); // 直接内部调用onSubmit函数, submitted被更改为true
expect(comp.submitted).toBeTruthy();
}));
it('form call the onSubmit method', async(() => {
fixture.detectChanges();
spyOn(comp, 'onSubmit');
el = fixture.debugElement.query(By.css('button')).nativeElement;
el.click(); // 模拟在html界面上点击onSubmit,此时是不能被点击的,因为没有输入,所以次数应该是0
expect(comp.onSubmit).toHaveBeenCalledTimes(0);
}));
it('form should be invalid', async(() => {
comp.contactForm.controls['email'].setValue('');
comp.contactForm.controls['name'].setValue('');
comp.contactForm.controls['text'].setValue('');
expect(comp.contactForm.valid).toBeFalsy();
}));
it('form should be vaild', async(() => {
comp.contactForm.controls['email'].setValue('[email protected]');
comp.contactForm.controls['name'].setValue('aada');
comp.contactForm.controls['text'].setValue('text');
expect(comp.contactForm.valid).toBeTruthy();
}));
}) ;
此时执行:
ng test
我们来分析一下,这个测试文件做了哪些东西?
BrowserModule
,FormsModule
,ReactiveFormsModule
form
导入进来onSubmit
函数调用小提示
- detectChanges
在测试中的Angular变化检测。每个测试程序都通过调用fixture.detectChanges()来通知Angular执行变化检测。
- By
By类是Angular测试工具之一,它生成有用的predicate。 它的By.css静态方法产生标准CSS选择器 predicate,与JQuery选择器相同的方式过滤。
当你要测试一个带有服务的组件时,就像我们已经看到的那样,你需要将提供者添加到在“beforeEach”中创建的测试模块。事情是,你可能不想使用实际的服务,而是一个模拟版本,所以让我们看看如何做到这一点……
ng g s app
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class AppService {
constructor() { }
getInfo(): string {
return 'test service';
}
}
import { AppService } from './app.service';
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
text = 'Angular Unit Testing';
info: string;
constructor(private service: AppService) {
this.info = this.service.getInfo();
}
}
AppService
服务。...
providers: [
AppService
]
...
it('should have as info test service', async(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.info).toEqual('test service');
}));
import { TestBed, inject, async } from '@angular/core/testing';
import { AppService } from './app.service';
describe('AppService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [AppService]
});
});
it('should be created', inject([AppService], (service: AppService) => {
expect(service).toBeTruthy();
}));
it('should getInfo test service', inject([AppService], (service: AppService) => {
expect(service.getInfo()).toEqual('test service');
}));
});
此时执行:
ng test
测试全部通过。
小提示
有些时候我们希望不是异步的,这时需要使用takeAsync函数,fakeAsync最重要的好处是测试程序看起来像同步的。
it('should show quote after getQuote promise (fakeAsync)', fakeAsync(() => {
fixture.detectChanges();
tick(); // wait for async getQuote
fixture.detectChanges(); // update view with quote
expect(el.textContent).toBe(testQuote);
}));
Jasmine 提供非常丰富的API,一些常用的Matchers:
toBe()
等同 ===
toNotBe()
等同 !==
toBeDefined()
等同 !== undefined
toBeUndefined()
等同 === undefined
toBeNull()
等同 === null
toBeTruthy()
等同 !!obj
toBeFalsy()
等同 !obj
toBeLessThan()
等同 <
toBeGreaterThan()
等同 >
toEqual()
相当于 ==
toNotEqual()
相当于 !=
toContain()
相当于 indexOf
toBeCloseTo()
数值比较时定义精度,先四舍五入后再比较。toHaveBeenCalled()
检查function是否被调用过toHaveBeenCalledWith()
检查传入参数是否被作为参数调用过toMatch()
等同 new RegExp().test()
toNotMatch()
等同 !new RegExp().test()
toThrow()
检查function是否会抛出一个错误而这些API之前用 not 来表示负值的判断。
expect(true).not.toBe(false);
这些Matchers几乎可以满足我们日常需求,当然你也可以定制自己的Matcher来实现特殊需求。
在实际的组件测试中发现组件往往依赖于服务。而服务又依赖于外部资源如http交互、本地资源等。为了屏蔽外部依赖方便组件的测试,可以对服务进行mock。对于服务的mock方式有两种:伪造服务实例(提供服务复制品)、刺探真实服务。这两种方式都能够达到mock的效果,我们可以挑选一种最适合自己当前测试文件的测试方式来进行测试。
第一步:编写服务的mock类
class TaskMonitorStubService extends TaskMonitorService {
public queryTaskList(request: ViewTaskRequest): Observable {
return request.code === -1 ? Observable.of(runningTaskResponse): Observable.of(finishedTashResponse)
}
}
第二步:在configureTestingModule用Mock的服务替换真实的服务
TestBed.configureTestingModule({
imports: [
HttpModule,
TaskMonitorModule
],
Providers: [
{provide: TaskMonitorService, useClass: TaskMonitorStubService}
]
})
刺探真实服务
Angular的服务都是通过注入器注入到系统中的,同样我们可以从根TestBed获取到注入服务的实例,然后结合刺探(Spy)对真实的服务的方法进行替换.
let taskMonitorService: TaskMonitorService = TestBe.get(TaskMonitorService);
spyOn(taskMonitorService, 'queryTaskList').and.returnValue(Observable.of(runningTaskResponse));