开发者工具与单元测试
一、devtools
- devtools简介
SpringBoot提供了一组开发工具spring-boot-devtools,可以提高开发者的工作效率,开发者可以将该模块包含在任何项目中,spring-boot-devtools最方便的地方莫过于热部署了
。 - devtools实战
- 基本用法
当开发者将spring-boot-devtools引入项目后,只要classpath路径下的文件发生了变化,项目就会自动重启,这极大地提供了项目的开发速度。如果开发者使用Eclipse
,那么修改完代码保存之后,项目将自动编译并触发重启
,而开发如果使用了InteIIiJ IDEA
,默认情况下需要开发者手动编译才会触发重启。手动编译时,单击Build-->Build Project菜单或者按Ctrl + F9快捷键进行编译,编译成功后就会触发项目重启
。当然,使用IntelliJ IDEA的开发者也可以配置项目自动编译
,配置步骤如下:
按Ctrl + Shift + Alt + /快捷键调出Maintenance
页面
单击Registry,在新打开的Registry页面中,勾选compiler.automake.allow.when.app.running复选框
。
做完这两步配置之后,若开发者再次在IntelliJ IDEA中修改代码,则项目会自动重启。
classpath路径下的静态资源或者视图模板等发生变化时,并不会导致项目重启
- 基本原理
SpringBoot中使用的自动重启技术涉及两个类加载器
,一个是baseclassloader
,用来加载不会变化的类,例如项目引入的第三方的jar;另一个是restartclassloader
,用来加载开发者自己写的会变化的类。当项目需要重启时,restartclassloader
将被一个新创建的类加载器代替,而baseclassloader
则继续使用原来的,这种启动方式要比冷启动快很多,因为baseclassloader
已经存在并且已经加载好。 - 自定义监控资源
默认情况下,/META-INF/maven,/META-INF/resources、/resources、/static、/public以及/templates位置下资源的变化并不会触发重启
,如果开发者想要对这些位置进行重定义,在application.properties
中添加如下配置即可:
spring.devtools.restart.exclude=/resources
这表示从默认的不触发重启的目录中除去resources目录,即resources目录下的资源发生变化时也会导致项目重启。用户也可以反向配置需要监控的目录,配置方式如下:
spring.devtools.restart.additional-exclude=src/main/resources
这个配置表示当src/main/resources目录下的文件发生变化时,自动重启项目
。这个项目的编码过程是一个连续的过程,并不是每修改一行代码就要重启项目
,这样不仅浪费电脑性能,而且没有实际意义。鉴于这种情况,开发者也可以考虑使用触发文件
,触发文件是一个特殊的文件,当这个文件发生变化时项目就会重启,配置方式如下
:触发器部署:修改class不会重启,只有修改指定文件的值才会重启。
spring.devtools.restart.trigger-file=trigger.txt
在项目resources目录下创建一个名为trigger.txt文件,此时当开发者修改代码时,默认情况下项目不会重启,需要项目重启时,开发者只需要修改trigger.txt文件即可。但是注意,如果项目没有改变,只是单纯地改变了trigger.txt文件,那么项目不会重启
。
- 使用LiveReload
devtools默认嵌入了LiveReload服务器,可以解决静态文件的热部署,LiveReload可以在资源发生变化时自动触发浏览器更新,LiveReload支持Chrome、Firfox以及Safari(苹果)。以Chrome为例:
将第一个搜索结果添加到Chrome中,添加成功后,在Chrome右上角有一个LiveReload图标。
在浏览器中打开项目的页面,然后单击浏览器右上角的LiveReload按钮
,开启LiveReload连接,此时当静态资源发生改变时,浏览器就会自动加载
。如果开发者不想使用这一特性,可以通过如下配置:
spring.devtools.livereload.enabled=false
注意:建议使用LiveReload策略而不是项目重启策略来实现静态资源的动态加载,因为项目 重启所耗费的时间一般要超过LiveReload
。
5. 禁用自动重启
如果开发者添加了spring-boot-devtools 依赖但是不想使用自动重启特性,那么可以关闭自动重启,如下:
spring.devtools.restart.enabled=false
也可以在代码中配合禁止自动重启,配置方式如下:
@SpringBootApplication
public class springApp {
public static void main(String[] args) {
System.setProperty("spring.devtools.restart.enabled", "false");
SpringApplication.run(springApp.class);
}
}
- 全局配置
如果项目模块众多,可以在当前用户目录下创建.spring-boot-devtools.properties文件来对devtools进行全局配置,这个配置文件适用于当前计算机上任何使用了devtools模块的SpringBoot项目
。
二、单元测试
在SpringBoot中使用单元测试可以实现对每一个环节的代码进行测试。SpringBoot中的单元测试与Spring中的测试一脉相承,但是又做了大量的简化,只需要少量的代码就能搭建一个测试环境,进而实现对Controller、Service或者Dao层的代码进行测试。
- 基本用法
使用Idea 或者在线创建一个SpringBoot项目时,创建成功后,默认都添加了spring-boot-starter-test依赖,并且创建好了测试类,代码如下:
依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
测试类:
@RunWith(SpringRunner.class)
@SpringBootTest
public class BootTest {
@Autowired
BookService bookService;
@Test
public void test1() {
//List<Book> bookList = bookService.queryAll();
String hello = bookService.sayHello("Michael");
// Assert.assertThat(hello, Matchers.is("hell"));
Assert.assertTrue(hello, (hello).equals("hello"));
}
}
代码解释:
- 这里首先使用了
@RunWith
注解,该注解将JUnit执行类修改为SpringRunner
,而SpringRunner是Spring Framework中测试类SpringJUnit4ClassRunner的别名
。- @Spring BootTest注解除了提供
Spring TestContext中的常规测试功能
之外,还提供了其他特性:提供默认的ContextLoader
、自动搜索@Spring BootConfiguration
、自定义环境属性
,为不同的webEnvironment模式提供支持
,这里的webEnvironment模式主要有4种:
MOCK
这种模式是当classpath下存在servletAPIS
时,就会创建WebApplicationContext并提供一个mockservlet环境
;当classpath下存在Spring WebFlux
时,则创建ReactiveWebApplicationContext
;若都不存在,则创建一个常规的ApplicationContext
。RANDMON_PORT
,这种模式将提供一个真实的Servlet环境,使用内嵌的容器,但是端口随机
。DEFINED_PORT
,这种模式也将提供一个真是的Servlet环境,使用内嵌的容器,但是使用定义好的端口
。NONE
,这种模式则加载一个普通的ApplicationContext,不提供任何Servlet环境。这种一般不适用于Web测试
。
- 在Spring测试中,开发者一般使用@ContextConfiguration(class = ) 或者@ContextConfiguration(locations = ) 来指定要加载的Spring配置,而在SpringBoot中则不需要这么麻烦,SpringBoot中的
@Test*
注解将会去包含测试类的包下查找
带有@SpringBootApplication或者@SpringBootConfiguration注解的主配置类
。@Test注解则来自junit,junit中的@After、@AfterClass、@Before、@BeforeClass、@Ignore等注解一样可以在这里使用
。
- Service测试
@Service
public class BookService {
@Autowired
BookDao bookDao;
public List<Book> queryAll() {
return bookDao.queryAll();
}
public String sayHello(String name) {
// return "Hello" + name + " ! ";
return "hello";
}
}
测试:
@RunWith(SpringRunner.class)
@SpringBootTest
public class BootTest {
@Autowired
BookService bookService;
@Test
public void test1() {
//List<Book> bookList = bookService.queryAll();
String hello = bookService.sayHello("Michael");
// Assert.assertThat(hello, Matchers.is("hell"));
Assert.assertTrue(hello, (hello).equals("hello"));
}
}
有以下几个问题
- spring boot 测试 提示service没有注入:
测试包的包名要与对应的项目包名相同,否则无法找到相应的bean
。
Runner org.junit.internal.runners.ErrorReportingRunner (used on class com.devtoolstest.BootTest) does not support filtering and will therefore be run completely.:
问题的发生是因为我再引入【@Test】注解的时引错了包
错误的包:
import org.junit.jupiter.api.Test;
正确的包:
import org.junit.Test;
- Controller测试
Controller测试则要使用到Mock测试,即对一些不易获取的对象采用虚拟的对象来创建进而方便测试。而Spring中提供的MockMvc则提供了对HTTP请求的模拟,使开发者能够不依赖网络环境的情况下实现对Controller的快速测试。例如有如下Controller:
@RunWith(SpringRunner.class)
@SpringBootTest
public class BootTest {
@Autowired
BookService bookService;
@Autowired
WebApplicationContext wac;//注入一个WebApplicationContext用来模拟ServletContext环境
MockMvc mockMvc;//声明一个MockMvc对象,
@Before
public void before() {
mockMvc = MockMvcBuilders.webAppContextSetup(wac).build();//并在每个测试方法执行前进行MockMvc的初始化操作
}
@Test
public void test1() throws Exception {
/**
* 调用mockMvc中的perform方法开启一个RequestBuilder请求,具体的请求则通过
* MockMvcRequestBuilders进行构建,调用MockMvcRequestBuilders中的get方法表示发起一次Get请求
* 调用post方法则发起一个Post请求,其他的DELETE和PUT请求也是一样,最后通过param方法设置请求参数。
*/
MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/test")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "Michael"))
//表示添加返回值的验证规则,利用MockMvcResultMatchers进行验证,这里表示验证响应码是否是200
// isOK源码: OK(200, HttpStatus.Series.SUCCESSFUL, "OK"),
.andExpect(MockMvcResultMatchers.status().isOk())
//表示将请求详细信息打印到控制台
.andDo(MockMvcResultHandlers.print())
//返回相应的MvcResult
.andReturn();
System.out.println(mvcResult.getResponse().getContentAsString());
}
/**
*
* @throws Exception
*/
@Test
public void test2() throws Exception {
/*
*
*/
ObjectMapper objectMapper = new ObjectMapper();
Book book = new Book();
book.setBkAuthor("哈哈哈");
book.setBktype("笑话真好笑");
String s = objectMapper.writeValueAsString(book);
//发送Post请求
MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.post("/book")
//将book对象转换为JSON
.contentType(MediaType.APPLICATION_JSON)
.content(s))
// isOK源码: OK(200, HttpStatus.Series.SUCCESSFUL, "OK"),
.andExpect(MockMvcResultMatchers.status().isOk())
//表示将请求详细信息打印到控制台
.andDo(MockMvcResultHandlers.print())
//返回相应的MvcResult
.andReturn();
System.out.println(mvcResult.getResponse().getContentAsString());
}
}
除了MockMvc这种测试方法之外,SpringBoot还专门提供了TestRestTemplate用来实现集成测试
,若开发者使用了@SpringBookTest注解,则TestRestTemplate将自动可用
,直接在测试类中注入即可。注意,如果要使用TestRestTemplate进行测试,需要将@SpringBootTest直接中webEnvironment属性的默认值有webEnvironment.MOCK修改为WebEnvironment.DEFINED_PORT或者WebEnvironment.RANDOM_PORT
,因为这两种都是使用一个真实的Servlet环境而不是模拟的Servlet环境
。其代码如下:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class BootTest {
@Autowired
TestRestTemplate restTemplate;
@Test
public void test3() {
ResponseEntity<String> hello = restTemplate.getForEntity("/hello?name={0}",
String.class,"Michael");
System.out.println(hello.getBody());
}
}
- JSON测试
开发者可以使用@JsonTest测试JSON序列化和反序列化是否工作正常
,该注解将自动配置JacksonObjectMapper
、@JsonComponent以及Jackson Modules
。如果开发者使用Gson代替Jackson,该注解将配置Gson,具体用法如下:
@RunWith(SpringRunner.class)
@JsonTest
public class BootTest {
@Autowired
JacksonTester jacksonTester;
@Test
public void testSerialize() throws IOException {
Book book = new Book();
book.setBkId(1);
book.setBkname("三国演义");
book.setBkAuthor("罗贯中");
JsonContent<Book> jsonContent = jacksonTester.write(book);
Assertions.assertThat(jsonContent).isEqualToJson("book.json");
Assertions.assertThat(jsonContent).hasJsonPathStringValue("@.bkname");
Assertions.assertThat(jsonContent).extractingJsonPathStringValue("@.bkname")
.isEqualTo("三国演义");
}
@Test
public void testDeserialize() throws IOException {
String context = "{\"bkId\":1,\"bkname\":\"三国演义\",\"bkAuthor\":\"罗贯中\"}";
Map jsonContent = (Map)jacksonTester.parseObject(context);
Assertions.assertThat(jsonContent.get("bkname")).isEqualTo("三国演义1");
}
}
部分测试结果
如果@RunWith(SpringRunner.class)注释掉,JacksonTester对象为null.
Unable to load JSON from class path resource
原因:classes文件中没有book.json文件
java.lang.AssertionError: JSON Comparison failure: Expected: bkauthor but none found
原因:json文件中的名称和实体类相同
book.json
{"bkId":1,"bkname":"三国演义","bkAuthor":"罗贯中"}
三、新断言assertThat使用
JUnit 4.4 结合 Hamcrest 提供了一个全新的断言语法——assertThat。程序员可以只使用 assertThat 一个断言语句,结合 Hamcrest 提供的匹配符,就可以表达全部的测试思想,我们引入的版本是Junit4.12所以支持assertThat。
Hamcrest (匹配器框架)
使用过Junit 的应该有过体验:在实际开发中,一些基本的断言,如eqaul,null,true它们的可读性并不是很好。而且很多时候我们要比较对象、集合、Map等数据结构。这样我们要么进行大段的字段获取再断言。或者干脆自己编写表达式并断言其结果。
JUnit4.4引入了Hamcrest框架,Hamcest提供了一套匹配符Matcher,这些匹配符更接近自然语言,可读性高,更加灵活。
- assertThat 的基本语法如下:
清单 1 assertThat 基本语法
assertThat( [value], [matcher statement] );
- value 是接下来想要测试的变量值;
- matcher statement 是使用 Hamcrest 匹配符来表达的对前面变量所期望的值的声明,如果 value 值与 matcher statement 所表达的期望值相符,则测试成功,否则测试失败。
- assertThat 的优点
- 优点 1:以前 JUnit 提供了很多的 assertion 语句,如:assertEquals,assertNotSame,assertFalse,assertTrue,assertNotNull,assertNull 等,现在有了 JUnit 4.4,一条 assertThat 即可以替代所有的 assertion 语句,这样可以在所有的单元测试中只使用一个断言方法,使得编写测试用例变得简单,代码风格变得统一,测试代码也更容易维护。
- 优点 2:assertThat 使用了 Hamcrest 的 Matcher 匹配符,用户可以使用匹配符规定的匹配准则精确的指定一些想设定满足的条件,具有很强的易读性,而且使用起来更加灵活。如清单 2 所示:
清单 2 使用匹配符 Matcher 和不使用之间的比较
// 想判断某个字符串 s 是否含有子字符串 "developer" 或 "Works" 中间的一个
// JUnit 4.4 以前的版本:assertTrue(s.indexOf("developer")>-1||s.indexOf("Works")>-1 );
// JUnit 4.4:
assertThat(s, anyOf(containsString("developer"), containsString("Works")));
// 匹配符 anyOf 表示任何一个条件满足则成立,类似于逻辑或 "||", 匹配符 containsString 表示是否含有参数子
// 字符串,文章接下来会对匹配符进行具体介绍
- 优点 3:assertThat 不再像 assertEquals 那样,使用比较难懂的“谓宾主”语法模式(如:assertEquals(3, x);),相反,
assertThat 使用了类似于“主谓宾”的易读语法模式(如:assertThat(x,is(3));)
,使得代码更加直观、易读。 - 优点 4:可以将这些
Matcher
匹配符联合起来灵活使用,达到更多目的。如清单 3 所示:
清单 3 Matcher 匹配符联合使用
字符相关匹配符
/**equalTo匹配符断言被测的testedValue等于expectedValue,
* equalTo可以断言数值之间,字符串之间和对象之间是否相等,相当于Object的equals方法
*/
assertThat(testedValue, equalTo(expectedValue));
/**equalToIgnoringCase匹配符断言被测的字符串testedString
*在忽略大小写的情况下等于expectedString
*/
assertThat(testedString, equalToIgnoringCase(expectedString));
/**equalToIgnoringWhiteSpace匹配符断言被测的字符串testedString
*在忽略头尾的任意个空格的情况下等于expectedString,
*注意:字符串中的空格不能被忽略
*/
assertThat(testedString, equalToIgnoringWhiteSpace(expectedString);
/**containsString匹配符断言被测的字符串testedString包含子字符串subString**/
assertThat(testedString, containsString(subString) );
/**endsWith匹配符断言被测的字符串testedString以子字符串suffix结尾*/
assertThat(testedString, endsWith(suffix));
/**startsWith匹配符断言被测的字符串testedString以子字符串prefix开始*/
assertThat(testedString, startsWith(prefix));
一般匹配符
/**nullValue()匹配符断言被测object的值为null*/
assertThat(object,nullValue());
/**notNullValue()匹配符断言被测object的值不为null*/
assertThat(object,notNullValue());
/**is匹配符断言被测的object等于后面给出匹配表达式*/
assertThat(testedString, is(equalTo(expectedValue)));
/**is匹配符简写应用之一,is(equalTo(x))的简写,断言testedValue等于expectedValue*/
assertThat(testedValue, is(expectedValue));
/**is匹配符简写应用之二,is(instanceOf(SomeClass.class))的简写,
*断言testedObject为Cheddar的实例
*/
assertThat(testedObject, is(Cheddar.class));
/**not匹配符和is匹配符正好相反,断言被测的object不等于后面给出的object*/
assertThat(testedString, not(expectedString));
/**allOf匹配符断言符合所有条件,相当于“与”(&&)*/
assertThat(testedNumber, allOf( greaterThan(8), lessThan(16) ) );
/**anyOf匹配符断言符合条件之一,相当于“或”(||)*/
assertThat(testedNumber, anyOf( greaterThan(16), lessThan(8) ) );
数值相关匹配符
/**closeTo匹配符断言被测的浮点型数testedDouble在20.0¡À0.5范围之内*/
assertThat(testedDouble, closeTo( 20.0, 0.5 ));
/**greaterThan匹配符断言被测的数值testedNumber大于16.0*/
assertThat(testedNumber, greaterThan(16.0));
/** lessThan匹配符断言被测的数值testedNumber小于16.0*/
assertThat(testedNumber, lessThan (16.0));
/** greaterThanOrEqualTo匹配符断言被测的数值testedNumber大于等于16.0*/
assertThat(testedNumber, greaterThanOrEqualTo (16.0));
/** lessThanOrEqualTo匹配符断言被测的testedNumber小于等于16.0*/
assertThat(testedNumber, lessThanOrEqualTo (16.0));
集合相关匹配符
/**hasEntry匹配符断言被测的Map对象mapObject含有一个键值为"key"对应元素值为"value"的Entry项*/
assertThat(mapObject, hasEntry("key", "value" ) );
/**hasItem匹配符表明被测的迭代对象iterableObject含有元素element项则测试通过*/
assertThat(iterableObject, hasItem (element));
/** hasKey匹配符断言被测的Map对象mapObject含有键值“key”*/
assertThat(mapObject, hasKey ("key"));
/** hasValue匹配符断言被测的Map对象mapObject含有元素值value*/
assertThat(mapObject, hasValue(value));