之所以把可读性,可维护性,可扩展性放到一块说是因为解决它们的思路往往是很像的. 你做好了一些事情,以上三个问题也就都解决了,例如可读性好了,可维护性也就差不了. 所以接下来我可能在文章中穿插着讲这三个问题,并不是按绝对的先后顺序来说. 同时把这个话题提前到第三章来说, 也是因为我觉得这是一个大家非常容易忽略,但是在case达到一定量级的时候会出现很大的问题的一个点,也许大家觉得我讲这些有的没的根本没用,我们以前没考虑这些的时候也不是照样干活么.那么在下面的文章里,我会跟大家解释,其实这很重要. 我不会讲什么让你们设计高内聚,低耦合 的软件这种废话.我们来实际举一些例子来看看我们一般是怎么做的.
测试框架与测试脚本的目标(部分)
分层
为了提高我们测试脚本的质量, 分层显然是最常用的方法. 想象一下如果我们把根测试所有相关的东西都放在脚本里那是怎样的一种灾难,每次你去看脚本的时候都会一个头两个大。其一你不知道脚本在干嘛,其二你根本不敢随便动这个脚本。深怕动了哪里就破坏了这条脚本。所以当我们作了分层后,将责任划分出去,分而治之,每一层负责特定的功能,其他层不用担心这些特定的功能。
原则:
测试脚本只关注被测的功能逻辑,其他一切责任分层出去,或交给框架作,或交给其他模块作。
常用的分层方式:
1. 数据驱动,具体数据驱动的实现请看:数据驱动及其变种。把测试参数的构建分离出去,减少脚本复杂度
2. 注册式数据管理,具体实现请看:测试数据管理策略. 我们把测试数据的构建与销毁分离出去,减少脚本复杂度
3. page object,UI自动化常用的模式. 我看到的大家常用的方式就是把页面元素的定义分到单独的类中.下面来看看我曾经怎么做这个分层的.
脚本是这么写的:
driver.page("登陆页面").sendKeys("用户名输入框", "Admin").sendKeys("密码输入框", "1234567").click("登录按钮");
可以看到我定义了一个page的概念.一个页面所有的元素都在这个page里. 只要脚本中选定了某个page,那么他就能随意控制页面操作. 那么page object在哪呢?脚本中我们看不到调用page object的操作,我们看不到你到底用xpath查找的元素还是用id还是用name。请看下一段xml定义
<page name="登陆页面">
<Element eleName="用户名输入框" xpath="//input[@type='text']" id="userNameInput" ifBaseElement="true"></Element>
<Element eleName="密码输入框" xpath="//input[@type='password']" ifBaseElement="true"></Element>
<Element eleName="登录按钮" xpath="//button" ifBaseElement="true"></Element>
</page>
page object在这里,这里面用中文定义了元素名称,以及控件元素到底是用什么方式去查找等信息。当脚本引用任何页面的时候,框架都会去缓存中读取此页面信息,并执行页面元素的控制操作。可以看到我们不仅把页面元素的定义分层出去,还把页面元素的查找过程也都分层出去了。 而且我们可以用自然语言定义控件的名字(英语还是汉语都可以),所以就像上面的代码一样,脚本在做什么一目了然。这就是可读性,我们做的事情跟之前没什么分别,但是我们把责任划分的更详细,脚本中只剩下业务逻辑。我们有一个原则就是脚本中只有业务逻辑。其他一切不相关的要不交给框架,要不交给其他层的模块。
使用类似xml这种可扩展性强的语义存储数据
我们看到上面的xml里还有一个ifBaseElement 属性。 这个是什么呢? 它就是给这些页面元素打个标签,这些控件是属于页面基本元素,这样我们可以通过下面一段代码把所有带有这个标签的页面元素全找出来。
List<String> eleNames = driver.getBaseElementsNameOfPage("登陆页面");
for(String eleName:eleNames){
WebElement element = driver.findElement("登陆页面."+eleName+"");
Assert.assertNotNull(element);
}
看到效果了么? 这样我可以验证所有这些页面基本元素在页面中是存在的,这就是我们UI自动化策略中的静态元素验证。我们不用再一行一行去写代码验证了。而是通过xml这种方便扩展的定义遍历出所有的静态元素。这是一种方式,你也可以通过定义xml文件的属性扩展出很多功能。这是可扩展性。记得我的那篇数据驱动及其变种么?之后的关键字驱动框架就使用xml在数据驱动的基础上扩展而来的。同时xml是一种很清晰很结构化的定义方式。实际上xml本身的可读性就不低。可扩展性和可读性上去了,可维护性也就差不到哪去
代码复用:抽象一切可抽象的,减少一切可能的代码相似与重复
记住一点:代码越少越简单,维护起来就越方便。简单即是美
还是用UI自动化这个例子吧,我们看到上面讲xml可扩展性的时候。我们可以通过定义一个标签ifBaseElement 来帮助做静态元素验证。但是java里普遍也就是用dom4j等工具遍历xml文件,你为ifBaseElement 需要写一套遍历,你加另一个属性可能还要一套遍历。或者xml树结构改了,我们在已有的标签下又加了一套新的标签等情况。都需要重写遍历。而且一层又一层的for循环也挺让人崩溃的。外人不知道你这段代码在干吗。可维护性,可读性,可扩展性都差的要死。那我们一般怎么做呢。看下面一个例子。
注:有个方案是写迭代器(请Google迭代器模式),for循环过多,而且复杂的时候一般使用此模式增加可维护性和可读性。不过在xml遍历的场景中,应变能力不强。xml变化,迭代器也必须变化。所以我一般使用解释器模式遍历xml和json
XMLParser.parser(pageObject, "page/Element$(ifBaseElement=true).eleName");
OK,大家看到了吧,一个解释器接收一个string和xml对象为参数。String就是我们自定义的语法,上面的意思就是取出page节点下的Element节点中所有ifBaseElement属性值为true的eleName属性的值。这样就满足上一个例子的遍历出所有的页面基本元素的需求了。通过定义一个简单易懂的语法(一开始我想做成根sql语句一样的语法的,后来觉得太麻烦了)满足了我们各方面的需求:使用者很容易使用,也很容易看懂这段代码再作什么。很容易接受变化,xml改变了我们改变一下字符串就行了。扩展性也很好。语法很容易进化。基本上可读性,可扩展性,可维护性都做到了。
举些json的例子:
dataList[id=89898,54546,90723,1,90724,90725,54545]/* 取json中dataList数组中 id为这些的所有的值,*代表查询所有
dataList[id=89898,54546,90723,1,90724,90725,54545]/id 取json中dataList数组中 id为这些的所有的值,id代表只查询id
dataList[0~5]/* 取json中dataList数组中前6个元素
dataList[*]/* 取json中dataList数组中所有元素
想知道实现方式的自行google解释器模式吧,这个模式比较大,我说不清楚
再举个例子,我们写脚本的时候一定会验证返回值,有时候这个值可能是简单的数字或者字符串。有些时候可能就是复杂的对象了。这个时候对复杂对象作验证就比较痛苦,每个属性都写断言的方式简直要人命。为了解决这个问题,我们的方式是java反射机制加上责任链模式
VerifyHandler handler = VerifyHandlerFatory.createVerifyHandler();
String[] notverify = {"Task:id"};
handler.PassRequest(copyTask, sourceTask, notverify);
大家看上面的代码,就是比较两个task对象是否相等,第一行代码是创建一个责任链对象,第二行代码规定了什么东西不需要验证,因为task的ID是随机生成的不可能相等。最后我们把两个对象仍进去就行了。你不用管它怎么验证的,责任链在运行到javaBean类型的时候,就会用java反射解析两个对象的每一个属性并调用链表中其他的节点做相应的断言。是不是很好用?不仅仅是javaBean类型,JSON,数组,List,Map,File你全都能不管三七二十一的仍进责任链里。这下子写脚本的人可爽了,以前我们最怕的就是一个ORM映射出来的字段百八十个的,光是写断言就写到手软。现在完全木有这个问题了。如果有新的验证类型出现,你只需要在责任链表里增加一个节点对应这个类型作验证就好了。不需要一大堆的if else 递归调用的,可维护性,可扩展性很不错。现在可读性也好了,就一行验证代码,你肯定知道脚本在干吗。
说一下大概实现思路吧。责任链可以是单向链表,也可以是循环链表,甚至你可以发展成树形结构(暂时我在测试中没碰见这种复杂结构,开发那常碰见),每个节点对应一种类型,如果判断当前类型是该节点应该处理得,就处理。如果不是就传递给下一个节点处理,依次类推,直到遇到跳出链表的点(例如验证结束)或者是到达链表的尾部。中间如果遇到容器类型例如一个javaBean或者一个List等等,就循环遍历每一个值依次传递下去。你可以理解为你为链表作线性遍历,但是链表给传递进来的对象做的事树的先序遍历(深度优先)。
上图所有的类,所有的节点继承VerifyHandler抽象类,VerifyAlgoChain是链表的容器,VerifyHandlerFatory是组装链表的工厂类。下面贴一个List类型的代码
/**
* 验证list类型
*
* @author Gaofei Sun
*
*/
public class ListType extends VerifyHandler {
@Override
public Boolean PassRequest(Object actualValue, Object expectedValue, String fieldName_no,Params info,String[] notVerifyFlag) {
// 如果对象属于List类型就验证
if (expectedValue.getClass().isAssignableFrom(List.class)
|| expectedValue.getClass().isAssignableFrom(ArrayList.class)
|| expectedValue.getClass().isAssignableFrom(LinkedList.class)) {
List<?> expectedValueList = (List<?>) expectedValue;
List<?> actualValueList = null;
try {
actualValueList = (List<?>) actualValue;
} catch (ClassCastException e) {
e.printStackTrace();
Assert.assertTrue(fieldName_no + "返回值并不是List类型,而是:" + actualValue.getClass() + " 类型", false);
}
if (actualValue == null) {
if (expectedValueList.size() == 0) {
return true;
} else {
Assert.assertTrue(fieldName_no + " 返回值中的List为空,但是预期值不是", false);
}
}
Assert.assertEquals("输入的List:" + fieldName_no + " 的大小与返回的不等", expectedValueList.size(),
actualValueList.size());
VerifyHandler handler = VerifyHandlerFatory.createVerifyHandler();
for (int i = 0; i < expectedValueList.size(); i++) {
// 取出所有对象继续在责任链表中传递。
handler.PassRequest(actualValueList.get(i), expectedValueList.get(i), fieldName_no,info,notVerifyFlag);
}
return true;
}
// 不属于List类型,传递给下一个节点
return nextHandler.PassRequest(actualValue, expectedValue, fieldName_no,info,notVerifyFlag);
}
}
好了具体的实现原理请大家自行Google 责任链模式
当然了大家也许会说我不用这个屁的责任链模式也可以阿,我写N个if else 加递归调用加java反射也可以实现。那么我们通篇都在说什么呢?可读性,可扩展性,可维护性。如果你这么写你指望谁愿意接手你的代码。反正我写这种代码出来我老大肯定抽我。多少个公司的代码规范里都是严禁出现这种情况的
活用java注解和反射(python中应该也有相关的机制)
这个例子是在 测试数据管理策略一章中讲到的注册式数据管理。看一下下面的例子
@DataBaseFile(filePath="defaultProject.xls",scope=Scope.CLASS)
public class UnitTestNew extends UnitCaseBase{
上面的代码在类的基础上加一个DataBaseFile的注解,然后再基类中我们有如下定义:
/**
* 根据数据文件内容解析出的数据库执行语句的集合,用来初始化和销毁数据库。 初始化方法读取数据文件执行数据库insert语句并给此变量赋值,销毁方法在测试结束后读取此变量执行销毁操作
*/
private List<DataEntity> dataEntityList;
/**
* 表明子类的DataBaseFile注解
*/
private DataBaseFile data;
/**
* 表明子类的DataBaseFile注解中数据文件的路径信息
*/
private String[] filesPath;
/**
* 表明子类的DataBaseFile注解中执行初始化和销毁的策略信息
*/
private Scope scope;
/**
* 构造方法,获取子类的@DataBaseFile信息
*/
public UnitCaseBase(){
register = false;
data = this.getClass().getAnnotation(DataBaseFile.class);
dataEntityList = new ArrayList<DataEntity>();
if(data!=null){
this.filesPath = data.filePath().split(",");
this.scope = data.scope();
}
}
我们可以看到,在子类中,我们使用注解的方式制定数据文件的路径和作用域,基类默认构造方法会使用反射的方式去读取注解的信息,然后再基类中定义好了方法去做测试测试数据的初始化和销毁。如下:
// 供子类重写,用于setup测试用例
protected void methodSetUp(){}
// 供子类重写,用户销毁测试用例
protected void methodTearDown(ITestResult result){}
// 供子类重写,用于在测试类开始前执行初始化工作
protected void classSetUp(){}
// 供子类重写,用于在测试类结束后执行销毁工作
protected void classTearDown(){}
/**
* 测试用例的初始化
*/
@BeforeMethod
protected void methodDataBaseSetUp(){
this.setUpDataBase(Scope.METHOD);
this.methodSetUp();
}
/**
* 测试用例的销毁
*/
@AfterMethod
protected void methodDataBaseTearDown(ITestResult result){
this.methodTearDown(result);
// 判断子类是否注册了测试数据
if(dataEntityList!=null&®ister.equals(true)){
this.destoryData();
}
this.tearDownDataBase(Scope.METHOD);
}
/**
* 测试类的初始化
*/
@BeforeClass
protected void classDataBaseSetUp(){
this.setUpDataBase(Scope.CLASS);
this.classSetUp();
}
/**
* 测试类的销毁
*/
@AfterClass
protected void classDataBaseTearDowm(){
this.classTearDown();
this.tearDownDataBase(Scope.CLASS);
}
我们可以看到基类定义的before系列的方法中有着针对数据作用域进行初始化和销毁的操作。并且留给子类接口扩展销毁和初始化操作。一般情况子类只需要使用注解规定数据文件的路径和作用域就可以了。这种基类定义行为,子类定义实现的方式是 模板模式 的变种. 这下我们可以看到我们的脚本类只需要继承这个基类,使用一个简单的注解就可以不用管数据的销毁与创建了. 我十分推荐这种方式制作测试框架.
活用注解和反射很重要, 很多工具和框架都离不开这两种机制
一个模块知道的越少越好
其实这个原则跟分层有点像,把责任划分出去了,知道的就少了. 现在让我们来看看下面一个例子,这个例子我之前关于数据驱动及其变种的帖子里的一部分. 用来解析作为数据驱动的xml文件中的数据类型,我们知道java是一门强类型语言,从xml读取出来的都是String类型,我们需要对其作类型转换. 如果我在脚本里做类型转换的话无疑太痛苦了.所以我们交给框架来做,我们希望脚本是这样的.
@Test(dataProvider="unitDataProvider",dataProviderClass=UnitDataProvider.class)
@DataFile(filePath="test.xml")
public void test(List<String> in,File file, String mock,String out) throws SQLException{
如上面代码,测试脚本中用一个DataFile注解定义数据文件位置,框架用java反射读取内容,这一点和上面的例子很像. 数据类型的转换也交给框架来做.这样测试脚本就 知道的很少 然后我们再看框架. 框架专门有一个地方负责读取这些文件.然后解析并作类型转换操作. 可是我们发现这个类型转换操作其实是很复杂的. 你不确定你穿进来的对象里是不是还包含着另一个对象或者容器.java里的对象就是个不确定深度的树结构. 我们一般的思路就是写N个if else判断到底是什么类型,然后做类型转换. 碰到容器类型就递归调用. 这样的话我们发现这个模块就非常复杂了.N多的if else和递归调用无疑是个灾难. 之后不管谁来接手这段代码心里都会骂娘的.
所以我们把这个职责也分层出去成为一个模块. 我们不用那么多的 if else了, 每个类型都单独一个类型转换算法. 提供一种机制让每一个算法之间都能互相调用(模拟递归).所有的算法都实现一个接口.如下
import org.springframework.stereotype.Component;
import InterfaceTool.paramLoader.params.Param;
@Component
public interface TypeConvert {
public Parameter convertType(Param value);
}
这个接口只有一个方法,就是类型转换算法. 所有的类型转换算法都要实现这个接口. 有一个工厂类专门负责创建算法对象.
public class TypeConvertFactroy {
private static ConcurrentHashMap<String, TypeConvert> map = new ConcurrentHashMap<String, TypeConvert>();
public static TypeConvert createTypeConvert(String type) {
if (type == null || type.equals("")) {
type = "String";
}
if (map.containsKey(type)) {
return map.get(type);
} else {
TypeConvert obj = null;
try {
type = Tools.convertStringToUp(type);
obj = (TypeConvert) Tools.reflectObject(
"InterfaceTool.paramLoader.typeConvert." + type + "Convert");
map.putIfAbsent(type, obj);
} catch (Exception e) {
e.printStackTrace();
Assert.assertTrue("没有找到 " + type + " 的参数类型,请核对是否输入错误的参数类型或请在系统中增加对应的参数类型", false);
}
return obj;
}
}
}
上面我们看到这个工厂类负责维护一个map,map里装的就是所有的算法(缓存). 有一个细节就是
obj = (TypeConvert) Tools.reflectObject("InterfaceTool.paramLoader.typeConvert." + type + "Convert");
这是利用java反射去生成算法对象, 好处是以后加入新的算法类型的时候,只要在特定路径下定义一个特定名字的算法类就行了. 这样这个工厂类就能自动创建这个对象而不用任何的代码变动(可扩展性)。
OK,那我们看看其他模块怎么调用算法的。
// 将Param转型成真正的参数
TypeConvert convert = TypeConvertFactroy.createTypeConvert(param.getType());
Parameter obj = convert.convertType(param);
上面我们看到调用方只要通过工厂类创建TypeConvert 类型的对象就可以了。直接使用convert算法得到转型后的结果。调用方不需要知道他到底创建了哪个类型的算法(因为工厂类返回的类型是所有算法的接口类型),不知道里面到底做了什么。只需要知道这么做是在做类型转换就可以了。这是最简单的 策略模式。对调用方来说,它知道的非常少,大家应该可以感觉出来这几个模块的可读性,可扩展性和可维护性了么?顺便说一下工厂模式的这种设计方法也是在模拟递归调用,也就是说算法内部调用其它算法的时候也是通过这个工厂类来调用的
上面的例子涉及到了表驱动,工厂模式,策略模式,注解,反射等知识,不清楚的请自行Google
我发现篇幅已经好长了~,说最后一个例子吧。。还是用UI自动化那个例子。下面是代码
driver.page("登陆页面").sendKeys("用户名输入框", "Admin").sendKeys("密码输入框", "1234567").click("登录按钮");
大家可以猜到上面用的肯定不是webDriver原生的接口。我一定是封装过一层的,可我没有都重新定义dirver的接口(太多了,而且也没必要)。如果我们想调用原本的接口呢? 也简单,直接调用就行了(我没有重写webDriver的东西)。这时候大家可能猜到了,直接用继承来扩展webDriver不就得了。像下面这个样子:
public class MyChromeDriver extends ChromeDriver {
可是这样就出现问题了。我们知道webDriver里有好几种driver。难道我们为了实现page这个功能要为每个driver都扩展一次么。肯定不行啊。多少的代码重复呢。 有的同学说你可以写个适配器阿,把driver传到适配器里不就行了。适配器里传得是所有dirver的基类类型就行了,在适配器里重新定义你需要的接口。像下面
public MyDriver(RemoteWebDriver driver, String pageName) {
this.driver = driver;
this.currectPage = pageName;
pageObjectMap = PageObjectFactory.getPageObjects();
}
可是这样我们就无法通过这个适配器调用driver原本的接口了,而且我要让写脚本的人知道两套东西,一个driver和一个适配器,还需要用户去组装这个适配器。 也许有些同学说干嘛那么纠结,这样干活就足够了。但是我们是完美主义追求者,根据知道的最少原则我希望测试人员能通过简单易懂的方式完成工作。在这里我们让用户少知道点东西,在另一个地方我们让他们少知道点,聚沙成塔。最后我们的质量就上去了。OK,我们来看看到底怎么做。
首先我们知道,我们有了一个适配器,我们有了一个通过继承得来的driver。下面我们要考虑怎么把他们两个合成一个东西。 我们知道java里是没有多重继承的。如果有的话我们就不用烦了。所以我们弄了个山寨版的多重继承----通过内部类。 看下面例子
public class MyChromeDriver extends ChromeDriver {
private Map<String, Page> pageMap = new HashMap<String, Page>();
@Override
public void get(String url) {
super.get(url);
}
public MyChromeDriver(ChromeOptions options){
super(options);
}
public Page page(String pageName){
// 如果缓存中已经有该page的对象就使用缓存中的,如果没有创建一个新的
if(pageMap.containsKey(pageName)){
return pageMap.get(pageName);
}else{
Page page = new Page(pageName);
pageMap.put(pageName, page);
return page;
}
}
public Page page(){
return new Page(null);
}
public class Page extends MyDriver{
public Page(String pageName) {
super(MyChromeDriver.this,pageName);
}
}
上面我们通过类内部再定义一个内部类,这个内部类继承了我们之前说的那个适配器类。这样我们就让一个类中拥有两个类的行为了。是不是很简单,我们把这个内部类命名为page,适配器类中定义跟原生的webdriver一样的接口名称,例如,sendkey,click等等。这样调用方很自然以为这其实就是一个机制。就像下面的代码
driver.page("登陆页面").sendKeys("用户名输入框", "Admin").sendKeys("密码输入框", "1234567").click("登录按钮");
符合人类思维的结构和接口命名,是不是看起来好多了。不想通过page的概念控制也可以 直接一个driver.findElement的方式去做。额,我嘴比较笨,大家自行体会吧。
case的隔离
这回真是最后一个了。。。时间有限,我就简单点说了。 其实case的隔离主要分为两方面:第一,不依赖产品接口创建和销毁测试数据。第二:每个用例都有自己的测试数据,不跟其他case共享,最简单最有效的做法就是做 隔离数据,而且保险起见,case运行结束后数据都删除掉。
具体实现还是看我那篇测试数据管理策略的帖子把。我说说为什么这么做,我发现其实大多数人都不是这么做的。就说第一点,很多人都是依赖产品接口做的测试数据准备。 测试这个接口前会调用很多别的接口先创建数据。其实这样你的测试粒度已经很粗了,你没有把被测功能隔离开,这是一个标准的粗粒度的集成测试。这么做不是说不行。而是一旦创建数据的接口bug了,你觉得你得有多少case会直接fail。你得脚本里之前调用了那么多的接口,一旦bug了你能确定到底是哪个接口的bug么?举个例子,get一个user的时候出错了,你能确定就是get接口出错了么? 很可能是adduser的接口就出错了。所以记得我们一开始就说的 测试脚本的目标 么,脚本能根据脚本准确定位bug的位置。
我们接下来再说第二点,很多人反对做隔离数据,觉得太麻烦没有必要。 可是我想说数据库是对所有case可见的。不做隔离数据的话,case之前肯定互相影响。例如我这个脚本测试listuser,把所有user都展示出来。 结果后来又测试一个case叫adduser。adduser就会导致listuser的失败。也许有些同学会说我们可以定义case执行顺序,我们可以定义不同类型的数据等等,但是这些都是不靠铺的,你的case可能依然互相影响。而且这些的前提是只有你一个人在做自动化,而且自动化的规模比较小。 如果是好几个人在做自动化,你能指望记住每个人的case执行顺序和数据类型么。 如果来个新人不知道这些规则呢? 如果case达到几千的量,你还记得住你的顺序和规则了么?
所以我一直在说数据的管理很重要。
OK就说到这把,篇幅好长了,其他的以后又机会说吧。最后做个总结,其实保证可读性,可维护性 和 可扩展性 的方法很多,我列出来的只是冰山一角。不仅仅是脚本的,还有框架的。都要保证这三个纬度。也许大家觉得这没什么卵用,不保证这些我照样干活,用不着学这些有的没的,什么设计模式,数据结构的,都是用来装逼得。
我猜一定有很多人是这么想的。那么大家其实细想想,如果按我上面的方法做了,是不是长远上讲其实是增加了效率的。 一旦产品改动,架构改动或者产品出bug的时候。会不会很爽? 如果大家仔细想想得话,答案是肯定的。 退一万步讲,如果你的代码里真的是各种if else for循环的嵌套好几层,还外带递归调用的,没什么分层没什么模块的。这样的代码你指望谁来接手? 时间长了你不忘么?就算你不忘你能保证你呆在公司一辈子么?你走了留个烂摊子给后辈么? 我们不要嘴里骂着开发的代码质量垃圾的同时,自己还写着垃圾代码吧
So,大家不要排斥学习开发的知识,一旦经历过数千级别的case,长达数年的自动化项目,遇到过这样那样的坑的时候。你就会知道我说的这些有多重要。
原文发表于:Testerhome;
作者:ycwdaaaa ;
原文链接:https://testerhome.com/topics/4861
目前腾讯WeTest服务器性能测试已经正式对外开放,业务场景模拟,持续压力触达服务器极限,帮助寻找服务器性能问题!点击链接:http://wetest.qq.com/gaps/立即体验!