测试简介

上一章节,我们一步步开发了一个完整的服务程序DemoService。代码是写完了,如何验证我们写的代码是否正常工作呢,比如自定义的LightMsgMarshaller逻辑是否正确,DemoService能否正确处理APP的请求,能否正确控制智能灯,能否正确接收智能灯的汇报消息,能否将汇报数据写入云端存储等,都少不了测试。测试根据阶段分为多种,比如单元测试、模块测试、集成测试等。考虑到后端服务的复杂性,ablecloud提供了多种测试方案,下面会一一介绍。

准备工作

从我们实现DemoService的过程,我们可以整理出很多测试点,即所谓的test case。由于ablecloud是一个完整的智能硬件PAAS平台,服务开发框架只是其中的一部分,还有console控制台,后端云服务等。因此在开始测试前,咱们需要做一些准备工作。 简单回顾下,在开发框架概述章节,我们知道了开发者主域子域等基本概念。其中开发者主域需要你在console上发起申请,ablecloud的工作人员会第一时间为你创建好并告知你。然后你就可以登录并创建产品了,子域名在创建产品的时候自动创建,具体请参看console的使用手册产品管理章节。 因此,我们要做的准备工作如下:

  1. 申请开发者帐号,主域名,并在console上创建产品(子域);

注:以上准备工作需要在测试环境的上进行操作。

单元测试

准备工作做好后,就可以开始我们的测试了,我们的单元测试采用org.apache.maven.surefire插件结合junit来完成。

测试LighMsgMarshaller

该测试很简单,不与任何后端服务交互,纯粹的测试计算逻辑,用于测试序列化/反序列化逻辑正确与否。

package com.ablecloud.demo;

import com.ablecloud.service.ACDeviceMsg;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.*;

public class LightMsgMarshallerTest {
    @Before
    public void setUp() throws Exception {
    }

    @After
    public void tearDown() throws Exception{
    }

    @Test
    public void testLightMsg() {
        byte ledOnOff = 1;
        LightMsg msg = new LightMsg(ledOnOff);
        LightMsgMarshaller marshaller = new LightMsgMarshaller();
        ACDeviceMsg deviceMsg = new ACDeviceMsg(68, msg);
        byte[] result = null;
        try {
            result = marshaller.marshal(deviceMsg);
            assertNotNull(result);
            assertEquals(4, result.length);
        } catch (Exception e) {
            System.out.println("marshal light msg error: " + e.toString());
            fail("test marshal light msg fail");
        }

        try {
            ACDeviceMsg newDeviceMsg = marshaller.unmarshal(68, result);
            assertEquals(68, newDeviceMsg.getCode());
            LightMsg newMsg = (LightMsg)(newDeviceMsg.getContent());
            assertEquals(1, newMsg.getLedOnOff());
        } catch (Exception e) {
            System.out.println("unmarshal light msg error: " + e.toString());
            fail("test unmarshal light msg fail");
        }
    }
}

注:测试case的执行,也是通过mvn package来驱动并查看测试结果,在ablecloud提供的示例pom.xml中,该命令除了将开发的服务打包成jar文件外,如果开发者编写了单测代码,也会执行单元测试。

测试DemoService

具体的服务代码测试相对复杂,一方面其依赖的云端服务比较多,另一方面作为服务框架,在没有client,没有设备的情况下驱动测试,需要一些技巧。为此,ablecloud为开发者提供了一系列便于测试用的功能,这里详细介绍下。

测试桩

从前面的场景分析我们知道,开发的DemoService会和等交互,但是我们在开发服务的过程,很可能智能灯也在研发之中,还没有发布硬件产品。这中情况在生产环境很常见,我们后端服务开发者不需要也不应该等待硬件设备开发完毕才做相应的功能测试。为此,ablecloud在服务开发框架中提供了设备桩ACDeviceStub功能,开发者只需要依照此接口实现具体的设备桩即可。 在服务开发demo章节我们演示了整个服务完整的功能代码,没有提到测试桩相关的内容。这里再把缺少的桩代码补全,示例的桩很简单,实际上你可以任意扩展,比如在桩中模拟灯的各种状态,代码如下:

package com.ablecloud.demo;

import com.ablecloud.service.ACDeviceMsg;
import com.ablecloud.service.ACDeviceStub;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class LightStub extends ACDeviceStub {
    private static final Logger logger = LoggerFactory.getLogger(LightStub.class);

    /**
     * 收到控制灯的命令,并做出相应。这里响应的code为102
     */
    public void handleControlMsg(String majorDomain, String subDomain,
                                 ACDeviceMsg req, ACDeviceMsg resp) throws Exception {
        int code = req.getCode();
        if (code != LightMsg.CODE) {
            logger.warn("got an incorrect opcode[" + code + "]");
            return;
        }
        resp.setCode(102);      // return code from light is 102
        LightMsg reqMsg = (LightMsg)req.getContent();
        LightMsg respMsg = new LightMsg(reqMsg.getLedOnOff());
        resp.setContent(respMsg);
    }
}

注:无论是设备桩,还是服务桩,仅在测试test模式生效,正式生产production环境无效。

有了测试桩,我们在实现的DemoService中增加一个辅助接口,用于把设备桩设置到ac框架中,如下:

//////////////////////
// for test
public void addDeviceStub(String subDomain, ACDeviceStub stub) {
    ac.addDeviceStub(subDomain, stub);
}

ACAccountMgrForTest

服务框架接收的命令大部分来自于APP端,因此需要创建一些测试用户,以便模拟客户发起的请求,该类便是用于此类作用。需要注意的是,该类接口只在测试环境中正常工作,具体定义如下:

public interface ACAccountMgrForTest extends ACAccountMgr {
    /**
     * 注册一个用户
     * @param email     用户邮箱
     * @param phone     用户电话
     * @param password  用户密码
     * @return
     * @throws Exception
     */
    public ACAccount register(String email, String phone, String password) throws Exception;

    /**
     * 注册一个来自第三方平台的用户。
     * @param openId 新用户在第三方平台上的ID。
     * @param provider 第三方平台的标识符。
     * @return
     * @throws Exception
     */
    public ACAccount registerWithOpenId(String openId, String provider) throws Exception;

    /**
     * 开发者接口,删除一个用户
     * @param account   用户邮箱或电话
     * @return
     * @throws Exception
     */
    public long deleteAccount(String account) throws Exception;

    /**
     * 清除开发者主域下的所有帐号数据
     * @throws Exception
     */
    public void cleanAll() throws Exception;
}

ACBindMgrForTest

为了便于对UDS进行单元测试,可以模拟APP的基本操作,包括设备绑定,解绑,更改设备的owner等。另外提供了cleanAllData和unbindUser接口,清理单元测试中产生的数据,用于单元测试的可重复执行。需要注意的是,该类接口只在测试环境中正常工作,具体定义如下:

public interface ACBindMgrForTest {
    /**
     * 绑定一个设备,一个设备必须绑定后才能控制
     * @param physicalDeviceId  设备的逻辑id
     * @param name              给设备起的名字
     * @return
     * @throws Exception
     */
    public ACUserDevice bindDevice(String physicalDeviceId, String name) throws Exception;

    /**
     * 解绑一个设备,解绑后不能再控制
     * @param deviceId    设备的逻辑id
     * @return
     * @throws Exception
     */
    public void unbindDevice(long deviceId) throws Exception;

    /**
     * 管理员接口,更改一个逻辑ID对应的物理设备
     * @param deviceId          设备的逻辑id
     * @param physicalDeviceId  新设备的物理id
     * @throws Exception
     */
    public void changeDevice(long deviceId, String physicalDeviceId) throws Exception;

    /**
     * 管理员接口,更改设备管理员
     * @param deviceId    设备的逻辑id
     * @param userId      新设备的物理id
     * @throws Exception
     */
    public void changeOwner(long deviceId, long userId) throws Exception;

    /**
     * 管理员接口,绑定一个设备和一个帐号(手机/邮箱)
     * @param deviceId     设备的逻辑id
     * @param account      帐号手机或邮箱
     * @return
     * @throws Exception
     */
    public void bindDeviceWithUser(long deviceId, String account) throws Exception;

    /**
     * 管理员接口,解绑一个设备和一个普通用户
     * @param deviceId     设备的逻辑id
     * @param userId       用户id
     * @return
     * @throws Exception
     */
    public void unbindDeviceWithUser(long deviceId, long userId) throws Exception;

    /**
     * 开发者接口,解绑一个用户的所有设备
     * 如果该用户是某个设备的管理员,则该设备的所有绑定关系被清除,该设备被删除
     * @throws Exception
     */
    public void unbindUser(long userId) throws Exception;

    /**
     * 清除开发者所属主域下的所有分组/设备/成员相关数据
     * @throws Exception
     */
    public void cleanAll() throws Exception;
}

ACStoreForTest

我们知道,测试过程会产生数据,如果服务用到了ablecloud云端存储,也会在云端存储中存储一些测试用的数据。考虑到我们的单元测试会很频繁的运行,因此,在每次测试执行前(junit的@Before或@BeforClass)或执行后(junit的@After或@AfterClass),需要对测试数据进行清理。该类便提供了创建/删除数据分类(类似于table)功能,定义如下:

public interface ACStoreForTest {
    /**
     * 创建一个class
     */
    public interface CreateClass {
        public CreateClass addEntityGroupKey(String attrName, long attrType) throws Exception;
        public CreateClass addPrimaryKey(String attrName, long attrType) throws Exception;
        public void execute() throws Exception;
    }

    /**
     * 删除一个class
     */
    public interface DeleteClass {
        public void execute() throws Exception;
    }

    /**
     * 创建一个class
     * @param className     要创建的class名
     * @return
     */
    public abstract ACStoreForTest.CreateClass createClass(String className);

    /**
     * 删除一个class
     * @param className     要删除的class名
     * @return
     */
    public abstract ACStoreForTest.DeleteClass deleteClass(String className);
}

获取测试接口

前面介绍了三个辅助测试的类ACAccountMgrForTestACDeviceMgrForTestACStoreForTest,均可以通过统一的AC框架获取,ablecloud在ACCloud中也提供了默认实现,开发者可直接使用。相关的获取接口如下:

/**
 * 获取用于单元测试的帐号管理器,可以注册用户等
 *
 * @param context   开发者的context
 * @return
 */
public abstract ACAccountMgrForTest accountMgrForTest(ACContext context);

/**
 * 获取用于单元测试的设备管理器,可以创建分组/绑定设备等
 *
 * @param context   用户的context
 * @return
 */
public abstract ACDeviceMgrForTest deviceMgrForTest(ACContext context);

/**
 * 用于测试时清空数据操作
 *
 * @param context   开发者的context
 * @return
 */
public abstract ACStoreForTest storeForTest(ACContext context);

测试demo

通过前面的介绍,开发者在开发服务的过程,大部分功能是实现handleMsghandleDeviceMsg的各个handler,因此测试工作也集中组对各个handler的测试。在单元测试的过程,无须通过任何client工具驱动,即可完成自动化的单元测试。

这里通过一个完整的测试代码演示如何对DemoService进行测试,测试代码中有详细的注释,请仔细阅读代码中的注释,相信你能够比较清晰的了解整个单元测试的代码编写流程。

package com.ablecloud.demo;

import com.ablecloud.common.ACConfiguration;
import com.ablecloud.service.*;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.FixMethodOrder;
import org.junit.runners.MethodSorters;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import java.util.List;

@FixMethodOrder(MethodSorters.NAME_ASCENDING)
public class DemoServiceTest {
    // 我们会在多个测试case中用到以下的成员,并且只需要初始化一次,
    // 因此我们定义为static的类型
    private static ACConfiguration config;  // 测试过程的配置信息,在cloudservice-conf.xml中配置
    private static AC ac;                   // 测试用的AC框架,正式环境由框架初始化,开发者不用关注
    private static DemoService demoService; // 我们开发的服务
    private static ACAccount account;       // 用于保存测试的帐号信息
    private static ACGroup group;           // 用于保存测试的分组信息
    private static ACDevice light;          // 用于保存测试的设备信息

    // 在所有的test case执行前初始化一次
    @BeforeClass
    public static void setUp() throws Exception {
        try {
            // 这里初始化config对象,必须指定测试环境配置文件的绝对路径,否则会找不到配置文件
            config = new ACConfiguration("/home/work/ablecloud/config/cloudservice-conf-test.xml");
            ac = AC.getTestAc(config);          // 需要调用该接口,通过AC的获取用于测试的ac框架

            demoService = new DemoService();
            demoService.setEnv(ac, config);     // 需要调用该接口,将框架ac赋予demoService
            demoService.init();                 // 初始化demoService,将marshshaller设置给ac

            // create one test account, using developer(service) context
            account = ac.accountMgrForTest(ac.newContext()).register("test@ablecloud.cn", "13100000000", "pswd");
            // create one test group, using user context
            ACContext context = ac.newContext(account.getUid());
            group = ac.deviceMgrForTest(context).createGroup("group1");
            // bind one test device, using user context
            light = ac.deviceMgrForTest(context).bindDevice(group.getId(), "11111111", "light1");

            // create data class
            ac.storeForTest(ac.newContext()).createClass("light-action-data")
                                            .addEntityGroupKey("groupId", ACStore.INT_TYPE)
                                            .addPrimaryKey("groupId", ACStore.INT_TYPE)
                                            .addPrimaryKey("time", ACStore.INT_TYPE)
                                            .execute();
            Thread.sleep(3000);     // 创建数据分类是一个异步操作,在真正开始测试前,睡眠一段时间(这里是3秒)
        } catch (Exception e) {
            e.printStackTrace();
            fail("set up fail");
        }
    }

    @AfterClass
    public static void tearDown() throws Exception {
        // clear all test data
        ac.accountMgrForTest(ac.newContext()).cleanAll();   // 注意,这里需要传入开发者context
        ac.deviceMgrForTest(ac.newContext()).cleanAll();    // 注意,这里需要传入开发者context
        ac.storeForTest(ac.newContext()).deleteClass("light-action-data")
                                        .execute();         // 注意,这里需要传入开发者context
    }

    @Test
    public void test1ControlLight() throws Exception {
        try {
            // service handle request from app, pass through user context
            ACContext context = ac.newContext(account.getUid());
            demoService.addDeviceStub("light", new LightStub());    // 添加一个灯的桩,子域为"light"

            // 下面构造client发送的请求参数
            ACMsg req = new ACMsg();
            req.setContext(context);            // 将上面创建的用户context传入
            req.setName("controlLight");        // 设置请求消息的名字
            req.put("deviceId", light.getId()); // 设置要控制灯的逻辑id
            req.put("action", "on");            // "on"表示开灯

            // 构造响应消息,用于接收服务端返回的消息
            ACMsg resp = new ACMsg();
            // 这里直接调用服务的处理handler,驱动测试
            demoService.handleMsg(req, resp);
            // 服务发送消息给设备后,设备会将处理结果代码返回
            // 在我们实现的LightStub中,返回的code为102,比较结果是否正确
            assertEquals(102, resp.get("code"));
        } catch (Exception e) {
            e.printStackTrace();
            fail("test control light fail");
        }
    }

    @Test
    public void test2LightReportAndQuery() throws Exception {
        // 先测试智能灯上报消息,将上报数据写入云端存储中
        try {
            int opCode = LightMsg.REPORT_CODE;      // 灯上报时的命令号
            LightMsg lightMsg = new LightMsg((byte)1);  // 1--on
            ACDeviceMsg acDeviceMsg = new ACDeviceMsg(opCode, lightMsg);
            // 这里直接调用服务的设备消息处理handler,驱动测试
            // 这里由于设备汇报的消息中context没有用户id信息,随便填一个大于0的id即可
            demoService.handleDeviceMsg(ac.newContext(1), light.getId(), acDeviceMsg);
        } catch (Exception e) {
            e.printStackTrace();
            fail("test light report fail");
        }

        // 这里用上面写入云端存储的数据来驱动测试app发来的数据查询请求处理handler
        try {
            ACMsg req = new ACMsg();
            ACMsg resp = new ACMsg();
            // 这里是模拟用户发的查询请求,因此需要设置用户的context
            req.setContext(ac.newContext(account.getUid()));
            req.setName("queryData");
            req.put("groupId", group.getId());
            req.put("startTime", 0L);
            req.put("endTime", System.currentTimeMillis());
            // 这里直接调用服务的消息处理handler,驱动测试
            demoService.handleMsg(req, resp);
            List<ACObject> zos = resp.get("actionData");
            assertEquals(1, zos.size());
            for (ACObject zo : zos) {
                System.out.println("group[" + zo.get("groupId") + "] time[" + zo.get("time") +
                                   "] action[" + zo.get("action") + "]");
            }
        } catch (Exception e) {
            e.printStackTrace();
            fail("test query data fail");
        }
    }
}

注:可以看到,所有的单元test case,我们均是直接调用handleMsghandleDeviceMsg驱动测试,无需编写或使用client工具。 此外,非常重要的一点,我们需要使用4.11及以上的junit,并且使用标签@FixMethodOrder(MethodSorters.NAME_ASCENDING)固定test case的执行顺序,因为我们的test case可能前后依赖,比如在test1ControlLight中写入数据,在后面的test case中会读取。因此,在为测试函数命名的时候,如果有前后依赖关系的,需要考虑命名的规则按ascii字典序。

集成测试

单元测试通过后,我们还需要进行集成测试,因为单元测试的过程,我们并没有真正启动开发的服务,某些场景或代码路径不一定覆盖全,比如网络通信部分、服务框架部分等。 由于大部分逻辑在单元测试阶段均做了,因此集成测试相对简单,大致步骤如下:

  1. 按照开发环境设置章节,在本地机器或任意开发机上启动自定义服务
  2. 用任意客户端发送http请求,例如linux下用curl或自己开发的客户端都可以

注:ablcloud提供的多种服务,其client和service之间的通信,底层采用http协议,方法为POST,因此任何能发送http请求的工具均可以用作服务测试的客户端。

client请求示例

这里以linux下的curl工具为例,向我们开发的DemoService发送开灯命令:

curl -v -X POST -H "Content-Type:application/x-zc-object" -H "X-Zc-major-Domain:ablecloud" -H "X-Zc-Sub-Domain:test" -H "X-Zc-User-Id:1" -H "X-Zc-Timestamp:1417606141500864046" -H "X-Zc-Timeout:60000" -H "X-Zc-Nonce:exzabc9xy10a2cb3" -H "X-Zc-User-Signature:b7b53a8b6615bdfacc457735e1708b7455f8a165" -d '{"deviceId":1,"action":"on"}' http://127.0.0.1:1234/DemoService/v1/controlLight'

其中-H指定的各个头域都是ACContext中的字段,-d指定的内容,是构造的ACMsg中的请求参数,http://127.0.0.1:1234/DemoService/v1/controlLight中的ip:port是你启动DemoService的主机和端口号,DemoService为我们开发的服务名,v1为服务的版本,controlLight即为具体的方法。

注:如果用于测试的账号是开发者,则在构造http请求的时候,需要如下三个头域:X-Zc-Developer-Id、X-Zc-Access-Key、X-Zc-Developer-Signature。如果用于测试的账号是普通用户,则如下需要如下两个头域:X-Zc-User-Id、X-Zc-User-Signature。