• 软件测试技术
  • 软件测试博客
  • 软件测试视频
  • 开源软件测试技术
  • 软件测试论坛
  • 软件测试沙龙
  • 软件测试资料下载
  • 软件测试杂志
  • 软件测试人才招聘
    暂时没有公告

字号: | 推荐给好友 上一篇 | 下一篇

追求代码质量: 测试 Struts 遗留的应用程序

发布: 2007-5-24 22:03 | 作者: Andrew Glover | 来源: IBM | 查看: 38次 | 进入软件测试论坛讨论

领测软件测试网 虽然 Struts 正在慢慢退出 Web 框架的历史舞台,但它的遗产仍然存在,存在的形式主要是需要测试和维护的应用程序。这个月,Andrew Glover 向您介绍如何使用 JUnit 的 StrutsTestCase、DbUnit 以及在这个系列中迄今为止学到的一些工具,把以质量为中心的方法用于 Struts 上的测试(可以这么说)。

基于 Java™ 的 Web 开发领域最近出现了丰富的竞争性技术。启动新项目的开发人员可以在许多不同的框架之间进行选择,包括 JavaServer Faces、Tapestry、Shale、Grails 和 Seam (只列举众多机灵的名称中的几个)。很快,我们就可以通过 JRuby 框架在 Java 编程中使用 Ruby on Rails 了!

但就在不远的过去,只有一个 Java Web 开发框架卓然而立。Struts 是第一个在 Java 世界掀起风暴的框架,而且多年以来,好像是如果一个项目不用 Struts 构建就没有前途一样。没有 Struts 经验的 Java 开发人员很稀少,也很不幸,就像今天的开发人员没有听说过 Ruby on Rails 一样。

提高代码质量
不要错过 Andrew 的附带 讨论组 ,可以得到最迫切问题的答案。

即使 Struts 正慢慢地从舞台中央退去(原来的基本框架,现在叫做 Struts 1,似乎正在退出 Web 框架的历史舞台),但它的遗产仍然存在,既以 Shale (请参阅 参考资料)的形式存在,又以运行在世界各地的成千上万的遗留应用程序的形式存在。因为许多企业宁愿测试和维护这些应用程序而不愿意花钱重新编写它们,所以理解 Struts 应用程序的一些缺陷,以及如何围绕它们进行重构,是个好主意。

这个月,我要把以质量为核心的方法用于 Struts 应用程序的测试场景。结合现实,这个场景围绕着最普遍的 Struts 构造:深受喜爱的 Action 类。

1、2、3,行动!

Struts 的革新之一就是把 Web 开发从 Servlet 移进了 Action 类。这些类包含业务逻辑,以 JavaBean 的形式(通常叫做 ActionForm)把数据传送到 JSP。然后 JSP 处理应用程序视图。Struts 到 MVC 的方法非常容易掌握,以至于许多开发团队冒失地闯进去,而很少考虑与 Action 相关的长期设计和维护问题。

测试和复杂性

我已经发现,在开发人员的测试和代码的复杂性之间存在强烈的相关性:没有其中一个的地方,通常也没有另一个。高度复杂的编码难于测试,结果是很少有人会真正为它编写测试。反过来,编写测试可以降低代码的复杂性。因为给复杂代码编写测试更困难,而且因为会边走边测试,所以会发现自己朝着更简单的代码构造前进。如果代码太复杂,而且知道不得不测试它,您可能就会在测试之前对复杂性进行重构。不论如何看待,为不那么简单的代码编写测试是消灭代码复杂性的好实践。

虽然在那个时候(过去的自由时光啊)可能没人想过,但 Struts Action 类通常成为复杂性的保护所。像在老的 EJB 架构中声名狼籍的会话 Facade 一样,Action 类会成为特定业务过程的严格伪装,或者通过直接调用 EJB,通过打开数据库连接,或者通过调用其他高度依赖的对象。Action 类还有输出耦合(通过 java.servlet API 包中的对象,例如 HttpServletRequestHttpServletResponse),从而极难把它们隔离出来测试。

隔离出来测试 Action 类的困难意味着它们可以很容易变得相当复杂 —— 特别是当它们变成越来越深入地与遗留框架耦合的时候。现在我们来看这个困难在真实的遗留应用程序场景中作用的情况。

测试挑战

即使最简单的 Struts Action 类也会是个测试挑战。例如,以清单 1 中的 execute() 方法为例;它看起来足够简单,可以测试,但是真的么?


清单 1. 这个方法看起来容易测试……
public ActionForward execute(ActionMapping mapping, ActionForm aForm,
            HttpServletRequest req, HttpServletResponse res) throws Exception {
            try{
            String newPassword = ((ChangePasswordForm)aForm).getNewPassword1();
            String username = ((ChangePasswordForm)aForm).getUsername();
            IUser user = DataAccessUtils.getDaos().getUserDao().findUserByUsername(username);
            user.digestAndSetPassword(newPassword);
            DataAccessUtils.getDaos().getUserDao().saveUser(user);
            }catch(Throwable thr){
            return findFailure(mapping, aForm, req, res);
            }
            return findSuccess(mapping, aForm, req, res);
            }
            


图 1. Action 类的输出耦合

但是,就像在图 1 中可以看到的,在试图隔离 ChangePasswordAction 类并检验 execute() 方法时,该类给出了一些有代表性的挑战。为了有效地测试 execute() 方法,必须处理三层耦合。首先,到 Struts 自身的耦合;其次,Servlet API 代表一个障碍;最后,到业务对象包的耦合,进一步检查业务对象包,还会有数据访问层使用 Hibernate 和 Spring。

每种情况一个 mock?

即使在我编写本文时,我还可以听到开发人员的嘲笑者 认为我的测试问题通过明智地使用 mock 对象就能轻易解决。可以 用 mock 对象创建一级隔离,它会形成更容易的测试;但是,我要说的是,把目标对象通过 mock 排除所需要的付出级别,比起承认隔离测试困难所需要的付出,要多得多。在这种情况下,我会采用在更高层次上的测试,这级测试有时叫做集成测试

对于更高的复杂性,请注意 清单 1 中的代码如何把 aForm 参数转换成 ChangePasswordForm 对象,它是 Struts ActionForm 类型。这些 JavaBeans 有一个 validate 方法,这个方法由 Struts 在调用 Action 类的 execute() 方法之前调用。

犯错误太容易了

在清单 2 中,可以看到所有这个复杂性会在哪里发生。ChangePasswordFormvalidate() 方法的代码片段演示了保证两个属性(newPassword1newPassword2)不为空并彼此相等的简单逻辑。但是,如果 Struts 发现 errors 集合(类型为 ActionErrors)包含一些 ActionError 对象,就会沿着错误路径走,例如带着出错消息重新显示 Web 页面。


清单 2. ChangePasswordForm 的验证逻辑
if((newPassword1 == null) || (newPassword1.length() < 1)) {
            errors.add("newPassword1",
            new ActionError("error.changePassword.newPassword1Required"));
            }
            if((newPassword2 == null) || (newPassword2.length() < 1)) {
            errors.add("newPassword2",
            new ActionError("error.changePassword.newPassword2Required"));
            }
            if((newPassword1 != null) && (newPassword2 != null)) {
            if(!newPassword1.equals(newPassword2)) {
            errors.add(ActionErrors.GLOBAL_ERROR,
            new ActionError("error.changePassword.passwordsDontMatch"));
            }
            }
            

清单 1清单 2 的代码不特殊也不特定于某个领域。它是无数应用程序中都包含的简单口令修改逻辑。如果正在测试 Struts 遗留应用程序,将不得不花些时间处理口令逻辑,但是如何用可重复的方式测试它呢?





回页首


两个测试用例

在企图为 清单 1(间接的是 清单 2)的代码编写测试之前,可能想确定实际需要测试什么。在这个具体示例中,逻辑清楚地是为了方便用户口令的修改;所以,应当编写至少两个层次的测试用例:

  • 口令修改在数据正确时是否工作?
  • 如果数据不正确,口令是不是 修改?

这些测试不会太容易只是个假设。不仅需要对付 Struts,还必须处理数据层以及数据层与数据库暗含的耦合!在面对复杂性时,我的第一本能是寻求帮助,在这个示例中,是以 JUnit 的 StrutsTestCase 的形式。





回页首


来自 StrutsTestCase 的帮助

StrutsTestCase 是一个 JUnit 扩展,专门针对 Struts 应用程序。这个框架实际上模拟了一个 servlet 容器,这样就能虚拟地运行和测试 Struts 应用程序,而不必在 Tomcat(举例)中运行它了。框架还有一个方便的 MockStrutsTestCase 类,它扩展了 TestCase 并处理许多 Struts 配置方面(例如装入 struts-config.xml 配置文件)。

但是,在您认为自己完全脱离了 Struts 配置的痛苦之前,应当了解一些正确配置 MockStrutsTestCase 的事情。也就是说,需要把它指向代表 Web 应用程序的目录,然后指向必要的 web.xml 和 struts-config.xml 文件。默认情况下,MockStrutsTestCase 扫描这些项目的类路径;但是,要把 MockStrutsTestCase 配置成在特定环境中工作,操作很简单,只需覆盖一些设置并编写一些特定的配置代码即可。

返回口令验证示例,包含 ChangePasswordAction 类的项目有图 3 所示的目录结构:


清单 3. 示例目录结构
root/
            src/
            conf/
            java/
            webapp/
            images/
            jsp/
            WEB-INF/
            test/
            

WEB-INF 目录包含 web.xml 和 struts-config.xml 文件,webapp 目录代表 Web 上下文环境。知道了这些,我就如清单 4 所示配置 MockStrutsTestCase


清单 4. MockStrutsTestCase 的定制 fixture 代码
public void setUp() throws Exception {
            try {
            super.setUp();
            this.setContextDirectory(new File("src/webapp/"));
            this.setServletConfigFile("src/webapp/WEB-INF/web.xml");
            this.setConfigFile(
            this.getSession().getServletContext()
            .getRealPath("WEB-INF/struts-config.xml"));
            }catch (Exception e) {
            fail("Unable to setup test");
            }
            }
            

其他测试方式

在某些情况下,基于 Action 类中发现的对应逻辑,可能能够用基于 Web 的测试框架(像 JWebUnit 或 Selenium间接地 测试代码。使用这些框架从测试设置的角度来说,确实增加了复杂性。例如,要使用 JWebUnit,必须把应用程序部署到一个运行着配置好的数据库的 servlet 容器。把 StrutsTestCase 和 DbUnit 协同使用,可以方便测试,不必 把 war 文件部署到运行着的 servlet 容器。它还允许在 考虑应用程序的视图方面的情况下进行测试。

关于逻辑映射

正确地配置了 MockStrutsTestCase 的实例后,测试 Action 类就只包含一点点逻辑映射。要调用 Action 类,需要强制 StrutsTestCase 框架通过一个路径间接地 调用它,这是在 struts-config.xml 文件中定义的。

例如,要强制调用 ChangePasswordAction 类,必须告诉框架使用 /changePasswordSubmit 路径。在清单 5 中可以看到这点,清单 5 中的代码片段来自 struts-config.xml 文件,它把 ChangePasswordAction 类映射到 /changePasswordSubmit 路径:


清单 5. struts-config.xml 代码片段显示了动作类路径映射
<action path="/changePasswordSubmit"
            type="com.acme.ccb.action.ChangePasswordAction"
            name="changePasswordForm" scope="request"
            input="/jsp/admin/changepassword.jsp">
            <forward name="success" path="/viewUsers.do"
            redirect="true" contextRelative="false" />
            </action>
            

一旦某个用户点击了提交按钮(举例),Struts 就把来自 HTTP 请求的参数值映射到 ActionForm,在这个示例中,是上面的 struts-config.xml 代码片段中(在清单 5 中)定义的 ChangePasswordForm。要模拟这个行为,在测试用例中必须有另一个逻辑映射 —— JSP 表单名称必须映射到值。在口令修改场景中,提交了四个参数:usernamecurrentPasswordnewPassword1newPassword2 newPassword2 参数是多数 Web 页面为了校验新口令正确的确认信息)。





回页首


成功的测试用例!

请求路径和参数映射好之后,编写测试用例就成了利用 MockStrutsTestCase API 设置相关口令值的问题,如清单 6 所示。在这个测试用例中,用户 Jane 的口令从 “admin” 改成了 “meme”。


清单 6. 一个验证口令修改成功的简单测试用例
public void testExecute() throws Exception{
            setRequestPathInfo("/changePasswordSubmit");
            addRequestParameter("username","jane");
            addRequestParameter("currentPassword","admin");
            addRequestParameter("newPassword1","meme");
            addRequestParameter("newPassword2","meme");
            actionPerform();
            verifyForward("success");
            }
            

setRequestPathInfo() 方法配置路径以映射到 Action 类,addRequestParameter() 方法把来自 JSP 文件的参数名称映射到值。例如,在清单 6 中,username 参数映射到 “jane”。

还请注意清单 6 中的最后两行。actionPerform() 方法实际上让 Struts 去调用对应的 Action 类。如果这个方法没被调用,什么也不会发生。最后调用的方法 verifyForward() 是在 MockStrutsTestCase 类中找到的一个类似于断言的方法,它验证正确的转发。在 Struts 中,这是一个 String,通常映射到成功或失败状态。(请注意,清单 5 中的 XML 定义了 “success” 转发。)





回页首


用 DbUnit 进行的可重复的成功

这时,您可能希望工作完成 —— 毕竟已经编写了一个企图验证口令修改的测试。但是还缺乏更深的验证。确实,这个方便的框架调用了 Struts,但是代码依赖于数据库。如果希望能够不止一次地运行这个测试,比如在构建过程中,就需要让它可重复

由于一些特定的假设,所以 清单 6 中的测试用例不是可重复的。首先,测试用例假设在系统中已经 有一个名为 “jane” 的用户,它的口令是 “admin”。其次,测试用例假设在某些永久存储 中口令 “admin” 被更新成 “meme”。正如所写的那样,只要代码没有生成异常,ActionForm 成功验证,Struts 就假定事情工作良好,测试用例也是一样。

现在需要的是更深层次的验证 —— 在数据库层次。对于应当更新口令的测试用例来说,理想情况下应当在数据库上 执行检查,确保那里有一个新口令。对于口令不应当修改的测试来说,需要进行验证,真正检验没有修改 口令。最后,要让这个测试套件可重复,最好是不要 对数据完整性做任何假设。

DbUnit 是个专门方便把数据库放进测试状态中已知状态的 JUnit 扩展。使用 XML 种子文件,可以把特定数据插入到测试用例可以依靠的数据库中。而且,使用 DbUnit API,可以容易地比较数据库的内容和 XML 文件的内容,从而提供一个在应用程序代码之外 校验预期数据库结果的机制。





回页首


用 DbUnit 进行测试

要使用 DbUnit,需要两样东西:

  • 通过普通 JDBC 的数据库连接
  • 一个文件,包含需要放到数据库中的数据

清单 7 是一个 DbUnit 种子文件,只定义了几样东西:首先,有一个叫做 user 的表和另一个叫做 user_role 的表。在 user 表中定义了一个新行,并映射一些值到列(例如列 username 拥有值 “jane”)。在 user_role 中还定义了一行。请注意这个数据库中的口令是通过 SHA 加密的


清单 7. 用于测试表 user 和 user_role 的 DbUnit 种子文件
<?xml version='1.0' encoding='WINDOWS-1252'?>
            <dataset>
            <!-- user with password admin -->
            <user username="jane"
            password="d033e22ae348aeb5660fc2140aec35850c4da997"
            name="Jane Admin"
            date_created="2003-8-14 10:10:10"
            email="jane@elsewhere.org"/>
            <user_role username="jane" rolename="ADMIN"/>
            </dataset>
            

有了这个文件,就可以利用 DbUnit 插入数据、更新数据库来反映数据,甚至删除数据。数据库修改逻辑包含在 DbUnit 的 DatabaseOperation 类中。在这个示例中,只是通过 清单 4 中定义的 MockStrutsTestCase 类型的 setUp() 方法中的一些增强的 fixture 逻辑中的 CLEAN_INSERT 标志来保证干净的数据集。例如,在清单 8 中,定义了三个方法,分别利用 DbUnit API 把 dbunit-user-seed.xml 文件的内容插入数据库。


清单 8. 定制的 DbUnit fixture 逻辑
private void executeSetUpOperation() throws Exception{
            final IDatabaseConnection connection = this.getConnection();
            try{
            DatabaseOperation.CLEAN_INSERT.execute(connection, this.getDataSet());
            }finally{
            connection.close();
            }
            }
            private IDataSet getDataSet() throws IOException, DataSetException {
            return new FlatXmlDataSet(new File("test/conf/dbunit-user-seed.xml"));
            }
            private IDatabaseConnection getConnection() throws ClassNotFoundException, SQLException {
            final Class driverClass = Class.forName("org.gjt.mm.mysql.Driver");
            final Connection jdbcConnection = DriverManager.
            getConnection("jdbc:mysql://localhost/ccb01",
            "9043", "43xli");
            return new DatabaseConnection(jdbcConnection);
            }
            

清单 8 中定义的 executeSetUpOperation() 方法将在前面的 清单 4 中定义的 setUp() 方法中调用。这个方法再调用清单 8 中的另两个方法:getDataSet() 把 XML 转换成 DbUnit 的 IDataSet 类型,getConnection() 则返回包装成 DbUnit 的 IDatabaseConnection 类型的数据库连接。





回页首


更好的测试用例

配置好 DbUnit 后,剩下的就只有改进 清单 6 的测试用例,验证数据库中的一切 OK。然后,添加验证其他问题场景的其余测试用例。

要确认数据库中的口令更新,可以使用 DbUnit 的查询 API,它帮助比较数据库的结果与静态定义的 XML 文件,例如清单 9 中定义的那个。请注意这个文件没有列出 user 表中的所有列 —— 实际上,它只列出了两个:usernamepassword


清单 9. 比较测试 XML 文件
<?xml version='1.0' encoding='WINDOWS-1252'?>
            <dataset>
            <user username="jane"
            password="58117e24e4d0b8a958146c9eaa28336184f4d491"/>
            </dataset>
            

DbUnit 的查询 API 足够灵活,可以帮助过滤掉没有意义的值,在这个示例中就是 usernamepassword 之外的值。同样,在清单 10 中,verifyPassword() 方法用 DbUnit 的 createQueryTable() 方法构建 ITable 类型,以与清单 9 中定义的 XML 进行比较:


清单 10. 使用 DbUnit 查询 API 的 verifyPassword 方法
private void verifyPassword(String fileName) throws Exception{
            final IDataSet expectedDataSet = new FlatXmlDataSet(
            new File(fileName));
            final ITable defJoinData = this.getConnection().
            createQueryTable("TestResult",
            "select user.username, user.password " +
            "from user where user.username=\"jane\"");
            final ITable defTable = expectedDataSet.getTable("user");
            Assertion.assertEquals(defJoinData, defTable);
            }
            

Assertion 类型是 DbUnit 定义的定制类,可以进行特定于数据库结果集的额外断言。还请注意 verifyPassword() 接受一个文件路径,这意味着我可以定义多个期望的数据集(一个用于修改的口令,一个用于相同的口令)。





回页首


反复测试 Struts

综合起来,现在有了一个可以完成以下工作的测试用例:

  1. 通过 DbUnit 填充数据库
  2. 配置 Struts
  3. 间接地调用 ChangePasswordActionChangePasswordForm
  4. 关联参数值
  5. 验证成功转发
  6. 验证数据库内容

从清单 11 可以看出,ChangePasswordAction 测试用例只通过 testExecute 测试处理一个正常场景:


清单 11. ChangePasswordAction 测试用例
package test.com.acme.ccb.action;
            import java.io.File;
            import java.io.IOException;
            import java.sql.Connection;
            import java.sql.DriverManager;
            import java.sql.SQLException;
            import org.dbunit.Assertion;
            import org.dbunit.database.DatabaseConnection;
            import org.dbunit.database.IDatabaseConnection;
            import org.dbunit.dataset.DataSetException;
            import org.dbunit.dataset.IDataSet;
            import org.dbunit.dataset.ITable;
            import org.dbunit.dataset.xml.FlatXmlDataSet;
            import org.dbunit.operation.DatabaseOperation;
            import servletunit.struts.MockStrutsTestCase;
            public class ChangePasswordActionTest extends MockStrutsTestCase {
            public ChangePasswordActionTest(String arg0) {
            super(arg0);
            }
            public void setUp() throws Exception {
            try {
            super.setUp();
            this.executeSetUpOperation();
            this.setContextDirectory(new File("src/webapp/"));
            this.setServletConfigFile("src/webapp/WEB-INF/web.xml");
            this.setConfigFile(
            this.getSession().getServletContext()
            .getRealPath("WEB-INF/struts-config.xml"));
            } catch (Exception e) {
            fail("Unable to setup test");
            }
            }
            public void testExecute() throws Exception{
            setRequestPathInfo("/changePasswordSubmit");
            addRequestParameter("username","jane");
            addRequestParameter("currentPassword","admin");
            addRequestParameter("newPassword1","meme");
            addRequestParameter("newPassword2","meme");
            actionPerform();
            verifyForward("success");
            verifyPassword("test/conf/dbunit-expect-user.xml");
            }
            private void executeSetUpOperation() throws Exception{
            final IDatabaseConnection connection = this.getConnection();
            try{
            DatabaseOperation.CLEAN_INSERT.execute(connection, this.getDataSet());
            }finally{
            connection.close();
            }
            }
            private IDataSet getDataSet() throws IOException, DataSetException {
            return new FlatXmlDataSet(new File("test/conf/dbunit-user-seed.xml"));
            }
            private IDatabaseConnection getConnection() throws ClassNotFoundException, SQLException {
            final Class driverClass = Class.forName("org.gjt.mm.mysql.Driver");
            final Connection jdbcConnection = DriverManager.
            getConnection("jdbc:mysql://localhost/ccb01",
            "9043", "43xli");
            return new DatabaseConnection(jdbcConnection);
            }
            private void verifyPassword(String fileName) throws Exception{
            final IDataSet expectedDataSet = new FlatXmlDataSet(
            new File(fileName));
            final ITable defJoinData = this.getConnection().
            createQueryTable("TestResult",
            "select user.username, user.password " +
            "from user where user.username=\"jane\"");
            final ITable defTable = expectedDataSet.getTable("user");
            Assertion.assertEquals(defJoinData, defTable);
            }
            }
            

只多一个测试……

请注意这个测试用例没有测试边界用例,例如:如果两个口令字段(newPassword1newPassword2())不匹配。谢天谢地,一旦设置好了,添加另一个测试用例并不难。在清单 12 中,验证了如果两个值不匹配,就生成 ActionError,用户 “jane” 口令在数据库中的值保持不变


清单 12. 添加新测试
public void testExecuteWithErrors() throws Exception{
            setRequestPathInfo("/changePasswordSubmit");
            addRequestParameter("username","jane");
            addRequestParameter("currentPassword","admin");
            addRequestParameter("newPassword1","meme");
            addRequestParameter("newPassword2","emem");
            actionPerform();
            verifyActionErrors(
            new String[]{"error.changePassword.passwordsDontMatch"});
            verifyPassword("test/conf/dbunit-expect-user-same.xml");
            }
            

在清单 12 中,我验证了 清单 2 中的逻辑正确地捕捉到了口令值不匹配的情况。MockStrutsTestCase 类包含一个方便方法可以断言错误条件,这个方法是 verifyActionErrors(),在这个方法中,错误 String 被传递进来进行验证。还请注意,数据库被检查,这次是根据另一个包含相同值的文件(在这个示例中,username “jane” 有一个未加密的 password “admin”)。





回页首


Struts 的集成测试

多数 Struts 应用程序不会 很快消失,所以重要的是知道如何在重写之前用开发人员测试构建一定层次的保证。这个月,我介绍了在测试 Struts 遗留应用程序时的一些挑战,并介绍了如何用 StrutsTestCase 和 DbUnit 处理它们。

StrutsTestCase 只要配置正确就会处理 Struts 的工作,而 DbUnit 处理与数据库有关的代码的工作。一起使用这两个框架,可以在 Struts 应用程序上进行集成级别的测试,而不用通过更高层次的框架(例如 JWebUnit 或 Selenium)模拟浏览器(也是一个值得采用的方法,但是生成的结果非常不同。)

Struts 应用程序对测试来说 具有挑战性的,并且没有解决的方法。这个困难是 Struts 框架被更加创新的框架所掩盖的原因之一,特别是那些解决了测试容易问题的框架。另一方面,就像我在这里介绍的,测试 Struts 可能的 —— 只是需要费些力气。

延伸阅读

文章来源于领测软件测试网 https://www.ltesting.net/


关于领测软件测试网 | 领测软件测试网合作伙伴 | 广告服务 | 投稿指南 | 联系我们 | 网站地图 | 友情链接
版权所有(C) 2003-2010 TestAge(领测软件测试网)|领测国际科技(北京)有限公司|软件测试工程师培训网 All Rights Reserved
北京市海淀区中关村南大街9号北京理工科技大厦1402室 京ICP备10010545号-5
技术支持和业务联系:info@testage.com.cn 电话:010-51297073

软件测试 | 领测国际ISTQBISTQB官网TMMiTMMi认证国际软件测试工程师认证领测软件测试网