# 单元测试
本小节建立在你已经阅读和熟悉了上一小节快速上手的基础上。
上一节中,我们完成了图书的两个基本 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。

