# 单元测试

本小节建立在你已经阅读和熟悉了上一小节快速上手的基础上。

上一节中,我们完成了图书的两个基本 API,并从中逐步提到了一些 lin-cms 规范,
本小节我们将继续以图书为支撑点进行应用的开发教程。

# 校验配置

在上一节中,我们在新建图书接口的开发中,使用到了一个校验类 ——CreateOrUpdateBookDTO:

package io.github.talelin.latticy.dto.book;

import lombok.Data;

import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

@Data
public class CreateOrUpdateBookDTO {

    @NotEmpty(message = "必须传入图书名")
    @Size(max = 50, message = "图书名不能超过50字符")
    private String title;

    @NotEmpty(message = "必须传入图书作者")
    @Size(max = 50, message = "图书作者名不能超过30字符")
    private String author;

    @NotEmpty(message = "必须传入图书综述")
    @Size(max = 1000, message = "图书综述不能超过1000字符")
    private String summary;

    @Size(max = 100, message = "图书插图的url长度必须在0~100之间")
    private String image;
}

不知你是否记得,我们在异常消息处理的时候谈到过,异常消息如果硬编码,
那么如果后续修改的话,会将耗费一定的时间去寻找它,且不易维护。

这句话放在校验类中同样适用,类似于必须传入图书名这样的硬编码异常信息,
你确实可以把它以硬编码的形式来处理,但是如果提供配置文件的方式确实更好,
而且 spring-boot已经帮我们做了这项工作,我们在src/main/resources/ValidationMessages.properties 中添加上关于 CreateOrUpdateBookDTO 的校验信息配置:

# book
book.title.not-empty=必须传入图书名
book.title.size=图书名不能超过50字符
book.author.not-empty=必须传入图书作者
book.author.size=图书作者名不能超过30字符
book.summary.not-empty=必须传入图书综述
book.summary.size=图书综述不能超过1000字符
book.image.size=图书插图的url长度必须在0~100之间

有了该配置后,我们修缮一下我们 CreateOrUpdateBookDTO 代码。

package io.github.talelin.latticy.dto.book;

import lombok.Data;

import javax.validation.constraints.*;

@Data
public class CreateOrUpdateBookDTO {

    @NotEmpty(message = "{book.title.not-empty}")
    @Size(max = 50, message = "{book.title.size}")
    private String title;

    @NotEmpty(message = "{book.author.not-empty}")
    @Size(max = 50, message = "{book.author.size}")
    private String author;

    @NotEmpty(message = "{book.summary.not-empty}")
    @Size(max = 1000, message = "{book.summary.size}")
    private String summary;

    @Size(max = 100, message = "{book.image.size}")
    private String image;
}

在代码中,我们替换了硬编码,而是通过{}占位符来写入配置名,spring-boot 会自动帮 我们填充上配置信息。

如果你是极致主义者,你会发现,其实我们还有一处仍然存在消息的硬编码,在 BookController 中:

@PostMapping("")
public CreatedVO createBook(@RequestBody @Validated CreateOrUpdateBookDTO validator) {
    bookService.createBook(validator);
    return new CreatedVO("新建图书成功");
}

和异常信息处理一样,我们在src/main/resources/code-message.properties配置文件中,添加上 关于新建图书成功 的消息码配置,记住这样正常的消息码必须小于 10000。

code-message[12]=新建图书成功

且修改一下 BookController:

package io.github.talelin.latticy.controller.v1;

import io.github.talelin.autoconfigure.exception.NotFoundException;
import io.github.talelin.latticy.dto.book.CreateOrUpdateBookDTO;
import io.github.talelin.latticy.model.BookDO;
import io.github.talelin.latticy.service.BookService;

import io.github.talelin.latticy.vo.CreatedVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.validation.annotation.Validated;

import javax.validation.constraints.Positive;
import java.util.List;

@RestController
@RequestMapping("/v1/book")
@Validated
public class BookController {

    @Autowired
    private BookService bookService;

    @GetMapping("/{id}")
    public BookDO getBook(@PathVariable(value = "id") @Positive(message = "id必须为正整数") Long id) {
        BookDO book = bookService.getById(id);
        if (book == null) {
            throw new NotFoundException("book not found", 10022);
        }
        return book;
    }

    @PostMapping("")
    public CreatedVO createBook(@RequestBody @Validated CreateOrUpdateBookDTO validator) {
        bookService.createBook(validator);
        return new CreatedVO(12);
    }
}

随后,我们来测试一下代码,但这次我们故意让校验失败,来看一下配置是否生效。

mvn spring-boot:run
curl -XPOST -H 'Content-Type:application/json' -d '{"author":"pedro","summary":"summary"}'  localhost:5000/v1/book

结果如下:

{
  "code": 10030,
  "message": { "title": "必须传入图书名" },
  "request": "POST /v1/book"
}

从结果中,可以发现配置生效了,但是message字段却发生了变化,由字符串变成了一个 对象。没错因为参数校验会存在多个字段校验,且每个字段都有可能会出错,我们不可能一 次只给出一个字段的错误,因此当发生校验失败是,它的异常信息往往是多个的。

# 测试

回头发现,其实我们已经开发了相当多的代码,那么是时候来测试一下了, 在src/test/java/io/github/talelin/latticy/controller/v1目录下我们新建一 个BookControllerTest.java:

package io.github.talelin.latticy.controller.v1;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@Rollback
@AutoConfigureMockMvc
@Slf4j
class BookControllerTest {

    @Test
    void getBook() {

    }

    @Test
    void createBook() {
    }
}

我们已经添加上两个测试函数分别对应两个 API,首先我们测试一下getBook

package io.github.talelin.latticy.controller.v1;

import io.github.talelin.latticy.mapper.BookMapper;
import io.github.talelin.latticy.model.BookDO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@Rollback
@AutoConfigureMockMvc
@Slf4j
class BookControllerTest {

    @Autowired
    private MockMvc mvc;

    @Autowired
    private BookMapper bookMapper;

    private Long id;
    private String title = "千里之外";
    private String author = "pedro";
    private String image = "千里之外.png";
    private String summary = "千里之外,是周杰伦和费玉清一起发售的歌曲";

    @Test
    void getBook() throws Exception {
        BookDO bookDO = new BookDO();
        bookDO.setTitle(title);
        bookDO.setAuthor(author);
        bookDO.setImage(image);
        bookDO.setSummary(summary);
        bookMapper.insert(bookDO);

        this.id = bookDO.getId();
        this.mvc.perform(get("/v1/book/" + this.id))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(MockMvcResultMatchers.
                        jsonPath("$.title").value(title));
    }

    @Test
    void createBook() {
    }
}

然后,我们运行一下这个测试函数;

mvn test -Dtest=io.github.talelin.latticy.controller.v1.BookControllerTest#getBook -DfailIfNoTests=false

如果一切顺利,你将会看到类似如下的输出,当然你的输出可能更加详细:

[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  13.049 s
[INFO] Finished at: 2020-01-28T16:05:29+08:00
[INFO] ------------------------------------------------------------------------

随后,我们也为createBook完善一下测试,整体代码如下:

package io.github.talelin.latticy.controller.v1;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategy;
import io.github.talelin.latticy.dto.book.CreateOrUpdateBookDTO;
import io.github.talelin.latticy.mapper.BookMapper;
import io.github.talelin.latticy.model.BookDO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import org.springframework.transaction.annotation.Transactional;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@Rollback
@AutoConfigureMockMvc
@Slf4j
class BookControllerTest {

    @Autowired
    private MockMvc mvc;

    @Autowired
    private BookMapper bookMapper;

    private Long id;
    private String title = "千里之外";
    private String author = "pedro";
    private String image = "千里之外.png";
    private String summary = "千里之外,是周杰伦和费玉清一起发售的歌曲";

    @Test
    void getBook() throws Exception {
        BookDO bookDO = new BookDO();
        bookDO.setTitle(title);
        bookDO.setAuthor(author);
        bookDO.setImage(image);
        bookDO.setSummary(summary);
        bookMapper.insert(bookDO);

        this.id = bookDO.getId();
        this.mvc.perform(get("/v1/book/" + this.id))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(MockMvcResultMatchers.
                        jsonPath("$.title").value(title));
    }

    @Test
    void createBook() throws Exception {
        CreateOrUpdateBookDTO dto = new CreateOrUpdateBookDTO();
        dto.setAuthor(author);
        dto.setImage(image);
        dto.setSummary(summary);
        dto.setTitle(title);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setPropertyNamingStrategy(PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES);
        String content = mapper.writeValueAsString(dto);

        mvc.perform(post("/v1/book/")
                .contentType(MediaType.APPLICATION_JSON).content(content))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(MockMvcResultMatchers.
                        jsonPath("$.message").value("新建图书成功"));
    }
}

测试一下:

mvn test -Dtest=io.github.talelin.latticy.controller.v1.BookControllerTest -DfailIfNoTests=false

# 总结

本小节,我们一起探讨了信息配置的重要性,完善了代码,且为上一节开发的接口书写了测 试。注意,我们并未详细的教你怎么去写单元测试,而是将单元测试的书写逻辑交给了你, 因为很多人可能不写测试,但我们强烈建议你去写测试,你甚至可以为每个 mapper 和 service 都写上单元测试。

最后,我们附上一些参考资料供你阅读。

spring-boot web 测试指南Testing the Web Layer
好用的测试框架junit