测试驱动开发实践3————testSave之新增用户
内容指引
1.确定新增用户的业务规则
2.根据业务规则设计测试用例
3.为测试用例赋值并驱动开发
一、确定新增用户的规则
1.注册用户允许通过“用户名+密码”、“手机号+密码”或“Email+密码”来注册:
2.对于通过“用户名+密码”注册的用户,手机号和Email字段可以为空;
3.对于通过“手机号+密码”注册的用户,用户名和Email字段可以为空;
4.对于通过“Email+密码”注册的用户,用户名和手机号字段可以为空;
5.但是,用户名、手机号和Email不可同时为空,必须至少有其中一个值不为空,并且:
6.如果用户名字段有值,那么该字段必须由3-64位大小写字母和数字组成,且值不可与其它用户名重复;
7.如果手机号字段有值,那么该字段必须符合手机号格式,且值不可与其它手机号重复;
8.如果Email字段有值,那么该字段必须符合Email格式,且值不可与其它Email重复;
9.密码允许6-16位大小写字母、数字及下划线组成;
二、根据业务规则设计测试用例
设计测试用例的技巧
先列出可能新增数据的方式,以此分组。如可以根据“用户名+密码”、“手机号+密码”和“Email+密码”分为三组,然后对此三组分别进一步细化设计;
先合法,再非法。先设计输入合法值时的用例,再设计非法值时的用例;
设计非法值用例时针对每个可能的参数各个击破。如“用户名+密码”的非法值就分为“用户名不合法”和“密码不合法”;
对于每个非法值的设计,先考虑输入校验,再考虑逻辑校验。如,对于用户名不合法,从输入校验上可以分为“用户名为空”、“用户名长度不合法(3-64位)”和“用户名违反大小写字母及数字组成的规则”,从逻辑校验上需要设计“用户名已存在(违反唯一性)”的用例。密码不合法的用例可同样参考这个顺序设计。
可借助Xmind思维导图工具协助用例设计:

最终我们设计出如下测试用例:
1.用户名+密码(注册方式一)
1.1 用户名、密码均合法;
1.2 用户名不合法:
1.2.1 用户名为空;
1.2.2 用户名长度不合法(3-64位);
1.2.3 用户名违反大小写字母及数字组成的规则;
1.2.4 用户名已存在(违反唯一性);
1.3 密码不合法
1.3.1 长度不符合6-16位;
1.3.2 违反大小写字母、数字及下划线组成的规则;
2.手机号+密码(注册方式二)
2.1 手机号、密码均合法;
2.2 手机号不合法:
2.1.1 手机号为空;
2.1.2 不符合手机号格式;
2.1.3 手机号已存在;
2.3 密码不合法:同1.3
3.Email+密码(注册方式三)
3.1 Email、密码均合法;
3.2 Email不合法
3.2.1 Email为空;
3.2.2 不符合Email格式;
3.2.3 Email已存在;
3.3 密码不合法:同1.3
三、为测试用例赋值并驱动开发
首先打开测试方法testSave,这个方法中会依次测试新增用户和修改用户的逻辑,定位在“测试新增用户”处:

首先,我们完成第一个任务“//TODO 列出新增用户测试用例清单”。将“测试用例清单文档”写入多行注释中,作为测试清单。以后还有可能往这个清单中增加新的测试用例。让测试用例代码成为有价值的开发文档;
/** * 测试新增用户 */ /** * 列出新增用户测试用例清单 * 1.用户名+密码(注册方式一) 1.1 用户名、密码均合法; 1.2 用户名不合法: 1.2.1 用户名为空; 1.2.2 用户名长度不合法(3-64位); 1.2.3 用户名违反大小写字母及数字组成的规则; 1.2.4 用户名已存在(违反唯一性); 1.3 密码不合法 1.3.1 长度不符合6-16位; 1.3.2 违反大小写字母、数字及下划线组成的规则; 2.手机号+密码(注册方式二) 2.1 手机号、密码均合法; 2.2 手机号不合法: 2.1.1 手机号为空; 2.1.2 不符合手机号格式; 2.1.3 手机号已存在; 2.3 密码不合法:同1.3 3.Email+密码(注册方式三) 3.1 Email、密码均合法; 3.2 Email不合法 3.2.1 Email为空; 3.2.2 不符合Email格式; 3.2.3 Email已存在; 3.3 密码不合法:同1.3 */
“云开发”平台生成的初始化代码中已经为我们设计了一个”测试新增用户“的测试模版,由“测试用例赋值”、“模拟请求”及“测试断言”组成。代码如下:
测试用例赋值
/**---------------------测试用例赋值开始---------------------**/ //TODO 将下面的null值换为测试参数 User user = new User(); user.setUuid(null); user.setUsername(null); user.setPassword(null); user.setNickname(null); user.setPhoto(null); user.setMobile(null); user.setEmail(null); user.setQq(null); user.setWeChatNo(null); user.setSex(null); user.setMemo(null); user.setScore(null); user.setLastLogin(null); user.setLoginIP(null); user.setLoginCount(null); user.setLock(null); user.setLastLockTime(null); user.setLockTimes(null); Long operator = null; Long id = 4L; /**---------------------测试用例赋值结束---------------------**/
模拟请求
this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("uuid",user.getUuid()) .param("username",user.getUsername()) .param("password",user.getPassword()) .param("nickname",user.getNickname()) .param("photo",user.getPhoto()) .param("mobile",user.getMobile()) .param("email",user.getEmail()) .param("qq",user.getQq()) .param("weChatNo",user.getWeChatNo()) .param("sex",user.getSex().toString()) .param("memo",user.getMemo()) .param("score",user.getScore().toString()) .param("lastLogin",user.getLastLogin().toString()) .param("loginIP",user.getLoginIP()) .param("loginCount",user.getLoginCount().toString()) .param("lock",user.getLock().toString()) .param("lastLockTime",user.getLastLockTime().toString()) .param("lockTimes",user.getLockTimes().toString()) .param("operator",operator.toString()) )
测试断言
// 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"user" .andExpect(content().string(containsString("user"))) // 检查返回的数据节点 .andExpect(jsonPath("$.user.userId").value(id)) .andExpect(jsonPath("$.user.uuid").value(user.getUuid())) .andExpect(jsonPath("$.user.username").value(user.getUsername())) .andExpect(jsonPath("$.user.password").value(user.getPassword())) .andExpect(jsonPath("$.user.nickname").value(user.getNickname())) .andExpect(jsonPath("$.user.photo").value(user.getPhoto())) .andExpect(jsonPath("$.user.mobile").value(user.getMobile())) .andExpect(jsonPath("$.user.email").value(user.getEmail())) .andExpect(jsonPath("$.user.qq").value(user.getQq())) .andExpect(jsonPath("$.user.weChatNo").value(user.getWeChatNo())) .andExpect(jsonPath("$.user.sex").value(user.getSex())) .andExpect(jsonPath("$.user.memo").value(user.getMemo())) .andExpect(jsonPath("$.user.score").value(user.getScore())) .andExpect(jsonPath("$.user.lastLogin").value(user.getLastLogin())) .andExpect(jsonPath("$.user.loginIP").value(user.getLoginIP())) .andExpect(jsonPath("$.user.loginCount").value(user.getLoginCount())) .andExpect(jsonPath("$.user.lock").value(user.getLock())) .andExpect(jsonPath("$.user.lastLockTime").value(user.getLastLockTime())) .andExpect(jsonPath("$.user.lockTimes").value(user.getLockTimes())) .andExpect(jsonPath("$.user.creationTime").isNotEmpty()) .andExpect(jsonPath("$.user.creatorUserId").value(operator)) .andExpect(jsonPath("$.user.lastModificationTime").isEmpty()) .andExpect(jsonPath("$.user.lastModifierUserId").value(0)) .andExpect(jsonPath("$.user.isDeleted").value(false)) .andExpect(jsonPath("$.user.deletionTime").isEmpty()) .andExpect(jsonPath("$.user.deleterUserId").value(0)) .andReturn();
每一个测试用例的测试代码均由“测试用例赋值+模拟请求+测试断言组成”,测试用例赋值不同,模拟请求的参数和测试断言就应相应调整。
1.用户名、密码均合法:
第一个新增用户的测试用例代码,就在原测试模版的基础上修改即可。修改后代码:
/**---------------------测试用例赋值开始---------------------**/ // 用户名、密码均合法 User user = new User(); user.setUsername("Manon"); user.setPassword("123456"); Long operator = 1L; Long id = 7L; /**---------------------测试用例赋值结束---------------------**/ this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user.getUsername()) .param("password",user.getPassword()) ) // 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"user" .andExpect(content().string(containsString("user"))) // 检查返回的数据节点 .andExpect(jsonPath("$.user.userId").value(id)) .andExpect(jsonPath("$.user.uuid").isNotEmpty()) .andExpect(jsonPath("$.user.username").value(user.getUsername())) .andExpect(jsonPath("$.user.password").value(user.getPassword())) .andExpect(jsonPath("$.user.nickname").isEmpty()) .andExpect(jsonPath("$.user.photo").isEmpty()) .andExpect(jsonPath("$.user.mobile").isEmpty()) .andExpect(jsonPath("$.user.email").isEmpty()) .andExpect(jsonPath("$.user.qq").isEmpty()) .andExpect(jsonPath("$.user.weChatNo").isEmpty()) .andExpect(jsonPath("$.user.sex").value(0)) .andExpect(jsonPath("$.user.memo").isEmpty()) .andExpect(jsonPath("$.user.score").value(0.0)) .andExpect(jsonPath("$.user.lastLogin").isEmpty()) .andExpect(jsonPath("$.user.loginIP").isEmpty()) .andExpect(jsonPath("$.user.loginCount").value(0)) .andExpect(jsonPath("$.user.lock").value(false)) .andExpect(jsonPath("$.user.lastLockTime").isEmpty()) .andExpect(jsonPath("$.user.lockTimes").value(0)) .andExpect(jsonPath("$.user.creationTime").isNotEmpty()) .andExpect(jsonPath("$.user.creatorUserId").value(0)) .andExpect(jsonPath("$.user.lastModificationTime").isEmpty()) .andExpect(jsonPath("$.user.lastModifierUserId").value(0)) .andExpect(jsonPath("$.user.isDeleted").value(false)) .andExpect(jsonPath("$.user.deletionTime").isEmpty()) .andExpect(jsonPath("$.user.deleterUserId").value(0)) .andReturn();
代码解说
/**---------------------测试用例赋值开始---------------------**/ // 用户名、密码均合法 User user = new User(); user.setUsername("Manon"); user.setPassword("123456"); Long operator = 1L; Long id = 7L; /**---------------------测试用例赋值结束---------------------**/
在测试用例赋值部分,我们输入合法的用户名“Manon”,合法的密码“123456”。
给operator赋值为1,这个变量在修改用户的时候会用上,因为是Long型,所以写为“1L”。
为id赋值为“7L”。为什么输入为“7”?这里需要解释一下:
在UserControllerTest类运行时,先会执行testList方法,接着执行testSave方法。在执行testList方法前执行了setUp方法,其中添加了一条数据,id为“1”。接着,在testList方法中添加了4条数据,所以testList方法执行完时,User的数据库表主键id变为“5”了,虽然执行完testList方法后这5条数据都因为事务回滚清空了,但是id值“1-5”已被占用了。接着准备执行testSave方法前又执行了一次setUp方法,再次添加了一条数据,id变为“6”。所以,在testSave中添加的第一条数据的主键id值应为“7”,因为是Long型字段,所以赋值为“7L”。如果在setUp或testList中插入了更多数据,那么这个值也应相应调整,原理已说明。
this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user.getUsername()) .param("password",user.getPassword()) )
这段代码是利用mockMvc模拟post访问"/user/save"这个微服务Rest控制器接口,模拟表单提交了两个参数“username”和“password”,值已经在上面的测试用例赋值中。最后一个是设定本地环境为中文,所以报错信息会用中文提示,如果是英文环境,会通过英文提示。
// 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"user" .andExpect(content().string(containsString("user"))) // 检查返回的数据节点 .andExpect(jsonPath("$.user.userId").value(id)) .andExpect(jsonPath("$.user.uuid").isNotEmpty()) .andExpect(jsonPath("$.user.username").value(user.getUsername())) .andExpect(jsonPath("$.user.password").value(user.getPassword())) .andExpect(jsonPath("$.user.nickname").isEmpty()) .andExpect(jsonPath("$.user.photo").isEmpty()) .andExpect(jsonPath("$.user.mobile").isEmpty()) .andExpect(jsonPath("$.user.email").isEmpty()) .andExpect(jsonPath("$.user.qq").isEmpty()) .andExpect(jsonPath("$.user.weChatNo").isEmpty()) .andExpect(jsonPath("$.user.sex").value(0)) .andExpect(jsonPath("$.user.memo").isEmpty()) .andExpect(jsonPath("$.user.score").value(0.0)) .andExpect(jsonPath("$.user.lastLogin").isEmpty()) .andExpect(jsonPath("$.user.loginIP").isEmpty()) .andExpect(jsonPath("$.user.loginCount").value(0)) .andExpect(jsonPath("$.user.lock").value(false)) .andExpect(jsonPath("$.user.lastLockTime").isEmpty()) .andExpect(jsonPath("$.user.lockTimes").value(0)) .andExpect(jsonPath("$.user.creationTime").isNotEmpty()) .andExpect(jsonPath("$.user.creatorUserId").value(0)) .andExpect(jsonPath("$.user.lastModificationTime").isEmpty()) .andExpect(jsonPath("$.user.lastModifierUserId").value(0)) .andExpect(jsonPath("$.user.isDeleted").value(false)) .andExpect(jsonPath("$.user.deletionTime").isEmpty()) .andExpect(jsonPath("$.user.deleterUserId").value(0)) .andReturn();
其中:
.andDo(print())
这个是用来将请求及返回结果打印到控制台中,方便测试人员查看及分析。
// 检查状态码为200 .andExpect(status().isOk())
这个是基本的检查,正确的请求返回的状态码应为“200”,如果是“404”或其它值,就代表有问题。
// 检查内容有"user" .andExpect(content().string(containsString("user")))
如果新增数据成功,那么应返回user的实例json数据,其中含有user(和领域类名称相同)这个节点。如果表单验证通不过,则返回“formErrors”节点,如果发生异常,则返回“errorMessage”节点。
// 检查返回的数据节点 .andExpect(jsonPath("$.user.userId").value(id)) .andExpect(jsonPath("$.user.uuid").isNotEmpty()) .andExpect(jsonPath("$.user.username").value(user.getUsername())) .andExpect(jsonPath("$.user.password").value(user.getPassword())) .andExpect(jsonPath("$.user.nickname").isEmpty()) .andExpect(jsonPath("$.user.photo").isEmpty()) .andExpect(jsonPath("$.user.mobile").isEmpty()) .andExpect(jsonPath("$.user.email").isEmpty()) .andExpect(jsonPath("$.user.qq").isEmpty()) .andExpect(jsonPath("$.user.weChatNo").isEmpty()) .andExpect(jsonPath("$.user.sex").value(0)) .andExpect(jsonPath("$.user.memo").isEmpty()) .andExpect(jsonPath("$.user.score").value(0.0)) .andExpect(jsonPath("$.user.lastLogin").isEmpty()) .andExpect(jsonPath("$.user.loginIP").isEmpty()) .andExpect(jsonPath("$.user.loginCount").value(0)) .andExpect(jsonPath("$.user.lock").value(false)) .andExpect(jsonPath("$.user.lastLockTime").isEmpty()) .andExpect(jsonPath("$.user.lockTimes").value(0)) .andExpect(jsonPath("$.user.creationTime").isNotEmpty()) .andExpect(jsonPath("$.user.creatorUserId").value(0)) .andExpect(jsonPath("$.user.lastModificationTime").isEmpty()) .andExpect(jsonPath("$.user.lastModifierUserId").value(0)) .andExpect(jsonPath("$.user.isDeleted").value(false)) .andExpect(jsonPath("$.user.deletionTime").isEmpty()) .andExpect(jsonPath("$.user.deleterUserId").value(0)) .andReturn();
这是对返回的json数据的进一步判断,其中:
userId的值应该等于前面定义的id值"7";
uuid的值由领域类默认赋值,所以返回值中应该已有生成的UUID值,所以这里断言该字段不为空;
username和password:返回值应该等于前面赋的参数值;
nickname、photo、mobile、email、qq、weChatNo、memo:这些未传参赋值的String字段,应该保存为空字符串,所以断言为“.isEmpty()”;
sex:性别,未赋值的情况下应保存为“0”(性别保密);
score:评分,未赋值情况下应默认保存为“0.0”;
lastLogin:最后一次登陆时间,注册时未登陆,所以应保存为null,测试断言也应是“isEmpty()”;
loginIP:登陆IP,应返回空字符串;
loginCount:登陆次数应为0;
lock:是否锁定,默认注册时应该是不锁定,所以应返回值“false”;
lastLockTime:最近锁定时间,默认注册应保存为null,所以可以用“isEmpty()断言”;
lockTimes:锁定次数,默认应保存为0,所以返回值应为“0”;
creationTime:创建时间应该有值,所以可以用“.isNotEmpty()”来断言;
creatorUserId:创建者ID,本业务特殊,刚注册时无法确定创建者ID,只有注册后才产生主键ID,所以应返回该字段Long型的默认值“0”;
lastModificationTime:最近修改时间,注册时未修改,所以应保存为null,因此返回值可用“isEmpty()”断言;
lastModifierUserId:最近修改者,应返回默认值“0”;
isDeleted:新增的数据应该是未删除的,所以该字段应返回“false”;
deletionTime:删除时间应保存为null,所以返回值可用“isEmpty()”断言;
deleterUserId:删除者,应返回默认值“0”;
执行测试
写完测试代码后,我们运行下单元测试,结果如下:

异常定位在刚刚写的测试代码中,我们查看控制台提示的出错信息:

我们发现请求返回了"formErrors",说明表单输入的校验未通过,其中第一行提示是“"codes" : [ "NotNull.user.lastLogin", "NotNull.lastLogin", "NotNull.java.util.Date", "NotNull" ],”
打开领域类“User.java”,看看lastLogin这个字段:
/** * 最后一次登录时间 */ @NotNull(groups={CheckCreate.class, CheckModify.class}) //@Future/@Past(groups={CheckCreate.class, CheckModify.class}) @Column(name = "last_login") private Date lastLogin;
我们看到默认是有一个“@NotNull”注解的,要求必须传参,值不能为null,实际上我们通过“用户名+密码”传参时是没有给lastLogin传参的,也就是lastLogin参数值允许为null,所以这里未通过校验。这里特别说明下“groups={CheckCreate.class, CheckModify.class}”,这是分组校验的设置。刚才我们测试代码中请求的网址是“/user/create”:
this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create")
打开被请求的rest控制器,看看这个方法的代码:

我们看到控制器的添加用户方法中启用了CheckCreate这个分组校验,所以在领域类中的字段校验注解中使用了这个分组属性的会生效,我们改下代码:
/** * 最后一次登录时间 */ @NotNull(groups={CheckModify.class}) @Column(name = "last_login") private Date lastLogin;
将@NotNull注解中的"CheckCreate.class"删除掉。同理,将领域类字段中用户名(username)和密码(password)之外所有字段的校验注解中的分组校验“CheckCreate.class”都去掉。修改后代码如下:
private static final long serialVersionUID = 1L; public interface CheckCreate{}; public interface CheckModify{}; /** * 用户ID */ @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "id") private Long userId; /** * 用户UUID编号 */ @Column(nullable = false, name = "uuid", unique = true, length = 36) private String uuid = UUID.randomUUID().toString(); /** * 用户名 */ @NotBlank(groups={CheckCreate.class, CheckModify.class}) @Length(min = 3, max = 64, groups={CheckCreate.class, CheckModify.class}) @Pattern(regexp = "^[A-Za-z0-9]+$", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "username", length = 64) private String username = ""; /** * 密码 */ @NotBlank(groups={CheckCreate.class, CheckModify.class}) @Length(min = 6, max = 16, groups={CheckCreate.class, CheckModify.class}) @Pattern(regexp = "^\\w+$", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "password", length = 256) private String password; /** * 昵称 */ @NotBlank(groups={CheckModify.class}) @Length(min = 1, max = 50, groups={CheckModify.class}) //@Pattern(regexp = "", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "nickname", length = 50) private String nickname = ""; /** * 照片 */ @NotBlank(groups={CheckModify.class}) @Length(min = 1, max = 50, groups={CheckModify.class}) //@Pattern(regexp = "", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "photo", length = 50) private String photo = ""; /** * 手机 */ @NotBlank(groups={CheckModify.class}) @Pattern(regexp = "((\\d{11})|^((\\d{7,8})|(\\d{4}|\\d{3})-(\\d{7,8})|(\\d{4}|\\d{3})-(\\d{7,8})-(\\d{4}|\\d{3}|\\d{2}|\\d{1})|(\\d{7,8})-(\\d{4}|\\d{3}|\\d{2}|\\d{1}))$)", groups={CheckModify.class}) @Column(nullable = false, name = "mobile", length = 15) private String mobile = ""; /** * 邮箱地址 */ @NotBlank(groups={CheckModify.class}) @Email(groups={CheckModify.class}) @Column(nullable = false, name = "email", length = 255) private String email = ""; /** * QQ */ @NotBlank(groups={ CheckModify.class}) @Length(min = 1, max = 50, groups={CheckModify.class}) //@Pattern(regexp = "", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "qq", length = 50) private String qq = ""; /** * 微信号 */ @NotBlank(groups={ CheckModify.class}) @Length(min = 1, max = 50, groups={CheckModify.class}) //@Pattern(regexp = "", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "we_chat_no", length = 50) private String weChatNo = ""; /** * 性别 */ //@Range(min=value,max=value, groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "sex") private Short sex = 0; /** * 描述 */ @NotBlank(groups={CheckModify.class}) @Length(min = 1, max = 50, groups={CheckModify.class}) //@Pattern(regexp = "", groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "memo", length = 50) private String memo = ""; /** * 评分 */ //@Range(min=value,max=value, groups={CheckCreate.class, CheckModify.class}) //@Digits(integer, fraction, groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "score") private Double score = 0.0; /** * 最后一次登录时间 */ @NotNull(groups={CheckModify.class}) @Column(name = "last_login") private Date lastLogin; /** * 最后一次登录IP */ @NotBlank(groups={CheckModify.class}) @Pattern(regexp = "^(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]{1}[0-9]{2}|[1-9]{1}[0-9]{1}|[0-9])$", groups={CheckModify.class}) @Column(nullable = false, name = "login_i_p", length = 23) private String loginIP = ""; /** * 登录次数 */ //@Range(min=value,max=value, groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "login_count") private Integer loginCount = 0; /** * 是否被锁定 */ @NotNull(groups={CheckModify.class}) //@AssertTrue/@AssertFalse(groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "lock") private Boolean lock = false; /** * 锁定时间 */ @NotNull(groups={CheckModify.class}) @Column(name = "last_lock_time") private Date lastLockTime; /** * 锁定计数器次数 */ //@Range(min=value,max=value, groups={CheckCreate.class, CheckModify.class}) @Column(nullable = false, name = "lock_times") private Integer lockTimes = 0; /** * 创建时间 */ @Column(nullable = false, name = "creation_time", updatable=false) private Date creationTime = new Date(); /** * 创建者 */ @Column(nullable = false, name = "creator_user_id", updatable=false) private long creatorUserId; /** * 最近修改时间 */ @Column(name = "last_modification_time") private Date lastModificationTime; /** * 最近修改者 */ @Column(name = "last_modifier_user_id") private long lastModifierUserId; /** * 已删除 */ @Column(nullable = false, name = "is_deleted") private Boolean isDeleted = false; /** * 删除时间 */ @Column(name = "deletion_time") private Date deletionTime; /** * 删除者 */ @Column(name = "deleter_user_id") private long deleterUserId;
再次运行单元测试:

现在返回了errorMessage,说明现在领域类的校验没有问题,但是程序运行出现了异常,现在我们通过debug的方式运行单元测试,并且在rest控制器(UserController.java的create方法)中打断点:

进入到代码的下一层方法

经过断点跟踪,我们发现服务实现层的代码中有一个将operator参数转Long型的方法,这里导致了异常:

实际上,用户注册时是无法提供operator这个操作者参数的,也就是参数为null,null转Long型导致了异常,删除该代码即可。
补充一句,如果是其他类的添加数据,operator参数是需要提供的,它会赋值给领域类的创建者这个字段,以记录操作者是谁。
再此运行单元测试,现在代码出错定位在修改用户处,代表第一个测试用例已运行通过:

现在为新增用户写第二个测试用例代码:
/**---------------------测试用例赋值开始---------------------**/ // 用户名为空 User user2 = new User(); user2.setUsername(""); user2.setPassword("123456"); operator = 1L; id = 8L; /**---------------------测试用例赋值结束---------------------**/ this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user2.getUsername()) .param("password",user2.getPassword()) ) // 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"formErrors" .andExpect(content().string(containsString("formErrors"))) // 检查返回的数据节点 .andExpect(content().string(containsString("\"code\" : \"NotBlank\""))) .andReturn();
id赋值在上一次成功添加数据后加一,变为“8L”。
通过“用户名+密码”注册时,用户名是不可以为空的,所以,正确情况下该测试应该会引发输入校验错误,返回“formErrors”,且错误信息中应含有"code : NotBlank"(用户名不能为空)的错误:

由于我们在领域类有如下注解,所以已经能控制输入的用户名不能为空(否则触发formErrors的校验反馈):

运行单元测试,错误代码定位在测试修改的代码部分,说明第二个测试用例已通过测试。现在我们写第三个单元测试代码:
/**---------------------测试用例赋值开始---------------------**/ // 用户名长度不合法(3-64位) User user3 = new User(); user3.setUsername("Ma"); user3.setPassword("123456"); operator = 1L; id = 8L; /**---------------------测试用例赋值结束---------------------**/ this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user3.getUsername()) .param("password",user3.getPassword()) ) // 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"formErrors" .andExpect(content().string(containsString("formErrors"))) // 检查返回的数据节点 .andExpect(content().string(containsString("\"code\" : \"Length\""))) .andReturn(); /**---------------------测试用例赋值开始---------------------**/ // 用户名长度不合法(3-64位) User user4 = new User(); user4.setUsername("ManonManonManonManonManonManonManonManonManonManonManonManonManon");//长度为65 user4.setPassword("123456"); operator = 1L; id = 8L; /**---------------------测试用例赋值结束---------------------**/ this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user4.getUsername()) .param("password",user4.getPassword()) ) // 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"formErrors" .andExpect(content().string(containsString("formErrors"))) // 检查返回的数据节点 .andExpect(content().string(containsString("\"code\" : \"Length\""))) .andReturn();
由于上一个用例会引发“formErrors”的错误,所以不会向数据库插入数据,所以id不会增长,这里仍然给id赋值“8L”,而不是“9L”。
我们写了两个测试代码,故意分别将用户名长度设为2位和65位,不符合“3-64”位的长度要求,因为“云开发”初始化的代码中默认已将用户名的长度启用了这个长度限制的注解,所以测试应能通过(返回formErrors错误,并指明code为Length):

运行单元测试代码,果然返回了这个formErrors,说明测试已通过(换句话说,代码已能对客户端不符合长度要求的用户名进行输入校验):

现在接着写单元测试代码:
/**---------------------测试用例赋值开始---------------------**/ // 用户名违反大小写字母及数字组成的规则,故意加入不合法的"!" User user5 = new User(); user5.setUsername("Manon!");//长度为65 user5.setPassword("123456"); operator = 1L; id = 8L; /**---------------------测试用例赋值结束---------------------**/ this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user5.getUsername()) .param("password",user5.getPassword()) ) // 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"formErrors" .andExpect(content().string(containsString("formErrors"))) // 检查返回的数据节点 .andExpect(content().string(containsString("\"code\" : \"Pattern\""))) .andExpect(content().string(containsString("\"codes\" : [ \"^[A-Za-z0-9]+$\" ],"))) .andReturn();
用户名中故意加入不合法的“!”,预期触发formErrors,并且提示不符合正则表达式“[A-Za-z0-9]+$”的校验规则,因为领域类中username已有该校验注解,所以测试通过了。

现在继续写单元测试用例代码,模拟“用户名已存在(违反唯一性)”的情况,前面我们添加用户名为“Manon”的用户且添加成功。现在数据库中已存在用户名为“Manon”的数据,我们故意再添加一条这样的数据,期望能引起报错。由于这个数据并没有违反用户名输入的基本校验(非空、3-64位、符合正则规则),而是违反唯一性(并且是对于非空值而言的唯一性),错误提示为“用户名已存在!”,这个需要通过逻辑校验,所以应返回异常:errorMessage。我们给这个异常一个编码:10001(这个编码可以根据自己的规则去编写,但是不同异常的错误编码不能相同),所以我们期望返回的结果中包含""errorMessage" : "[10001]",测试用例代码如下:
/**---------------------测试用例赋值开始---------------------**/ // 用户名已存在(违反唯一性) User user6 = new User(); user6.setUsername("Manon"); user6.setPassword("123456"); operator = 1L; id = 8L; /**---------------------测试用例赋值结束---------------------**/ this.mockMvc.perform( MockMvcRequestBuilders.post("/user/create") .param("username",user6.getUsername()) .param("password",user6.getPassword()) ) // 打印结果 .andDo(print()) // 检查状态码为200 .andExpect(status().isOk()) // 检查内容有"formErrors" .andExpect(content().string(containsString("\"errorMessage\" : \"[10001]"))) // 检查返回的数据节点 .andReturn();
对于逻辑校验而言,代码应写在服务实现层:

原来新增的用户的代码为:
if(user.getUserId()==null){ return userRepository.save(user);
修改为:
if(user.getUserId()==null){ // 以"手机号+密码"或"Email+密码"注册时,用户名可以保存为空字符串(即"用户名未设置"),但是如果用户名不为空,则不能与已存在的其它用户名重复 if(!user.getUsername().equals("")){ List<User> list = userRepository.findByUsernameIgnoringCase(user.getUsername()); if(list.size() > 0){ throw new BusinessException(ErrorCode.User_Username_Exists); } } return userRepository.save(user);
我们根据用户名查询数据库,返回一个列表,如果列表元素数量大于0,就代表数据库中已存在用户名为该值的数据了,那么我们就抛出一个“用户名已存在”的异常。这里我们查询数据是通过dao层的接口方法“userRepository.findByUsernameIgnoringCase”实现的。当前还没有该接口方法,所以我们需要在dao层写一个接口:
List<User> findByUsernameIgnoringCase(String username);
上面服务实现层代码中使用BusinessException抛出异常,这是“云开发”平台封装的异常处理类,这里简单介绍下:

上图中的三个工具方法都跟异常处理相关,代码如下:
BusinessException
package top.cloudev.team.common; /** * 自定义业务异常类 * Created by Mac.Manon on 2018/03/13 */ public class BusinessException extends RuntimeException { private static final long serialVersionUID = 1L; public BusinessException(Object Obj) { super(Obj.toString()); } }
ErrorCode
package top.cloudev.team.common; /** * 错误码枚举类 * Created by Mac.Manon on 2018/03/13 */ public enum ErrorCode { //TODO 在这里定义错误码,并将key加入国际化语言包。key组成规则:"ErrorCode."+ code //User_Username_Exists("10001"); private String code; ErrorCode(String code) { this.code = code; } @Override public String toString() { return "[" + this.code + "] : " + I18nUtils.getMessage("ErrorCode." + this.code); } }
根据代码中的提示,我们定义三个错误码:用户名已存在,手机号已存在,Email已存在。其中后面两个错误码在后面的测试用例中用得上,修改后代码如下:
package top.cloudev.team.common; /** * 错误码枚举类 * Created by Mac.Manon on 2018/03/13 */ public enum ErrorCode { ser_Username_Exists("10001"),//用户名已存在 User_Mobile_Exists("10002"),//手机号已存在 User_Email_Exists("10003");//Email已存在 private String code; ErrorCode(String code) { this.code = code; } @Override public String toString() { return "[" + this.code + "] : " + I18nUtils.getMessage("ErrorCode." + this.code); } }
根据提示,我们需要在i18n的messages配置中配置好相应的错误提示多国语言包(实现异常提示信息的本地化),语言包配置位置为资源文件夹下的"i18n/messages":

其中,中文做了64位转码,如果直接使用中文,有可能客户端出现乱码。这里介绍一个转码工具:CodeText,这是Mac系统下的一个转码工具,Windows系统下也可以找找类似工具。

英文语言包配置如下:

中文语言包和默认语言包设置一致:

I18nUtils
package top.cloudev.team.common; import javax.servlet.http.HttpServletRequest; import java.util.Locale; import java.util.ResourceBundle; /** * 国际化工具 * Created by Mac.Manon on 2018/03/13 */ public class I18nUtils { /** * 根据key获得基于客户端语言的本地化信息 * @param key * @return */ public static String getMessage(String key){ return getMessage(key,Locale.getDefault()); } /** * 根据key获得基于request指定的Locale的本地化信息 * @param key * @param request * @return */ public static String getMessage(String key, HttpServletRequest request){ if(request.getLocale() != null) return getMessage(key,request.getLocale()); else return getMessage(key); } /** * 根据key和指定的locale获得本地化信息 * @param key * @param locale * @return */ public static String getMessage(String key, Locale locale){ ResourceBundle resourceBundle = ResourceBundle.getBundle("i18n/messages",locale); return resourceBundle.getString(key); } }
现在回到服务实现类抛异常的代码:
throw new BusinessException(ErrorCode.User_Username_Exists);
当数据库中已存在该用户名时抛出如上异常。
现在运行单元测试,确实返回了我们期望的异常信息,测试通过了:

现在我们将操作系统的语言设置为英文,然后运行单元测试,则抛出英文提示的异常。以Mac系统为例:



我们看到异常的详细描述变成英文了。
接下来我们需要继续写“手机号+密码”,“Email+密码”的相关测试用例代码了,方法类似上面,就不一一讲解了,有兴趣的同学请Github上获取最新代码:
Github代码获取:https://github.com/MacManon/top_cloudev_team
测试驱动开发实践3————testSave之新增用户的更多相关文章
- 测试驱动开发实践4————testSave之新增文档分类
[内容指引] 1.确定"新增文档分类"的流程及所需的参数 2.根据业务规则设计测试用例 3.为测试用例赋值并驱动开发 一.确定"新增文档分类"的流程及所需的参数 ...
- 测试驱动开发实践5————testSave之修改文档分类
[内容指引] 1.确定"修改文档分类"的微服务接口及所需的参数 2.设计测试用例及测试用例合并 3.为测试用例赋值并驱动开发 上一篇我们通过17个测试用例完成了"新增文档 ...
- 测试驱动开发实践2————从testList开始
内容指引 1.测试之前 2.改造dto层代码 3.改造dao层代码 4.改造service.impl层的list方法 5.通过testList驱动domain领域类的修改 一.测试之前 在" ...
- 测试驱动开发实践 - Test-Driven Development(转)
一.前言 不知道大家有没听过“测试先行的开发”这一说法,作为一种开发实践,在过去进行开发时,一般是先开发用户界面或者是类,然后再在此基础上编写测试. 但在TDD中,首先是进行测试用例的编写,然后再进行 ...
- 测试驱动开发实践 - Test-Driven Development
一.前言 不知道大家有没听过“测试先行的开发”这一说法,作为一种开发实践,在过去进行开发时,一般是先开发用户界面或者是类,然后再在此基础上编写测试. 但在TDD中,首先是进行测试用例的编写,然后再进行 ...
- 测试驱动开发实践3————从testList开始
[内容指引] 运行单元测试: 装配一条数据: 模拟更多数据测试列表: 测试无搜索列表: 测试标准查询: 测试高级查询. 一.运行单元测试 我们以文档分类(Category)这个领域类为例,示范如何通过 ...
- 测试驱动开发实践—从testList开始
[内容指引]运行单元测试:装配一条数据:模拟更多数据测试列表:测试无搜索列表:测试标准查询:测试高级查询. 一.运行单元测试 我们以文档分类(Category)这个领域类为例,示范如何通过编写测试用例 ...
- TDD(测试驱动开发)培训录
2014年我一直从事在敏捷实践咨询项目,这也是我颇有收获的一年,特别是咨询项目的每一点改变,不管是代码质量的提高,还是自组织团队的建设,都能让我们感到欣慰.涉及人的问题都是复杂问题,改变人,改变一个组 ...
- TDD(测试驱动开发)培训录(转)
本文转载自:http://www.cnblogs.com/whitewolf/p/4205761.html 最近也在了解TDD,发现这篇文章不错,特此转载一下. TDD(测试驱动开发)培训录 2015 ...
随机推荐
- 【mysql】连接查询
- Oracle 存储过程中的 =>
oracle实参与形参有两种对应方式1.一种是位置方式,和面向对象语言参数传递类似;2.另外一种是=> 作为形参对应,因为位置对应方法有缺限,比如一个函数有3个参数,但第2个是可以不传(有默认值 ...
- freemarker处理哈希表的内建函数
freemarker处理哈希表的内建函数 1.简易说明 (1)map取值 (2)key取值 2.实现示例 <html> <head> <meta http-equiv=& ...
- STM32f4 ARM Bootloader
参考资料: 基于ARM 的嵌入式系统Bootloader 启动流程分析 Bootloader 详解 ( 代码环境 | ARM 启动流程 | uboot 工作流程 | 架构设计) Android系统启动 ...
- Linux之网络管理
一.网络基础 1)ISO/OSI七层模型简介 ISO:国际标准化组织 OSI:开放系统互联模型 IOS:苹果操作系统(在计算机网络中,IOS是互联网操作系统,是思科公司为其网络设备开发的操作维护系统) ...
- Spring的Bean有哪些作用域?
Spring的Bean有以下五种作用域: 1.singleton:SpringIOC容器只会创建该Bean的唯一实例: 2.prototype:每次请求都创建一个实例: 3.requset:每次HTT ...
- AC自动机模板2(【CJOJ1435】)
题面 Description 对,这就是裸的AC自动机. 要求:在规定时间内统计出模版字符串在文本中出现的次数. Input 第一行:模版字符串的个数N. 第2->N+1行:N个字符串.(每个模 ...
- 【BZOJ1207】【HNOI2004】打鼹鼠(动态规划)
[BZOJ1207][HNOI2004]打鼹鼠 题面 BZOJ题面 题解 考虑到m的范围只有10000 O(m^2)的复杂度是可以接受的 所以直接暴力DP 每次枚举前面出现的鼹鼠 检查是否能够转移过来 ...
- [BZOJ1878] [SDOI2009] HH的项链 (树状数组)
Description HH有一串由各种漂亮的贝壳组成的项链.HH相信不同的贝壳会带来好运,所以每次散步 完后,他都会随意取出一段贝壳,思考它们所表达的含义.HH不断地收集新的贝壳,因此, 他的项链变 ...
- [BZOJ1207] [HNOI2004] 打鼹鼠 (dp)
Description 鼹鼠是一种很喜欢挖洞的动物,但每过一定的时间,它还是喜欢把头探出到地面上来透透气的.根据这个特点阿Q编写了一个打鼹鼠的游戏:在一个n*n的网格中,在某些时刻鼹鼠会在某一个网格探 ...