之前在《 如何说服你的同事使用 TDD 》中介绍了为什么要使用 TDD(测试驱动开发),以及如何使用 TDD 写代码。文章发表后,有同学在评论区中表示文章写得不错,但是举得例子太过脱离实际了,能不能举一个在实际工作中的例子呀。这篇文章,就来分享一下在 Spring Boot 中,如何使用 TDD 写出功能健壮、代码整洁的高质量接口。
我将用一个简单的案例,向你展示:
我们要实现的接口,功能非常简单,就是能够对敏感字眼进行检查的发帖功能,不允许发带有 "shit"、"fxxk" 之类字眼的帖子,嗯,我们是一个文明的社区!
接口文档如下:
接口说明发布帖子,同时对敏感字眼进行校验
URL/v2.0/posts
HTTP 请求方式POST
请求体参数: content(帖子的内容,String)
响应200 创建成功,返回成功创建的帖子信息
400 创建失败,帖子中包含敏感字眼
示例 1请求体
- {
- "content": "hello world!"
- }
响应
200
示例 2
- {
- "id": 1,
- "content": "hello world!",
- "username": "sexy code",
- "createDate": 1515312619351
- }
请求体
- {
- "content": "hello shit!"
- }
响应
- {
- "errorCode": 100001,
- "errorInfo": "post contains sensitive info"
- }
如果不采用 TDD,那么下一步就是拿着接口文档开发接口了,但是这很不 TDD。TDD 要求我们先写测试用例。
你或许会认为不写测试用例,同样可以写出实现功能的接口。别急,测试用例带给你的好处远远不止正确性。
看完上面那份接口文档,我们很自然的想到有下面两个测试用例:
上面这两个测试用例,都是从模拟客户端请求,到后台业务层和数据库层操作,再到返回响应的端到端测试,因此属于集成测试。
集成测试要求我们启动 Spring Boot 的容器,因此运行起来会比较慢。通常情况下,集成测试只覆盖基本场景,更细致的测试,可以交给单元测试。
比如在这个场景中,我们可以针对判断内容中是否含有敏感信息的这个功能,进行单元测试,这也就要求我们把这个功能,抽取成一个方法,这样才方便我们写测试用例。由于单元测试不需要启用 Spring Boot 容器,因此测试用例运行起来将非常迅速。
TDD 在不知不觉中提高了我们的代码质量。它让我们从测试用例的角度出发,思考如何写出方便测试的代码,方便测试的代码,往往是符合单一职责的。
制定好测试策略之后,下面开始写第一个测试用例。
一个测试用例通常包括以下三个步骤:
对于我们这个发帖的接口,那就是:
使用 Spring Boot 提供的测试框架,可以很轻松的将上面这个过程写成代码(本文的所有代码,可到 Github 下载,欢迎加星):
- @RunWith(SpringRunner.class)
- @SpringBootTest
- @AutoConfigureMockMvc
- public class PostControllerV2ITTest {
- public static final String POST_CONTENT_VALID = "post content test";
- public static final String POST_URL = "/v2.0/posts";
- @Autowired
- private MockMvc mockMvc;
- @Autowired
- private ObjectMapper objectMapper;
- @Autowired
- private PostRepository postRepository;
- @Test
- public void testCreatePost_returnSuccess() throws Exception {
- ResultActions resultActions = sendCreatePostRequest(POST_CONTENT_VALID);
- checkCreateValidPostResult(resultActions, POST_CONTENT_VALID);
- }
- ...
- }
PostControllerV2ITTest 类上的几个注解,@RunWith、@SpringBootTest 等,是 Spring Boot 提供的用于创建集成测试环境的注解,本文重点在于 TDD,因此这几个注解的具体用途和原理就不一一赘述了,有兴趣的同学可以查看 Spring Boot 官方文档 中关于测试框架的介绍。
代码中发送请求的函数 sendCreatePostRequest 和检查请求结果的函数 checkCreateValidPostResult 分别如下:
sendCreatePostRequest:
- private ResultActions sendCreatePostRequest(String postContent) throws Exception {
- PostCreateDTO postCreateDTO = new PostCreateDTO(postContent);
- return mockMvc.perform(post(POST_URL)
- .contentType(MediaType.APPLICATION_JSON)
- .content(objectMapper.writeValueAsString(postCreateDTO)));
- }
checkCreateValidPostResult:
- private void checkCreateValidPostResult(ResultActions resultActions, String expectedContent) throws Exception {
- resultActions.andExpect(status().isCreated());
- Post postFromRsp = transferResponse2PostEntity(resultActions);
- Post postFromDB = postRepository.findOne(postFromRsp.getId());
- assertNotNull(postFromDB);
- assertEquals(expectedContent, postFromDB.getContent());
- }
- private Post transferResponse2PostEntity(ResultActions resultActions) throws java.io.IOException {
- String response = resultActions.andReturn().getResponse().getContentAsString();
- return objectMapper.readValue(response, Post.class);
- }
写完测试用例,编辑器会用飘红提醒你,你还没创建 PostRepository、Post、PostCreateDTO 这些类。嗯,别急,这就创建。
PostRepository,使用 Spring Data,可以轻松写出一个自带增删改查功能的 DAO:
- public interface PostRepository extends CrudRepository < Post,
- Long > {}
Post,其实就是数据库中的存储结构,用 Java Entity 的形式表示出来:
- @Entity
- public class Post {
- @Id
- @GeneratedValue(strategy = GenerationType.AUTO)
- private long id;
- private String content;
- private String username;
- private Date createDate;
- public long getId() {
- return id;
- }
- public void setId(long id) {
- this.id = id;
- }
- public String getContent() {
- return content;
- }
- public void setContent(String content) {
- this.content = content;
- }
- public String getUsername() {
- return username;
- }
- public void setUsername(String username) {
- this.username = username;
- }
- public Date getCreateDate() {
- return createDate;
- }
- public void setCreateDate(Date createDate) {
- this.createDate = createDate;
- }
- }
PostCreateDTO,发帖接口的请求体:
- public class PostCreateDTO {
- private String content;
- public String getContent() {
- return content;
- }
- public void setContent(String content) {
- this.content = content;
- }
- public PostCreateDTO(String content) {
- this.content = content;
- }
- public PostCreateDTO() {}
- }
创建完这三个类之后,测试用例可以编译通过了,执行它,由于我们还没有写接口,嗯,测试用例理所当然、意料之中地失败了:
预期 201,实际 404,因为我们还没提供接口。
那下面自然就是写接口啦,终于可以写产品代码了!
PostController,只负责定义接口路径,逻辑全部交给 Service:
- @RestController
- @RequestMapping("/v2.0/posts")
- public class PostControllerV2 {
- @Autowired
- private PostService postService;
- @RequestMapping(value="", method= RequestMethod.POST)
- public ResponseEntity createPost(@RequestBody PostCreateDTO postCreateDTO) {
- return postService.createPost(postCreateDTO);
- }
- }
PostService,业务层操作,将 PostCreateDTO 转成 Post,然后调用 postRepository,将数据保存到数据库中:
- @Service
- public class PostService {
- @Autowired
- private PostRepository postRepository;
- @Autowired
- private UserService userService;
- public ResponseEntity createPost(PostCreateDTO postCreateDTO) {
- Post postCreateResult = savePost2DB(postCreateDTO);
- return ResponseEntity.status(HttpStatus.CREATED).body(postCreateResult);
- }
- private Post savePost2DB(PostCreateDTO postCreateDTO) {
- Post post = new Post();
- post.setCreateDate(new Date());
- post.setContent(postCreateDTO.getContent());
- post.setUsername(userService.queryCurrentUserName());
- return postRepository.save(post);
- }
- }
PostService 中用到了另一个 Service,UserService,用于获取当前登录用户,当然这里并没有真的去从 session 中获取用户信息:
- @Service
- public class UserService {
- public String queryCurrentUserName() {
- return "sexy code";
- }
- }
完工,运行下测试用例,通过后,我们继续写下一个集成测试用例——敏感字段校验。
第二个用例依然遵循测试用例 "三部曲",创建环境 -> 创建带有敏感信息的帖子 -> 检查响应是不是 400、检查数据库中是不是没有数据。这里只贴上新增的代码。
PostControllerV2ITTest:
- public static final String POST_CONTENT_SENSITIVE = "post content test fuck";
- ...
- @Test
- public void testCreatePost_withSensitiveInfo_returnBadRequest() throws Exception {
- ResultActions resultActions = sendCreatePostRequest(POST_CONTENT_SENSITIVE);
- checkCreateSensitivePostResult(resultActions);
- }
- ...
- private void checkCreateSensitivePostResult(ResultActions resultActions) throws Exception {
- resultActions.andExpect(status().isBadRequest());
- long count = postRepository.count();
- assertEquals(0, count);
- }
运行新的测试用例,自然又是理所当然的失败。继续写产品代码。由于我们遵循良好的分层结构,Controller 不需要做任何修改,只需给 PostService 加上判断敏感字段的逻辑即可,PostService:
- ...
- public ResponseEntity createPost(PostCreateDTO postCreateDTO) {
- if(isPostContainsSensitiveInfo(postCreateDTO.getContent())) {
- return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorInfo(SENSITIVE_INFO_ERROR_CODE, POST_CONTAINS_SENSITIVE_INFO));
- }
- Post postCreateResult = savePost2DB(postCreateDTO);
- return ResponseEntity.status(HttpStatus.CREATED).body(postCreateResult);
- }
- private boolean isPostContainsSensitiveInfo(String content) {
- // TODO: change to throw exception and use global exception handler to return response
- if(content.contains("shit") || content.contains("fuck")) {
- return true;
- }
- return false;
- }
- ...
这里的 isPostContainsSensitiveInfo 就是我们用来判断敏感字段的方法,我们将整个判断逻辑抽取出来,方便后面的单元测试。
值得注意的是,这个方法更好的做法是在判断为含有敏感信息时,抛出异常,而不是返回 true 这种标志(参见《Effective Java》第九章异常中提出的原则),不过由于我还没给整个 Spring Boot 项目加上全局异常处理器,因此这里暂时先使用返回 boolean 的方式来处理,后面会写一篇文章来分享如何在 Spring Boot 中把异常转换为 http 状态码。
写完产品代码,再来运行测试用例,通过。
现在我们的代码已经可以满足上面两个集成测试,可以说基础场景的功能我们已经实现了。但是我们的测试覆盖率并不全。
举个简单的例子,"shit" 和 "fxxk" 都是敏感信息,但是上面我们只测试了 "fxxk" 的场景,可是专门给 "shit" 这个场景写一个集成测试又未免太过兴师动众,这时候我们就可以使用单元测试,来对功能进行更细致并且更快速的测试。由于 isPostContainsSensitiveInfo 是 private 方法,因此我们在测试时用到了反射。
PostServiceUnitTest:
- public class PostServiceUnitTest {@Test public void testMethod_IsPostContainsSensitiveInfo() throws NoSuchMethodException,
- InvocationTargetException,
- IllegalAccessException {
- Class < PostService > postServiceClass = PostService.class;
- Method method = postServiceClass.getDeclaredMethod("isPostContainsSensitiveInfo", String.class);
- method.setAccessible(true);
- PostService postService = new PostService();
- checkWithContent(method, postService, "hi and fuck", true);
- checkWithContent(method, postService, "hello world", false);
- checkWithContent(method, postService, "hello shit", true);
- }
- private void checkWithContent(Method method, PostService postService, String content, boolean expected) throws IllegalAccessException,
- InvocationTargetException {
- boolean isSensitive = (Boolean) method.invoke(postService, content);
- assertEquals(expected, isSensitive);
- }
- }
显然,这是一个非常简单的 Junit,不需要启用 Spring Boot 容器,运行起来自然也是相当迅速,在我的机器上,执行一次集成测试要花费 15 秒,其中绝大多数时间都是花在初始化容器上,而执行一个单元测试只需要 1 秒。
写测试用例有一个原则,那就是各个用例之间不能够相互影响,而我在 testCreatePost_returnSuccess 用例中给数据库插入了数据,却没有在 testCreatePost_withSensitiveInfo_returnBadRequest 用例开始之前对数据库进行清空,这样 testCreatePost_returnSuccess 用例中插入的数据就会带到下一个用例中去,更不幸的是,我们在 testCreatePost_withSensitiveInfo_returnBadRequest 用例中还加入了如下数据库 count 的校验:
- ...
- long count = postRepository.count();
- assertEquals(0, count);
- ...
因此,只要 testCreatePost_returnSucces 用例在 testCreatePost_withSensitiveInfo_returnBadRequest 之前执行,那么 testCreatePost_withSensitiveInfo_returnBadRequest 就会失败。
我们来验证一下,为了实现上面所讲的测试用例的执行顺序,我给 PostControllerV2ITTest 加入了 @FixMethodOrder(MethodSorters.NAME_ASCENDING) 注解:
- @FixMethodOrder(MethodSorters.NAME_ASCENDING)
- public class PostControllerV2ITTest
执行测试用例,果然,testCreatePost_withSensitiveInfo_returnBadRequest 失败了:
预期 0,结果 1,因为我们在用例开始前没有清空数据库,导致用例之间相互影响。要解决这个问题,很简单,只需要写个 @Before 注解的函数,并在函数中清空表中的数据:
- @Before
- public void setup() {
- postRepository.deleteAll();
- }
@Before 是 Junit 提供的注解,每个测试用例在执行前,都会执行被 @Before 注解的函数。
这篇文章只是举了一个我认为的,足够简单,却又足够说明问题的例子,在实际开发中,自然会遇到更多的场景,比如:
Spring Boot 为我们写好测试用例、用好 TDD 提供了非常方便的框架,我们只需尽情去写测试用例,尽情去 TDD 就好了。
这篇文章虽然是在谈如何在 Spring Boot 中使用 TDD 写高质量的接口,但是从这样一个例子中,我们也看到了 TDD 的很多好处:
写完这篇文章,结合之前那篇《 如何说服你的同事使用 TDD 》,嗯,这下我真的非常有信心,可以说服你们使用 TDD,说服你们去说服你们同事,使用 TDD 了。
来源: http://www.jianshu.com/p/bae068a9c736