支持多编程语言的自动测试系统
源问题地址:http://www.cnblogs.com/xinz/archive/2011/03/20/1989662.html
问题背景
在一座高楼中,我们需要设计一个电梯系统。这个电梯系统中的电梯数量以及电梯的各种参数都是可配置的,同时,电梯的运行也会存在一些限制:比如有些电梯只能从1层运行到10层,不能到更高的20层。已知了电梯的配置后,我们就可以让这个多电梯系统按照我们设计的调度算法去运行,这个调度算法要尽可能高效地运送乘客们,并且调度必须考虑电梯的限制条件。例如,当一辆电梯就要超载的时候,在梯内乘客走出前,调度器不可再安排乘客进入。
电梯系统是需要学生好好构思设计一番的,但实际情况中,电梯系统的测试同样是个需要好好思考的问题。本文所要解决的主要问题就是怎样为电梯项目构建一个自动化测试系统,所以本文的读者主要是需要测试学生项目的助教们,希望本文能带给你们一些灵感:)。
问题分析
在设计开始之前,我们先来分析一下不同角色对于这个测试程序的不同需求。从助教的角度来看,他们可能比较希望测试程序可以达到这三点标准:
- 自动评测。测试系统应当能做到自动测试学生项目,并能记录学生得到的分数。
- 保证公平。测试系统不能被伪造的电梯项目所欺骗,同时要甄别学生的作弊行为,并能给出一定的判断依据。
- 部署简单且方便。如果测试系统是通过让学生上传代码的方式进行测试,那它应当能够使得助教在部署时非常方便;如果是通过让学生下载测试程序的方式进行测试,那么该测试系统应当能简单方便地部署在学生的计算机上。
而从学生的角度考虑,他们则更可能希望测试可以做到这样四点:
- 直观。在测试完成后,学生能直观地看到自己的测试结果。
- 快速。运行测试的时间不宜过长,学生能快速得到测试反馈。
- 小开销。测试程序尽量不要为学生的开发引入额外的负担。
- 多语言支持。考虑到学生们擅长的语言不尽相同,为了方便他们的开发,测试程序最好不会对学生所使用的编程语言有过多限制。
综合考虑双方的需求,本文为电梯系统设计了一套语言无关的自动测试框架,希望它能完美解决问题中提到的几个挑战,同时尽可能地满足两方需求。实际上,这个测试框架并非是与电梯项目紧密耦合的。测试者只要定义好数据接口,也完全可以在自己的测试工程外套上这样一层语言无关的自动测试框架,以实现上述需求。
传统测试
要想搭建一个自动的测试框架,首先应分析一下传统测试该是怎样的一个流程。仔细分析一下,其实电梯项目需要外界提供的数据只有这么两项:电梯的配置信息与乘客的请求数据。
在传统测试的流程中,助教可以把电梯的配置信息存储在文件中,要求学生的电梯项目读取固定文件加载该配置信息;也可以将乘客的请求数据也存在一个文件中,约定好数据格式后,要求学生的电梯项目按行读取解析并调度。助教在测试时只需要提供这两个文件,使用相应的运行环境运行学生的可执行文件即可。当然,助教也可以要求学生的电梯项目在运行中也可以交互,助教把乘客请求数据从交互界面逐个传给电梯项目,这样对实际情况的建模更加真实,但也就要耗费更长的测试时间。
上述流程存在着怎样的缺陷呢?从上面的测试流程中我们不难发现:所有项目都是在助教的计算机环境中进行测试的,所以助教需在自己的电脑上安装所有电梯项目的运行环境。如果助教希望能支持上面我们提到的学生需求中的【多语言特性,那么他将可能要为此付出不一般的代价:安装若干种不同编程语言不同版本的运行环境,这将给助教的测试带来巨大的困难。举个例子,去年北航软工结对编程环境要求做一个带UI的项目,一共有10个项目需要测试,编程语言限制在C++/C#,但因为各个组在开发UI时使用了不同的依赖库,而他们没有把库上传到源代码仓库中,导致我在测试时非常苦恼。
同时,由于传统测试是助教收集到所有项目后才会统一测试,只有在评分的环节学生才能知道自己的项目问题所在,学生在项目开发的过程中无法得到任何反馈。若是需求明确的小程序,只在评分环节进行一次测试是合理的,因为要锻炼学生的测试能力。但若是像电梯项目这样的一个复杂系统,教师团队应当在学生开发的过程中提供一些反馈,让他们确认对项目的需求理解无误。
自动测试
自动测试需要克服上述传统测试带来的缺点,这就要求它既要能支持多编程语言的测试,又要能够作为服务供学生多次测试,还得在测试结束后给出一定反馈。其难点主要也就是这三点,下面让我们来挨个解决,第一个问题就是:如何让自动测试程序支持多编程语言呢?
多语言支持
我们使用C#编写了电梯的测试程序,现在需要它能测试其他语言编写的项目。考虑到不同语言编译后产生的可执行文件的格式不同,运行所需的环境也不同,如果想让这个测试程序对于所有的语言都通用,就不能直接在编程语言层面支持,而要通过一些更加底层的组件如进程,系统或网络来支持。
Web API
首先考虑的测试框架是B/S架构(浏览器/服务器模型):助教把测试框架部署在服务器上,利用Web API作为信息传输的接口,学生的程序通过不断地向服务器发包以获取数据,助教则可以在测试程序记录一些日志来记录学生程序的正确性。使用Web API方式测试的好处在于:
- 能够在测试程序记录接收到数据包,自动验证学生程序的正确性。
- 对被测试程序而言,只要求其能够收发数据包即可,对运行平台和编程语言没有具体限制。
但是Web API因其公开性也有几个不可避免的缺点:
- 相比传统的测试程序,部署Web应用的时间成本与开销较大,尤其是需要测试框架安装一些额外依赖。
- 自动验证存在漏洞,如果只从被测试程序发送的数据包中获取作者信息,有可能会有“移花接木”的情况出现。例如,A同学将数据包中的作者信息改为B,就可以轻轻松松地帮B同学交作业。
- 考虑到程序本身是给高校教师使用的,一般会部署在内网服务器上以供学生访问。部署在内网服务器上,缺乏有效的安全保护措施,很容易被死程序(比如不断发送数据包的程序)攻击,使得其他数据包无法及时得到响应。
Socket
既然B/S架构的方式行不通,我们考虑使用C/S的方式来实现。C/S的方式其实就是将学生的程序作为电梯程序,助教给学生下发一个可执行文件——也就是我们的测试程序作为测试程序,两个程序通过进程间通信的方式进行交互以完成测试。
上面我们分析了公开性带来的一些弊端,为了解决这个问题,我们需要让测试框架在学生的计算机上运行。同时,我们不希望开销和依赖过多,所以需要采用一个简单可行,在原有测试程序基础上扩展成本较小的通信方案,而基于Socket的多进程通信机制正好符合我们的要求:Socket属于基础网络通信,一般的编程语言基本上都有用于Socket连接与通信的基础库函数,不会为开发者带来额外的依赖,开发的成本也比较小。
服务
使用Socket可以解决多编程语言支持的问题,下面来解决第二个问题:如何把电梯测试程序作为服务启动,以供学生多次测试呢?结合上文所说的Socket通信方案,解决第二个问题其实就等价于解决两个子问题:
- 如何编程Socket通信,让其可以发布成服务?
- 如何设计数据接口,让测试程序与电梯程序进行数据交互?
服务的发布
要想让测试程序作为一个服务运行在学生的电脑上,我们需要给学生下发一个这样的可执行程序:当学生在本地启动该程序后,它会监听本地网络的特定端口,对不同的请求做出不同的响应。整个测试程序的逻辑可用下述伪代码描述:
ListenOnPort();//监听特定端口
while(true){
AcceptDataFromClient();//电梯项目发送数据
RecordDataAndGenResponse();//记录电梯项目本次发送的数据,并根据本次数据产生返回数据
ResponseToClient();//将产生的响应数据返回给电梯项目
}
这里我们采用C#编程电梯测试程序,编译得到的可执行文件可以直接在Windows系统中运行。
监听端口
Socket通信中测试程序监听部分的关键代码如下
int port = 8989;
IPAddress localAddr = IPAddress.Parse("127.0.0.1");
var tcpListener = new TcpListener(localAddr, port);
Console.WriteLine($"Server >> Start Listening in {tcpListener.LocalEndpoint}");
tcpListener.Start();
接受电梯程序数据
var buffer = new byte[8192];
var tcpClient = tcpListener.AcceptTcpClient();
using (var networkStream = tcpClient.GetStream())
{
//数据未准备好,切换到其他线程
while (!networkStream.DataAvailable)
{
Thread.Sleep(100);
}
//开始读取Stream中的内容
int byteCount = buffer.Length;
StringBuilder builder = new StringBuilder();
while (byteCount == buffer.Length)
{
byteCount = networkStream.Read(buffer, 0, buffer.Length);
builder.Append(Encoding.UTF8.GetString(buffer, 0, byteCount));
Array.Clear(buffer, 0, buffer.Length);
}
//request是电梯程序发送的数据
var request = builder.ToString();
}
响应电梯程序数据
//调用自己的函数产生对电梯程序的应答,返回的是字符串类型
string serverResponse = GenerateResponse();
byte[] serverResponseBytes = Encoding.UTF8.GetBytes(serverResponse);
//将数据回复给电梯程序
networkStream.Write(serverResponseBytes, 0, serverResponseBytes.Length);
networkStream.Flush();
Console.WriteLine($"Server >> Response : {serverResponse}");
Array.Clear(buffer, 0, buffer.Length);
本文假设了学生使用的是Windows系统,如果想让测试程序也支持Linux/MacOS系统,可以用C++重写电梯测试程序,将GCC++编译后的可执行程序下发。
在本项目中,测试程序的流程如下所示:
- 测试程序监听本地的8989端口,等待着电梯程序发送请求数据。
- 电梯程序按照约定的数据接口向本地8989端口发送请求数据,接收返回的响应数据。
- 返回数据即电梯项目所需要的数据,包括电梯配置与乘客请求。
数据接口设计
在搞定服务的发布后,就需要考虑测试程序与电梯程序之间的数据接口了。整个测试都是由测试程序发出乘客请求,以驱动电梯程序的调度器运转,这也就意味着:测试程序既要给出足够的信息以驱动调度器,但又必须隐藏一部分以免超前调度。
这里所说的超前调度指的是电梯在时刻t时已经知晓时刻t+n时的乘客信息,并根据该信息调整自己的调度策略。这种超前调度算法跟操作系统页置换中的最优置换算法类似,只是一种理想算法,并不能用于实际调度。
时钟设计
为了达到不让电梯调度器知晓未来的目的,测试程序需要将乘客数据按照时间顺序分开发送。
物理时钟
既然是按照时间顺序,那我们自然就会有这样的想法:用物理时钟来驱动测试程序,当物理时钟走到某条乘客请求的发送时刻时,测试程序主动向电梯程序发送乘客数据。
这种依赖于物理时钟的做法简单又直接,但在实际测试中,它却并非是一个好的数据接口方案。使用物理时钟来驱动测试程序发送请求,不论时钟的基本单位有多小,测试程序在大部分时间内都是“空转”的。同时,与物理时钟的绑定导致测试所消耗的时间正比于测试数据中的时间跨度,大大延迟了测试的时长。
逻辑时钟
所以在本项目中,我们的测试程序维护一个全局逻辑时钟,电梯程序可以从测试程序返回的数据中找到下一个有效的逻辑时刻,并以此作为下一次请求的参数。这里所说的有效指的是存在乘客请求的时刻,在测试程序中我们将跳过不存在乘客请求的时间段以提升测试的效率。同时,由于请求是由电梯程序主动发起的,所以测试程序不会限制电梯程序的时钟实现。电梯程序使用逻辑时钟或物理时钟均可,只要与测试程序的逻辑时钟存在映射即可。
接口约定
在确定测试程序使用逻辑时钟的方法响应请求数据后,我们就要具体定义电梯程序发送的数据包格式与测试程序的响应格式。根据上文中的分析我们知道电梯程序需要两种数据:电梯的配置与乘客的请求。在本项目中,电梯的配置由学生自行指定,但因评分需求,电梯程序需要把电梯配置发送给测试程序,它的发送也作为测试的开始。数据接口涉及到请求主要分为两种:一种是配置电梯的请求,另一种是获取某时刻t时乘客数据的请求。
数据的传输需要序列化协议约束,本项目采用的是JSON序列化协议。一个简单的JSON数据包示例如下所示,其中
{}
是字典,每个键都映射到一个值[]
是列表,存储了若干个值
{
"employees": [
{
"firstName":"Bill" ,
"lastName":"Gates"
},
{
"firstName":"George" ,
"lastName":"Bush"
},
{
"firstName":"Thomas" ,
"lastName":"Carter"
}
]
}
电梯配置接口
在测试程序正式开始测试前,电梯程序需要将其使用的电梯配置以下述数据格式发送给测试程序。
{
"User":"Student",
"Elevators":[
{
"ID": 1,
"Capability": 1500,
"FloorMax": 25,
"FloorHeight": 10,
"InitHeight": 20
}
],
"TaskID":"1",
"Operation":"CONFIG"
}
成功配置后,测试程序将返回Config OK
的消息。
获取请求接口
在电梯程序配置好电梯信息后,即可按照下面的数据格式发送请求来获取乘客的请求数据。
- Tick 是指电梯程序需要时刻为25时的乘客请求数据。测试程序默认第一个有效时刻为0,所以在初次使用这一接口时,电梯程序需要请求的时刻为0。
- FinishRequests 是指电梯程序从上一个有效时刻至时刻25期间所完成的请求。
- Operation 是固定值,它充当了Web API中路由的角色,告知测试程序电梯程序所需要调用的接口。
{
"Tick":25,
"FinishRequests":[
{
"PassengerName":"Sen_1",
"FinishTime":20,
"ElevatorID":1
}
],
"Operation":"GETREQS"
}
当电梯程序成功发送请求后,测试程序会返回两个参数
- NextTick 是下一个有效时刻。如果当前已经是最后一个有效时刻,NextTick会被置为 -1。
- Passengers 是发送请求中Tick时刻的乘客请求,以列表形式返回,列表中的每一个元素都是一个请求
- Sen_1 是乘客姓名
- 15 是乘客出发楼层
- 12 是乘客目的楼层
- 60 是乘客的体重
{
"NextTick":-1,
"Passengers":[
"Sen_1,15,12,60",
]
}
反馈与自动评分
那么现在剩下最后一个问题需要解决:测试程序怎样给出反馈并自动评分?同样地,我们把这个问题拆分成两个问题来分别解决:给出反馈与自动评分。
结果反馈
在本项目中,结果的反馈直接放置在最后一次请求的响应数据包中。当电梯程序向测试程序发送的【获取乘客数据】请求中NextTick参数设置为-1,即意味着测试开始运行,测试程序会将测试结果打包成如下格式返回给电梯程序。
{
"Basic":100,
"Performance":100,
"User":"Student"
}
自动评分
那么测试程序如何实现对电梯程序的自动评分呢?在电梯项目中,本文把评分分为两点,一是对电梯运行正确性的评价,二是对电梯运行效率的评价。
对于电梯运行正确性的判断,本项目采取以下做法:
- 在数据交互完成后,测试程序可以收集到所有请求完成的时间与所在的电梯。
- 在得到这样的完成列表后,我们可以通过每个请求完成时的状态逆向推出每个电梯各个时刻的状态。
- 遍历逻辑时刻的最小单位,并检查每一个时刻电梯内的人数是否超过限制,同时检查由不同请求推断出的电梯状态是否产生矛盾。
举个例子,比如由请求【1】的完成状态我们推断出电梯【2】在时刻20在楼层3,而由请求【2】的完成状态却推断出电梯【2】在时刻20时在楼层5,这样就产生了矛盾,即可说明电梯程序的实现是有误的。
对于电梯运行效率的判断,由于电梯的配置参数不固定性,以及学生的调度算法的不固定性,故本项目采取对比测试的方法评价。我们在测试程序中实现了一个基本调度器,用于仿真一般情况下电梯的效果。在数据交互阶段结束后,我们会运行自己的电梯仿真程序,得到一个标准调度时间,再与电梯程序的实际调度时间进行对比以评价电梯程序的效率。
目前仅考虑了平均调度时间作为唯一评价标准,实际上我们还可以加入更多的评价指标。博主认为好的调度算法应当具有下面几个特性:
- 乘客的平均等待时间较低
- 乘客等待时间的最大值较低
- 大部分情况下,先发出需求的乘客要比后来的乘客更早地被服务
- 负载均衡,平衡电梯的“运动量”
加密保存
在给予学生反馈与完成自动评分后,测试环节还需要额外的一步:将自动评分的结果加密保存在学生本地,加密保存的意义在于防止学生篡改评分结果。
本项目中,加密的部分使用RSA算法生成公钥跟秘钥,秘钥只有助教可见。当学生运行测试程序产生加密的自动评分结果后,将其上传到Github统一的项目仓库中。助教在评分时,将该仓库下载下来,使用秘钥解密文件,即可得知每个学生的成绩。
自动测试小结
本文详细讲述了使用Socket通信 + 序列化协议 + 加密协议来完成自动测试的步骤与流程。其中Socket通信用于进程间的通信,可以简单方便地发布成服务供学生使用;序列化协议协助电梯程序和测试程序更优雅地定义数据接口;加密协议部分使用了RSA算法,防止学生对测试结果文件“自作主张”,保证自动验证的有效性。
源代码
Github 源代码地址:https://github.com/SivilTaram/EleAutoTest