在测试自动化中,测试代码中不仅仅包含测试逻辑,还包含许多其他代码,比如URL拼接、html/xml解析、访问UI控件,等等。若把测试逻辑与这些无关代码混在一起,测试逻辑将会很难理解, 也不容易维护。本文会介绍如何用分层结构来解决测试自动化中遇到的这些问题。在这个分层结构中,测试自动化代码会被分成三层:(1)测试用例层,表达应用程序的测试逻辑。(2)领域层, 用业务领域术语来给待测系统建模,封装HTTP请求、浏览器控制、结果解析逻辑等,给测试用例层提供一个接口。(3)待测系统层,第2层构建在这一层之上。
问题
QA的工作包括设计测试用例、探索性测试(exploratory testing)及回归测试,等等。这些工作有的依靠QA的聪明才智, 而有的却只是重复劳动(例如回归测试)。随着系统中不断地加入新功能,回归测试这类工作耗费的时间也越来越多。
测试自动化可以解决这个问题。测试自动化后,重复性的劳动会由计算机来做,而测试用例都用计算机程序来表述, 因此QA可以从重复劳动中解脱出来,有更多的时间用在创造性的工作上来。
在测试自动化中,测试代码并不仅仅包含测试逻辑,也包含许多其他的支撑代码,例如URL拼接、HTML/XML解析、UI控件访问等。 例如要测试一个能接受不同搜索参数,并返回包含特定信息的XML(例如用户数据)的web服务,测试代码需要:
- 根据待测操作拼接URL
- 使用HTTP库发起HTTP请求
- 读取web服务器返回的信息,并解析数据
- 对比返回的数据与期望数据
- 有的测试自动化代码,会把URL拼接、HTML/XML解析、XPath表达式,和测试逻辑写在一起,通常在同一个类或方法中。
- 这种方法很容易入手,且很直观,因为其反映了测试人员手工测试的过程。但是这种方法存在一定的问题:
- 测试逻辑难以理解及修改。当测试逻辑与一大堆无关代码混在一起时,很难辨别出测试逻辑。 要添加新测试用例,通常需要重读这些支撑代码才能找到需要修改的代码。测试逻辑也会很难理解。
- 测试变得很脆弱。因为测试逻辑和html解析等支撑代码混在一起,待测系统和自动化测试直接的‘契约’若稍有变化, 自动化测试将无法运行。例如,若UI发生变化,比如把input元素挪到另一个div元素下, 或者改变某个UI元素的ID,所有相关的测试自动化代码都会受到影响。
- 维护开销大。一组完备的测试用例会对系统的某个部分进行多组测试,而每组测试间都会存在重复的代码。例如这些代码可能都要
- (1)根据待测操作拼装URL,(2)发出HTTP请求,(3)解析web服务器返回的信息,(4)比较实际结果及期望结果。因为在各个测试用例间存在 重复代码,如果这个过程发生任何改变,则需要修改各个测试用例的代码。
解决方法:
软件开发领域曾遇到过同样的问题,并找到了解决方法,即‘层次结构’(Layered Architecture)。引用《领域驱动设计--软件核心复杂性应对之道》('Domain-driven design: tackling complexity in the heart of software')一书:
“分层结构的价值在于每一层只关注于程序的特定方面。这使得每个方面的设计都很紧凑,也更容易理解。当然,使用层次结构的最重要原因是把各个重要的方面都分隔开。“
虽然测试自动化领域关注的是测试领域,但是所遇到的问题的本质却是一样的,因此可以应用相似的解决方案:
测试用例层 | 这一层包含所有(并只有)测试逻辑。有了下一层即领域层帮忙,测试逻辑可以很清晰、简洁地表达出来。不同用户故事、场景及边界条件 都构建领域层之上,区别只在于测试数据。 |
领域层 | 这一层封装了对待测系统的所有操作,例如URL拼接、XML或HTML解析,富客户端或浏览器的控制,等等。通过这一层包装, 待测系统可以以业务领域语言的形式供调用者使用,而非以xpath、sql或者html等技术“语言”形式。这层的目的在于提高抽象层次。 测试的目的是验证业务逻辑是否实现地正确。若测试能用业务领域的语言编写,那么测试目的就一目了然了。 |
待测系统层 | 即要测试的系统 |
测试用例层包含许多测试用例。这些测试用例都是基于领域层的。领域层用领域语言封装了待测系统。
领域层直接访问待测系统。
例子
假设我们要测试一个restful web服务。通过这个web服务,我们可以用电话作为关键字搜索客户信息。
要调用这个web服务,需要发起以下格式的HTTP请求:
http://{endpoint}/subscribers?telephoneNumber={telephoneNumber}
服务端返回的以竖线分割的数据包含客户的姓名、电话、地址及其他信息:
13120205504|ST|C|SQ|112|||FIRST|ST|W|Riverfront|BC|010|68930432|
测试这个服务的用例为:(1)用能精确匹配一个用户的电话作为关键字搜索,(2)用能精确匹配多个用户的电话作为关键字搜索,(3)用 不完整电话作为关键字搜索等。用例的完整程度完全取决于QA的想象能力。
对于每个测试用例,执行的数据基本上都一样:(1)拼装包含电话号码关键字的URL,(2)用HTTP库发出HTTP GET请求,(3)解析数据, (4)把真实值与期望值做比较。为了避免上面提到的问题,我们在这里采用分层结构:
测试用例层
这一层的具体实现方式与采用的测试框架有关。在这个例子中,我们采用C#及NBehave。
[Story]
public class SearchCustomerbyTelephoneNumberStory: TestBase
{
[Scenario]
public void SearchWithAPhoneNumberWhichHasAnExactMatch()
{
story.WithScenario("Search with a phone number which has a exact match")
.Given(AN_ACCOUNT_WITH_PHONE_NUMBER, "01068930432", EMPTY_ACTION)
.When(SEARCH_WITH, "01068930432",
SEARCH_WITH_ACTION)
.Then(ACCOUNT_INFORMATION_SHOULD_BE_RETURNED, "13120205504",
ACCOUNT_INFORMATION_SHOULD_BE_RETURNED_ACTION)
.Given(AN_ACCOUNT_WITH_PHONE_NUMBER, "01062736745")
.When(SEARCH_WITH, "01062736745")
.Then(ACCOUNT_INFORMATION_SHOULD_BE_RETURNED, "12666056628");
}
[Scenario]
public void SearchWithPartialPhoneNumber()
{
story.WithScenario("Search with partial phone number")
.Given(THREE_ACCOUNTS_WITH_PHONE_NUMBER_STARTS_WITH, "0106", EMPTY_ACTION)
.When(SEARCH_WITH, "0106", SEARCH_WITH_ACTION)
.Then(ACCOUNT_INFORMATION_SHOULD_BE_RETURNED, "13120205504",
ACCOUNT_INFORMATION_SHOULD_BE_RETURNED_ACTION)
.And(ACCOUNT_INFORMATION_SHOULD_BE_RETURNED, "12666056628")
.And(ACCOUNT_INFORMATION_SHOULD_BE_RETURNED, "17948552843");
}
[Scenario]
public void SearchWithAPhoneNumberWhichHasSeveralExactMatches() {...}
[Scenario]
public void SearchWithNonExistentPhoneNumbers() {...}
[Scenario]
public void SearchWithInvalidPhoneNumberValues() {...}
...
...
}
这些测试用例用C#写成,但是很接近英语,即使非技术人员也可以读懂。 (请参照Martin Fowler的 BusinessReadableDSL )。这样,其他的团队成员,特别是对领域更熟悉的业务人员,可以很容易的读懂测试用例, 因此也更可能指出测试中遗漏的案例及场景。
若采用支持以自然语言形式书写测试用例的框架(例如Ruby平台下的Cucumber)则会更好。
以"ACTION"结尾的变量为lambda表达式。他们是真正的测试逻辑。
SEARCH_WITH_ACTION会向web服务发出请求,并会解析返回的以竖线分割的数据。类CustomerService和Subscriber在领域层中,他们 会在多个测试中重复使用。
SEARCH_WITH_ACTION =
phoneNumber =>
{
subscribers = customerService.SearchWithTelephoneNumber(phoneNumber);
};
ACCOUNT_INFORMATION_SHOULD_BE_RETURNED_ACTION is for verifying the data
ACCOUNT_INFORMATION_SHOULD_BE_RETURNED_ACTION =
accountNumber =>
{
//Get expected subscriber from fixture
Subscriber expected = SubscriberFixture.Get(accountNumber);
CustomAssert.Contains(expected, subscribers);
};
领域层
CustomerService类以真实web服务的名称命名。在需求文档、日常对话、架构图以及代码中,都用这个名称来指代此web服务。 使用统一的名称,能除去二义,提高沟通效率。
public class CustomerService
{
public Subscriber SearchWithTelephoneNumber(string telephoneNumber)
{
string url =
string.Format(
"{0}/subscribers?telephoneNumber={1}",
endpoint, telephoneNumber);
//Send http request to web service, parse the xml returned,
//populate the subscriber object and etc.
return GetResponse(url);
}
...
}
Subscriber类建模了用户。比起用竖线分割的字符串,增加一层数据抽象,用对象表示返回的数据,能使 测试更容易理解(你应该不会偏好用pipedData[101]表示电话号码吧?)。
public class Subscriber
{
public string AccountNumber { get; set; }
public string FirstName { get; set; }
public string Surname { get; set; }
public string TelephoneNumber { get; set; }
...
}
有了这些领域模型,测试就能直接构建在这些对象上了。例如,可以如此验证所返回的用户名为'Bei':
Assert.AreEqual("Bei", subscriber.FirstName);
或者电话号码以'010'开始:
Assert.IsTrue(subscriber.TelephoneNumber.StartsWith("010"));
点击这里可以下载到样例代码。代码中演示了如何用分层架构组织测试自动化代码。 你可以在Visual Studio 2008中打开项目,也可以在命令行运行执行‘go.bat’来运行所有测试。 ‘go.bat’运行完后会将测试结果保存在‘artifacts’文件夹。源代码中包含三个项目。 名称以with ‘Client’的项目包含领域层。以‘Client.Spec’结尾的项目为领域层对应的 单元测试(TDD)。‘Stories’项目包含测试用例层。这份源代码由真实项目中来,并作了相应修改。某些 类返回了硬编码的值,是为了不访问真实的web服务。
这如何能解决问题?
- 问题:'测试逻辑难以理解和修改'。现在我们有了一个单独的层表示测试逻辑。这层构建在领域层之上,因此测试可以 很用简洁、紧凑的自然语言形式表述,因此阅读、理解、推理和修改测试用例的难度,更取决于编码人员的语言能力,而非编码水平。
- 问题:'测试很脆弱'。因为我们有一个单独的层把测试用例和待测系统隔离开,若待测系统有任何变化,只有此层 会受到影响。只要在此层做相应修改,构建于此层之上的测试用例仍然可以执行。
- 问题: '维护开销大'。因为有了领域层的封装,各个测试用例中不会再有重复代码。要做修改,也只需修改一处。此外, 因为领域模型直接针对待测系统建模,代码也跟容易理解和修改。
常见问题解答
问题:这个方法看起来有些复杂,必须要这么做吗?
回答:这主要取决于待测系统的规模和复杂程度。如果系统规模较小、业务逻辑相对简单,这个方法就过于笨重了。在这种情况下, 甚至连测试自动化都可能是浪费时间。如果只花几分钟时间就能手动测试整个系统,那还自动化干什么呢?若系统较为复杂, 把测试逻辑和支持代码混合在一起问题应该不大。而对业务逻辑复杂、规模庞大的系统(也就是说,大部分企业级应用) 我偏好这种方式。
问题:若采用这种结构,那么在开始‘真正’的测试前,需要投入一定时间搭建整个结构,会不会很浪费时间?
回答:这只是另外一种组织代码的方式。即使代码不按照这种方式组织,还是要写代码拼装URL、解析XML / HTML、验证测试结果。 采用这种结构,只需要把代码拆分到不同的类及方法中。此外,没有必要一次完成整个结构。可以根据当前的测试需要,逐步完成整个结构。
问题:完成这个结构需要相当的面向对象知识,并不是所有QA都可以做。
回答:实际上测试自动化并不只是QA的职责。项目中其他成员,包括开发人员,也可以参与。
开发人员有很强的编程功底,编写出的代码质量也相对较高,因此可以负责领域层。而QA擅长设计测试用例、找出各种边界测试条件,因此可以 负责测试用例层。
做自动化测试有段时间了,但也不够深入,接触过两个公司的自动化测试框架,随便记录一下对这两种自动化测试框架的一些看法。
关于框架:
A公司的自动化测试框架的设计比较简单,使用Tcl编写,面向流程,框架是把一个个复杂重复的流程进行了封装,提供给自动化测试工程师,工程师根据测试用例所描述的步骤,调用框架的接口,完成自动化脚本的开发。
其实这是一种比较初级的框架设计,需要自动化测试工程师根据项目模块,分析其特性,并使用框架提供的接口进行二次封装,才能高效进行测试脚本开发,否则,测试工程师会沉没在无穷尽的复制粘贴的操作中,而没有精力去关注业务和用例本身。
使用这种类型的框架,第一,需要自动化测试工程师对项目所使用的测试开发语言非常熟悉,因为需要进行二次开发,需要对预期结果和实际结果进行分析判断,如果对开发语言不够熟悉,那么自动化过程将会是一团糟,甚至失败;第二,需要有一定的程序设计能力,测试自动化,其目的就是为了提高效率,减少开销,同时会对脚本的执行时间进行要求,如果只是熟悉语言而没有程序设计能力,那么二次封装将会是灾难性的;第三,需要很强的分析问题的能力,在脚本开发过程中,会逐步的暴露测试框架的问题,需要测试人员能够快速准确的定位问题,是框架问题,脚本问题,用例问题,还是产品问题?
在我看来,使用这种框架必须具备以上三种素质,否则就无法很好地完成工作。
B公司的测试框架是基于AW的,就是action word,算是比较先进的关键字驱动的测试框架,框架分为三层,最底层是测试框架,主要完成的工作就是定义脚本开发所使用的关键字和结构,完成日志的打印,和整个测试的流程定义;中间的一层是支撑,它所实现的是屏蔽产品类型和产品版本的差异化,对上层提供业务接口,它针对不同类型的产品和不同版本的产品都有实现,每个产品包里包含有所有特性的模板和数据字典,以此来达到屏蔽差异化的目的,使上层业务逻辑相同的测试脚本能够在不同的产品类型和版本上都能够兼容,降低人力成本;最上一层是测试脚本,根据框架定义的关键字和结构实现业务逻辑,根据测试用例调用相应的支撑和测试框架接口,执行之后生成测试日志。
这种框架的封装程度很高,允许自动化测试工程师的能力水平呈金字塔形分布,塔尖是框架设计维护人员,中间是支撑开发人员,最底下是脚本开发人员。因为自动化脚本开发和调试需要大量的人力,这种形状的人力配置结构能够有效的降低人力成本,提高开发效率。脚本开发人员只需要熟悉业务,了解开发语言即可,加上较强的分析能力,足以高效的进行脚本开发。
对比:
横向比较一下两种自动化框架,A公司的框架需要自动化测试工程师普遍具备较高的素质,而B公司允许工程师的水平存在层次差异;完成相同数量的测试,B公司显然会节省更多的人力;脚本维护方面,B公司的层次化框架也优于A公司的流程化测试框架,A公司的框架实现的测试脚本,存在很大的可能性出现“牵一发而动全身”的情况,B公司的层次结构,在保证接口不变的情况下,只需要相应的责任人进行维护即可。