SpringBoot


SpringBoot开发技术 — 自动化测试


技术无止境,唯有继续沉淀…之前介绍了两大安全框架,spirng security和shiro,spring security相比shiro来说复杂一丢丢,但是二者控制权限都是类似的,前台的控制都是查看权限,可以直接引入相关的依赖,使用thymeleaf配合相关的namespace就可以直接方便实现,后台直接使用注解控制,以方法为粒度,二者最主要的都是配置,都是直接创建配置类即可,一个是配置5个核心对象,而security最 主要的配置只有FilterChain,HttpSecurity新版的是提供了原型对象,build对象

除了安全之外,项目开发的一个重要的一环就是自动化测试,自动化测试主要是使用几个注解比如@springBootTest,引入Test starter依赖; 这样就可以自动启动获取容器中对象

自动化测试是一种软件测试技术,使用特殊的自动化测试工具执行测试用例。手工测试是手动输入的方式执行测试,自动化测试可以减少软件开发阶段运行测试用例的数量,一般是自动化测试与手动测试相结合; 这里主要分享Junit单元测试,Hamcrest断言,进行分层级测试和测试MOCK

单元测试

单元测试是最基础的测试,粒度最低

在这里插入图片描述

从下到上为单元测试Unit Test, 集成测试Integration Test,端对端测试End to End Test,越往下越独立,越往上越集成,执行效率低;一般情况下仅针对单个方法或者单个类的测试为小测试,针对服务和模块的测试为中型测试

  • 编写不同粒度的测试
  • 粒度越大的测试,所应编写的用例越少

单元测试是针对最下可测试单元的测试,可以测试方法,所测试单元与外界的依赖应该尽可能为0; Junit是java的一个测试框架: 容易上手,可以注解@Test识别测试方法,并且可以断言预测,自动运行检查结果

<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
</dependency>

引入依赖后就可以使用测试、断言等,因为间接引入相关的jar;

基础测试

单元测试常使用的注解为: 【】中为Junit4中的,5已经发生了变化

  • @Test: 作用于方法,表明一个方法作为一个测试用例
  • @BeforeEach【@Before】: 作用于方法,表明该方法会在该类中每个方法之前执行,执行某些必要先决条件
  • @BeforeAll【@BeforeClass】: 作用于静态方法,表示其在类的所有测试之前执行一次,一般用于提供测试计算,共享配置方法
  • @AfterEach【@After】: 作用域方法啊,表示该方法会在该类中每个方法执行之后执行,一般用于提供重置某些变量,删除临时变量
  • @AfterAll【@AfterClass】: 作用于静态方法,所有测试用例之后执行,一边拿用于清理资源,比如关闭数据库连接
  • @Disabled【@Ignore】: 作用域方法,暂时禁用特定的测试执行,标注之后不再执行
  • @ExtendWith【@RunWith】: 作用于类,确定该类运行时所依赖的运行器,不标注使用默认的,【Junit4的】,5使用@ExtendWith 用于声明性地注册扩展,这样的注解可继承
  • @Parameters: 使用参数化功能
  • @SuiteClasses: 套件测试

简单的测试示例:

package com.Jning.cfengtestdemo;

import org.junit.jupiter.api.*;

/**
 * @author Cfeng
 * @date 2022/7/25
 * 简单测试执行顺序
 */

public class JunitOrderTest {

    //all 都是作用于静态方法之上的
    @BeforeAll
    public static void beforeClass() {
        System.out.println("在所有方法执行之前执行 before all");
    }

    @BeforeEach
    public void setup() {
        System.out.println("在每个方法之前 before each ");
    }

    @Test
    public void test_1() {
        System.out.println("test1 执行");
    }

    @Test
    public void test_2() {
        System.out.println("test_2 执行");
    }

    @AfterEach
    public void teardown() {
        System.out.println("在每个测试之后 after each");
    }

    @AfterAll  //只能作用于静态方法
    public static void afterAll() {
        System.out.println("所有测试之后  after all");
    }
}

可以执行测试方法test_1,查看测试的结果

在所有方法执行之前执行 before all

在每个方法之前 before each 
test1 执行
在每个测试之后 after each

所有测试之后  after all

可以看到两个ALL方法分别最早和最晚执行,之后就是两个each,中间就是test标注的方法的执行

异常处理

junit4中expected属性,5中使用assertThrows即可,下面会提到

Junit4 中@Test除了标注测试用例之外,还可以指定测试过程中预期遇到的异常, 通过expected属性指定【5不行】

@Test(ecpected = FileNotFoundException.class)
public void testReadFile() throw IOException {
    //文件名不存在
    FileReader  reader = new FileReader("non-exists.txt");
    reader.read();
    reader.close();
}

@Rule
public ExpectedException thrown = ExpectedException.none()

如果不这样处理就要像普通类一样try catch处理,但是这样excepiton.getMessage内容不固定

可以通过@Rule结合org.junit.rules.TestRule接口的实现类,灵活添加或者重新定义每一个测试方法的行为

而在Junit5中@Test没有参数,直接将参数放到函数中即可使用assertThrows即可

    @Test
    public void testReadFile() throws IOException {
        Assertions.assertThrows(Exception.class, () -> {
            //文件名不存在
            FileReader reader = new FileReader("cfeng.test");
            reader.read();
            reader.close();
        });
    }

参数化测试 @ParameterizedTest + @xxxSource

自定义运行器Parameterized结合@Parameters可以将测试参数化,根据提供的多组测试数据运行多个测试用例

比如这里测试项目中的计算工具类

public class FibonacciUtil {

    public static int compute(int n) {
        int result = 0;
        if(n <= 1) {
            result = n;
        } else {
            result = compute(n -1) + compute(n -2);
        }
        return result;
    }
}

在测试类中测试方法上面直接加上 @ParameterizedTest,表明该测试示例使用参数化测试

同时分别通过注解指定数据源,数组

  • @ValueSource: 指定简单数据源 , @ValueSource(strings = {“a”,“b”})
  • @NullSource: 指定null为其中一个数据
  • @EnumSource: 指定枚举类型数据源,通过引入方法参数自动选择数据源
  • @MethodSource: 方法数据源
  • @CsvSource: Csv格式数据源
    @ParameterizedTest
    @ValueSource(ints = {0,1,2,3,4,5,6,7,8})
    public void FibnacciTest(int input) { //自动加入上面的source input就是数组中的数据
        System.out.println(input + " 时结果为: " + FibonacciUtil.compute(input));
    }

测试结果:

0 时结果为: 0
1 时结果为: 1
2 时结果为: 1
3 时结果为: 2
4 时结果为: 3
5 时结果为: 5
6 时结果为: 8
7 时结果为: 13
8 时结果为: 21

套件汇总测试结果【多测试类同时运行】

在Junit4中可以通过Suit运行器【@RunWith引入】实现多个测试类同时运行,

//junit4 中套件测试使用
@RunWith(Suite.class)
@Suite.SuiteClasses({XXXTest.class,XXXTest.class})
public void SuitTest() {
    //类内容为空,这里只是作为容器存在
}

忽略测试用例

如果遇到一些原因要忽略测试用例,junit4使用@Ignore完成,Junit5通过@Disabled完成

@Diabled
@Test
public void test() {
    assertThat(1.is(1));
}

Junit4, 5 区别

Junit5是目前使用比较广泛的版本,有别于之前的版本,其由3个子项目: Junit平台 + Junit Jupiter + Junit Vintage;

  • Junit 平台: 在JVM上启动测试框架,使用一个抽象的TestEngineApi定义运行在平台的测试框架
  • Junit Jupiter: 包含最新的编程模型和扩展机制
  • Junit Vintage: 允许在平台上允许Junit4和Junit5的测试用例

简单介绍上面没有提到的注解:

  • @ParameterizedTest: 表示方法为参数化测试,除非重写这些方法,否则他们将被继承
  • @RepeatedTest: 表示方法是重复测试的测试模板,…
  • @TestFactory: 表示方法是用于动态测试的测试工厂,…
  • @TestTemplate: 表示方法是测试用例的模板,…
  • @TestMethodOrder: 为带注解的测试类配置测试方法的执行顺序,类似4中的@FixMethodOrder
  • @TestInstance: 用于为带注解的测试类配置测试示例的生命周期
  • @DisPlayName: 声明测试类或者方法的自定义的显示名称,不继承
  • @DisPlayNameGeneration: 声明测试类或方法的自定义名称的生成器
  • @Nested: 表示带注解的类为一个非静态的嵌套测试类,@AfterAll和@BeforeAll不能作用于…
  • @Tag: 在类或方法生声明用于过滤测试示例的标签【@Catagory在4】
  • @Timeout: 如果执行超过时间,那么测试、测试工厂、测试示例都失败
  • @RegisterExtension: 通过字段以编程方式注册扩展【@ExtendWith是直接注册扩展】
  • @TempDir: 用于通过生命周期方法或测试方法的字段注入或者参数注入 提供临时目录

Junit4和5大体上相同,但是导入的包不同,5是org.junit.jupiter,4是使用的org.junit.jupiter-r.api.Test

并且5中@Test不再有参数,参数都是移到函数中,所以异常处理的方式不同,timeout不同,以及相关注解发生变化,Junit5更好,可以支持同时使用多个扩展,而4只能一个Runner

    @Test
    public void testReadFile() throws IOException {
        Assertions.assertThrows(Exception.class, () -> {
            //文件名不存在
            FileReader reader = new FileReader("cfeng.test");
            reader.read();
            reader.close();
        });
    }

    @Test
//    @Timeout(10)
    //Assertions下面的断言,前面都是相关的参数,Exception就是期望的异常类型,timeout就是超时的设置,后面的Lambda表达式中书写断言代码
    public void testTimeout() throws InterruptedException {
        Assertions.assertTimeout(Duration.ofMillis(10),() -> {
            Thread.sleep(100);
        });
    }

断言

在编写代码的过程中,往往需要做出一些判断,断言的作用就是在代码中捕获这些判断,将判断的结果以布尔表达式的形式展现,当运行结果为false,结束运行,这里就可以当作异常处理的一种方式,java中有assert提供该功能

assert基础断言【JDK】

JDK自带的assert关键字进行最基础的断言,使用的方式

  • assert< 布尔表达式>
  • assert< 布尔表达式> : < 错误信息表达式>

如果布尔表达式的结果为false,或抛出java.lang.AssertionError终止程序, 第二种方式会另外在抛出的错误异常信息中将错误信息表达式的内容附加

    @Test
    public void TestJavaAssert() {
        //断言1结果为true,继续执行
        assert 1 == 1;
        System.out.println("第一个断言顺利通过");
        //断言2结果为false,抛异常终止
        assert 1 > 1 : "Cfeng, 这里出现了错误";
        System.out.println("第二个断言顺利通过");
    }

测试结果:

第一个断言顺利通过

java.lang.AssertionError: Cfeng, 这里出现了错误

Junit 中的断言 Assertions.XXX

Junit为了帮助开发人员编写测试用例提供一组静态测试方法,可以直接使用,比如使用Assert.assertEquals【junit4】,在Junit5中,使用的是Asserts静态工具类

常用的方法:

  • Assertions.assertEquals 断言二者相等
  • Assertions.assertNotEquals 断言二者不相等
  • Assertions.assertNull 断言对象为Null
  • Assertions.assertNotNull 断言对象不为Null
  • Assertions.assertSame 断言两个引用指向同一个对象 ==
  • Assertions.assertNotSame 断言两个引用不指向同一个对象
  • Assertions.assertTrue 断言condition的结果为真,否则将message抛出
  • Assertions.assertFlase 断言condition的结果为假,否则将message抛出
  • Assertions.assertArrayEquals 断言数组相等
  • Assertions.fail 使测试立刻失效

使用Junit中的断言可以提高测试用例代码的可读性

    @Test
    public void TestJunitAssert() {
        //断言二者相等
        Assertions.assertEquals(1,1);
        //断言二者不相等
        Assertions.assertNotEquals(1,2);

        Assertions.assertNull(null);

        Assertions.assertNotNull(null);
        //...... 使用Assertions的方法代替assert可以提高代码可读性
    }

org.opentest4j.AssertionFailedError: expected: not ,抛出Error为AssertionFailedError

assertThat() 优化断言 【静态导入 import static】hamrcrest中CoreMatchers

这里为了方便测试文件中就直接使用各种hamcrest内置的匹配器,直接静态导入整个类中的方法,使用*通配

import static org.hamcrest.CoreMatchers.*;

虽然上面的方法很方便,但是不足以覆盖方方面面,Junit集成Hamcrest提供assertThat< T actual, Matcher< ? super T》 matcher),其中Mathcer是Hamcrest提供的匹配符,具有更强的可读性

//在Junit4中使用harmcrest提供的相关匹配器即可
//比如equalTo greaterThan lessThan closeTo  sameInsatance greaterThanEqualTo ......
 @Test
    public void testWithAssertThat() {
        //equalTo断言逻辑等于匹配项
        assertThat(1,equalTo(1));
        //断言map包含元素
        Map<Integer,String> map = new HashMap<>();
        map.put(1,"Cfeng");
        map.put(2,"World");
        assertThat(null,hasItem(map));
    }

这里的hasItem方法中放置的是Iterable对象,就是可以迭代的,而Map,List,Set等都实现了Itrable接口,都可以使用iterator, 这样就可以判断map中是否包含第一个参数

java.lang.AssertionError: Expected: a collection containing <{1=Cfeng, 2=World}>
but: was null

自定义Harmcrest匹配器

虽然不能直接使用harmcrest提供的匹配器了,但是junit5还是具有assertThat方法,还是支持自定义匹配器,自定义的匹配器分为两种:

  • 实现BaseMatcher< T》
  • TypeSafeMatcher<t》

Ctrl + I 查看Matcher的子类可以发现BaseMatcher,BaseMathcer下面就是TypeSafeMatcher所以这里就演示BaseMatcher自定义匹配器

package com.Jning.cfengtestdemo.matcher;

import org.hamcrest.BaseMatcher;
import org.hamcrest.Description;
import org.hamcrest.Matcher;

/**
 * @author Cfeng
 * @date 2022/7/26
 */

public class isCfengWorldMatcher extends BaseMatcher<String> {

    public static <T> Matcher<String> isCfengWorld() {
        //返回匹配器示例,就是一个单例
        return new isCfengWorldMatcher();
    }

    //匹配的业务逻辑
    @Override
    public boolean matches(Object actual) {
        if(actual == null) {
            return false;
        }
        String s = (String)actual;
        return s.startsWith("Cfeng") && s.endsWith("World");
    }
    //对输入项的描述,在断言失败的时候就会打印到console中
    @Override
    public void describeTo(Description description) {
        description.appendText("a string that start with \"Cfeng\" and end with \"World\"");
    }
}

这里要保证和原生匹配器一致,实现的两个方法分别用于实现匹配逻辑,以及对匹配器的描述,还要首先定义一个静态放你发返回当前类的示例,mathches匹配,description为描述

这里要引入自定义匹配器和上面的内置的是相同的,先静态导入静态方法,在测试方法中直接使用即可

import static com.Jning.cfengtestdemo.matcher.isCfengWorldMatcher.isCfengWorld;

     @Test
    public void testWithAssertThat_withDiyMatcher() {
        assertThat("Cfeng love the World",isCfengWorld());

    }

断言框架AssertJ 流式断言

AssertJ和Hamcrest都是用于断言的框架,在Junit5中已经代替Hamcrest称为推荐的选项

流式断言 【Junit4 中使用较多】

AssertJ的语法就是Fluent流式,所有的断言都是以assertThat(value)开始,value为需要匹配的对象,当断言的匹配项很多时,语法使用方便

也就是和之前的stream处理类似可以连续进行处理,流式断言就是可以连续进行断言

@Test
public void fluentTest() {
    Integer[] arr = new Integer[] {1,2,3};
    
    assertThat(1)
              .isEqualTo(1)
              .isLessThan(2)
              .isIn(arr);
        
    assertThat(arr)
              .hasSize(3)
              .contains(2,3)
              .doesNotContain(4);
}
对象之间的比较

AssertJ提供了一些针对对象字段的比较的方法,即便是非常复杂的对象,也可以使用使用这些对象完成对象的比较,即便所比较的对象类型不同,也可以当作匹配项进行比较

//比如有两种类型的对象User,Visitor
assertThat(user1)
        .isEqualToComparingFieldByField(user2) //各字段均匹配
        .isEqualToIgnoringGivenFields(user3,"firsetname","lastname");//忽略所给出的字段

在Juniti5中使用Asserttions就可以完成很大部分需求再加上Hamcrest的自定义匹配器就可以正常完成测试

测试模拟Mock

Mock就是测试的替身,可以使用Test Doubles描述,指的是测试过程中对于一些不易获取的对象,可以创造一个替身对象模拟这些对象的行为

测试替身

当项目的规模变得庞大之后,类与类之间,模块模块之间存在依赖和耦合,这个时候就不太可能进行低粒度的测试了,比如A依赖B、C,C依赖D,D依赖E;

要创建A类的实例,就必须创建B、C的实例,这些类还有各自的前置依赖,所以就十分复杂,可以使用Mock解决该问题

测试替身分为不同的种类,各自有不同的作用: Dummy Object Stub Spy Mock Object

Dummy Object

虚拟对象,占位符,该测试替身的作用在于填充无关参数,以保证测试用例可以成功运行,实际使用中,可能就是一个简单的null

Dummy Object所替代的部分在测试过程中是不允许被调用的

Stub

System under test SUT所运行的环境会影响SUT的行为,所以为了更好控制,可以使用测试桩模块: 根据预期设置返回值,并代替对应组件逻辑返回,这样就可以完成解耦

Spy

同样是SUT的行为,所以为了更好的可见性,可以捕获一些输出的上下文来代替,Spy是最直观的方式,公开SUT的间接输出,便于验证

Mock Object

模拟对象实现行为验证,与Spy类似,获取对于SUT间接输出的可见性,但是使用上有差异,Mock Object在创建时必须根据预定义的方法调用返回结果

Mockito框架

Mockito是常用的mock工具,Spring boot starter test起步依赖中集成了AspectJ,Junit,Hamcrest,Mockito等框架,可以直接使用

import static org.mockito.Mockito.*; //都是静态方法  
@Test
    public void testCreateMock() {
        //首先时候用静态导入Mockito包,和之前的CoreMatchers类似
        //创建Mock对象
        List mockedList = mock(List.class);
        //使用mock对象
        mockedList.add("one");
        mockedList.clear();
        //验证目标方法被调用verify,也就是验证是否使用了mock对象
        verify(mockedList).add("one");
        verify(mockedList).clear();
    }

通过mock(class)就可以创建一个特定类型的对象,之后就可以当作class类型进行使用,一旦Mock Object被创建,它就会记录自身方法的调用记录,所以就可以通过verify方法进行验证方法是否调用

//如果目标对象没有执行某个方法就抛出异常
verify(mockedList).size();


Wanted but not invoked:
list.size();
-> at com.Jning.cfengtestdemo.MockitoTests.testCreateMock(MockitoTests.java:28)
However, there were exactly 2 interactions with this mock:
list.add("one");
-> at com.Jning.cfengtestdemo.MockitoTests.testCreateMock(MockitoTests.java:22)
list.clear();
-> at com.Jning.cfengtestdemo.MockitoTests.testCreateMock(MockitoTests.java:23)

这里直接给出list.size没有使用过,add和clear使用过

Stubbing 测试桩 Mock具体类型 when().then

Mockito的Stub可以通过Mock对象实现,可以Mock具体的类型,不仅仅是接口

    @Test
    public void testMockStub() {
        LinkedList mockedList = mock(LinkedList.class);

        //测试桩mock,when then可以设置操作mock对象的预期的结果,设置stub的操作结果
        when(mockedList.get(0)).thenReturn("first");
        when(mockedList.get(1)).thenThrow(new RuntimeException());

        //这样就可以使用mock对象,操作结果就是when部分定义的
        System.out.println(mockedList.get(0));
        //抛出异常
//        System.out.println(mockedList.get(1));
        //get(999)没有打测试桩,输出的结果为空null
        System.out.println(mockedList.get(999));
        //验证get(0)是否被调用,如果没有调用就会给出提示,调用就正常通过
        verify(mockedList).get(0);
    }

可以通过when(mock对象).thenReturn, thenThrow来打桩,给出操作mock对象的预期的结果,没有打测试桩的位置就会返回null

@Mock、@Spy 、@Captor @InjectMocks

使用mock注解可以方便实现Mock对象的创建与注入,不必再调用Mockito.mock方法(static)

不使用注解的时候是; 需要手动mock

import static org.mockito.Mockito.*; //都是静态方法  
@Test
    public void testCreateMock() {
        //首先时候用静态导入Mockito包,和之前的CoreMatchers类似
        //创建Mock对象
        List mockedList = mock(List.class);
        //使用mock对象
        mockedList.add("one");
        mockedList.clear();
        //验证目标方法被调用verify,也就是验证是否使用了mock对象
        verify(mockedList).add("one");
        verify(mockedList).clear();
    }

使用注解@Mock,标注在成员变量上面,就会自动进行mock,当然需要进行init,init【openMocks】的内容就是: 使用mock注解工具类init该类的Mocks对象,也就是将此类中的所有加上@Mock的成员变量进行mock

public class MockAnnotationTests {

    @Mock
    List<String> mockedList;

    @BeforeEach
    public void init() {
        //将@mock注解标注的变量都mock对象
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void whenUseMockAnnotation_thenMocksIsInjected() {
        mockedList.add("one");
        verify(mockedList).add("one");
    }
}

这里加@Mock再进行初始化openMocks就可以自动mock对象,用于使用

  • 通过@Spy在需要标注的对象上面,同样可以使用Stubbing操作设定方法的返回值,如果没有对其stubbing,那么就会委托给原本的实例执行
    @Spy
    List<String> spiedList = new ArrayList<>();

    //使用spy不需要告诉开启mock,直接就会spy对象
    @Test
    public void whenUserSpyAnnotation_thenSpyIsInjectedCorrectly() {
        spiedList.add("one");
        spiedList.add("two");
        verify(spiedList).add("one");
        Assertions.assertEquals(spiedList.size(),2);

        //stubbing
        doReturn(100).when(spiedList).size();

        Assertions.assertEquals(spiedList,100);
    }

@Spy不需要进行BeforeEach操作

  • @Cptor用于帮助创建ArgumentCaptor对象,该对象配合verify方法一起捕获方法参数
    @Captor
    ArgumentCaptor argumentCaptor;

    @Test
    public void argumentCaptorTest() {
        List list1 = mock(List.class);
        List list2 = mock(List.class);
        list1.add("Cfeng");
        list2.add("Jim");
        list2.add("Bob");

        verify(list1).add(argumentCaptor.capture());
    }

这样就可以直接捕获参数,而不需要再写一次Cfeng

  • @InjectMocks

创建一个实例,将使用了@Mock的字段注入其中,也就是会将@Mock的对象注入给@InjectMocks添加的对象上面创建一个新的实例; 就和spring的注入类似

 * InjectMocks作用的对象
 */

public class DictionaryTest {
    Map<String,String> wordMap;

    public DictionaryTest() {
        this.wordMap = new HashMap<>();
    }

    public void add(final String word, final String meaning) {
        wordMap.put(word,meaning);
    }

    public String getMeaning(final String word) {
        return wordMap.get(word);
    }
}

之后在测试类中进行注入

    @Mock
    Map<String, String > wordMap;

    @InjectMocks
    DictionaryTest dic = new DictionaryTest();

    @BeforeEach
    public void init() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void whenUseInjectMocksAnnotation_thenCorrect() {
        //stubbing
        when(wordMap.get("word")).thenReturn("Cfeng");

        //断言
        assertThat(dic.getMeaning("word")).isEqualTo("Cfeng");
    }

使用@Mock就不需要开启mock,不然对象mock不成功,nullpointer异常,assertThat为aspectj中Assertions中的静态方法,匹配器也是aspectj的

这里小小总结一下需要的static相关import

  • Junit5中推荐使用Assertions.assertXXX, 需要导入Junit中junipter中的import org.junit.jupiter.api.Assertions;
  • 如果要使用AssertThat断言,需要引入静态导入aspectj中Assertions的方法: import static org.assertj.core.api.Assertions.assertThat; 【不是harmcrest】
  • 如果要使用hamcrest提供的默认的匹配器matcher,需要静态导入harmcrest的CoreMatchers类 import static org.hamcrest.CoreMatchers.*; 【如果使用了aspectj,就使用的是aspectj的判断方法,不需要导入此类;aspectj使用更广泛】
  • 如果要直接使用mock,verify等模拟方法,需要静态引入Mockito中的Mockito类中的静态方法 import static org.mockito.Mockito.*;

集成测试

单元测试只能针对较少的功能点,除它之外,最主要的就是集成Integration test,单元测试运行较快,因为相对对立,但是集成测试需要依赖spring 框架,为了让we专注功能点,使用了几个重要的注解协助测试

@WebMvcTest 表示层

帮助we开展侧重于表示层的集成测试,作用且仅可作用于Spring MVC组件,使用该注解之后,全局的自动配置禁用,只是启用mvc相关的配置

这里为了方便测试,快速构建一个简单的单体CRUD,使用JPA快速搭建

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class TestUser {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;

    private String userName;

    private String userPwd;

    private Integer userAge;

    private String userClass;
}

public interface TestUserRepository extends JpaRepository<TestUser,Integer> {

    TestUser queryTestUserByUserName(String userName);
}


* 这里很简单,就不创建接口了,并且只是调用repository
 */

@Service
@RequiredArgsConstructor
public class TestUserService {

    private final TestUserRepository userRepository;

    public List<TestUser> findAll() {
        return userRepository.findAll();
    }

    public TestUser findUserByName(String name) {
        return userRepository.queryTestUserByUserName(name);
    }

    public TestUser registerUser(TestUser user) {
        return userRepository.save(user);
    }
}

设置一下项目的欢迎页面

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    //设置欢迎页面
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("login");
        registry.setOrder(Ordered.HIGHEST_PRECEDENCE);
    }
}

之后再编写一个发送json格式数据的前台页面

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body style="background-color: mediumvioletred">
    <form>
        姓名:<input type="text" name="userName" placeholder="请输入姓名"><br>
        密码: <input type="password" name="userPwd" placeholder="请输入密码"><br>
        年龄: <input type="text" name="userAge"><br>
        班级: <input type="text" name="userClass"><br>
        <input type="submit" value="注册">
    </form>
    <!-- 当项目中有很多文件时,最好改名,不然系统会找寻之前的 -->
    <script src="/js/jquery.js"></script>
    <script type="text/javascript">
        $("form").submit(function () {
            console.log("执行了方法");
            //必须封装为Json对象后台才能够以对象接收
            let formObj = {};
            //响应的JSON数组
            let formArray = $("form").serializeArray(); //序列化表单元素(类似 .serialize () 方法 ),返回 JSON 数据结构数据。. 注意: 此方法返回的是 JSON 对象而非 JSON 字符串
            $.each(formArray,function (i,item) {
                formObj[item.name] = item.value;
            });
            //使用AJAX,创建POST请求,访问8888/article
            $.ajax({
                type: 'POST',
                url: "/user/register", //指定处理路径,和security配置中一致
                contentType: 'application/json',
                data: JSON.stringify(formObj), //将一个 JavaScript 对象或值转换为 JSON 字符串;JSON字符串,不是JSON对象
                success: function () {
                    alert(data);
                }
            })
        })
    </script>
</body>
</html>

再编写一个主页测试

<!DOCTYPE html >
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>主页</title>
</head>
<body style="background-color: aquamarine">
    <h2>WELCOME ADMIN</h2>
    <hr style="color: mediumvioletred"/>
    <center><span>查询名称</span></center><br>
    <div>
        查询者名称 <input type="text" id="userName" name="userName"><br>
        <br>
        查询结果:<span id="result"></span><br>
        <input id="queryByName" type="button" value="查询">
    </div>
    <hr>
    一键获取:
    <input type="button" id="queryAll" value="查询所有">
    <div id="allResult"></div>


    <script src="/jquery.js"></script>
    <script type="text/javascript">
        $("#queryByName").click(function () {
            alert("查询ByName");
            if(!$("#userName").val()) {
                alert("不能为空");
                return false;
            }
            //发送AJAX请求
            $.get("/user/queryByName",{"userName": $("#userName").val()},function (response) {
                console.log(response);
                //将span替换为结果,返回的JSon为字符串,需要转为JSON对象,下面的也一样
                response = $.parseJSON(response);
                $("#result").text(response.userName + " :  班级为 " + response.userClass + " 年龄为 " + response.userAge);
            },"text")
        });

       $("#queryAll").click(function () {
            alert("查询所有");
           //发送AJAX请求
           $.get("/user/queryAll",{},function (response) {
                   console.log(response);
                   //将span替换为结果
                   response = $.parseJSON(response); //必须转换,不然each就会报错
                   var result = new String();
                   result += "<table border='10' bordercolor='pink' width= '50%' align= 'center' background= '/cloud.jpg'><tr align='center'><th>姓名</th><th>班级</th><th>年龄</th></tr>";
                   $.each(response,function (index,element) {
                        result += "<tr align='center'><td>" + element.userName + "</td><td>" + element.userClass + "</td><td>" + element.userAge + "</td></tr>";
                   })
                   result += "</table>";
                   $("#allResult").html(result);
               },"text")
        })
    </script>
</body>
</html>

最后编写一个简单的userController

@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class TestUserController {

    private final TestUserService userService;

    @PostMapping("/register")
    public String registerUser(@RequestBody TestUser user) {
        user = userService.registerUser(user);
        if(user == null) {
           return  "注册失败";
        }
        return user.getUserName() + "注册成功";
    }

    @RequestMapping("/queryByName")
    public TestUser queryByName(String userName) {
        System.out.println(userName);
        return userService.findUserByName(userName);
    }

    @GetMapping("/queryAll")
    public List<TestUser> queryAll() {
        return userService.findAll();
    }
}

接下来就使用WebMvcTest来测试这个Controller

使用@WebMvcTest指定测试的控制器,属性中MockMvc为SPring Test自动装配的测试工具,实现模拟HTTP请求,能够直接以网络形式转换为controller转换,不依赖网络环境

@MockBean为Mockito提供的,可以模拟测试对象的依赖,打测试桩,屏蔽依赖,这样就不会加载容器中真正的对象

验证Http请求 perform【MockMvc】

验证控制器对于某个HTTP请求的监听,调用MockMvc的perform方法提供要测试的URL即可; 其参数为RequestBuilder;可以使用RequestBuilders直接创建 , get/post为发送的Http类型,加上URL,expect为期望的结果

package com.Jning.cfengtestdemo;

import com.Jning.cfengtestdemo.controller.TestUserController;
import com.Jning.cfengtestdemo.service.TestUserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import javax.annotation.Resource;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * @author Cfeng
 * @date 2022/7/27
 * 表示层集成测试,使用注解@WebMvcTest
 */

@WebMvcTest(controllers = TestUserController.class) //测试该controller
public class UserControllerTests {

    @Resource
    private MockMvc mockMvc;  //容器中自动装载的对象,实现了Http模拟,直接以网络形式

    @Resource
    private ObjectMapper objectMapper; //用于JSON的转换

    @MockBean
    private TestUserService userService; //Mockito框架,模拟测试依赖,打测试桩屏蔽下层

    /**
     * 测试Http请求的匹配,使用perform方法即可
     */
    @Test
    public void whenGetApiUser_thenReturns200() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/user/queryAll")).andExpect(status().isOk());
        System.out.println(mockMvc.perform(MockMvcRequestBuilders.get("/user/queryAll")).toString());
    }
验证输入 post请求 json序列化

为了验证输入内容是否成功序列化,必须再测试过程中的请求体中附加请求内容

@Test
    public void whenPostApiUser_thenReturns200() throws Exception {
        TestUser user = new TestUser().setUserName("严大美").setUserPwd("12345").setUserAge(3).setUserClass("幼儿园3班");
        mockMvc.perform(MockMvcRequestBuilders
                .post("/user/register")//ajax中type和url
                .contentType("application/json") //类似之前的ajax中设置的contentType
                .content(objectMapper.writeValueAsString(user))) //类似之前的data; 所以前台接收到的是字符串,使用的是Json对象,需要$.parseJSON,之前的eval
                //预测结果
                .andExpect(status().isOk());
    }

可以设置contentType和content,转为json发送给controller进行验证

验证输入检查 @Valid

测试对象controller使用@Validated开启检查,相关实体类也开启了输入检查

@NotNull
private String userName;

@NotNull
private String userPwd;

public String registerUser(@Validated @RequestBody TestUser user) {

配置之后就可以查看检查

@Test
    public void whenPostApiUser_thenFail() throws Exception {
        //构造一个空属性对象
        TestUser user = new TestUser().setUserName("严大欢").setUserPwd("123").setUserAge(2).setUserClass(null);
        mockMvc.perform(MockMvcRequestBuilders
                .post("/user/register")
                .contentType("application/json")
                .content(objectMapper.writeValueAsString(user)))
                .andExpect(status().isOk());
    }

这样会抛出异常: java.lang.NullPointerException: userClass is marked non-null but is null

验证依赖项的调用

demo中的service编写的很简单,实际开发中的Service很复杂,从控制器输入到调用依赖的过程中,可能改变输入内容,通过Mockito,可以确认调用情况

这里的mockBean是web中的,但是依赖的静态方法还是和之前相同

    @Test
    public void whenPostApiUser_thenCheckSave() throws Exception {
        TestUser user = new TestUser().setUserName("严大美").setUserPwd("12345").setUserAge(3).setUserClass("幼儿园3班");
        mockMvc.perform(MockMvcRequestBuilders
                .post("/user/register")//ajax中type和url
                .contentType("application/json") //类似之前的ajax中设置的contentType
                .content(objectMapper.writeValueAsString(user))); //类似之前的data; 所以前台接收到的是字符串,使用的是Json对象,需要$.parseJSON,之前的eval
        //创建参数捕获器
        ArgumentCaptor<TestUser> userArgumentCaptor = ArgumentCaptor.forClass(TestUser.class); //捕获TestUser类型的参数
        //确认是否调用过service以及调用次数
        verify(userService,times(1)).registerUser(userArgumentCaptor.capture());//该类型的参数
        assertThat(userArgumentCaptor.getValue().getUserName()).isEqualTo("严大美");
        assertThat(userArgumentCaptor.getValue().getUserAge()).isEqualTo(4);
    }
验证输出

业务逻辑验证之后,可能还需要验证输出的内容,通过MockMvc的andReturn获取MvcResult,对MvcResult中的响应体验证

    @Test
    public void whenPostApiUser_thenCheckResponse() throws Exception {
        TestUser user = new TestUser().setUserName("严大美").setUserPwd("12345").setUserAge(3).setUserClass("幼儿园3班");
        //只是测试控制层,所以给UserService stubbing
        when(userService.registerUser(eq(user))).thenReturn(user);

       //验证输出
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders
                .post("/user/register")//ajax中type和url
                .contentType("application/json") //类似之前的ajax中设置的contentType
                .content(objectMapper.writeValueAsString(user))) //类似之前的data; 所以前台接收到的是字符串,使用的是Json对象,需要$.parseJSON,之前的eval
                .andExpect(status().isOk())
                //返回MvcResult对象
                .andReturn();
        //验证结果,响应的结果获取response
        assertThat(result.getResponse().getContentAsString())
                .isEqualTo(objectMapper.writeValueAsString(user));
    }

这里会抛出异常,因为返回的结果为自定义的string,不是user

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /user/register
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"92"]
             Body = {"id":null,"userName":"严大美","userPwd":"12345","userAge":3,"userClass":"幼儿园3班"}
    Session Attrs = {}

Handler:
             Type = com.Jning.cfengtestdemo.controller.TestUserController
           Method = com.Jning.cfengtestdemo.controller.TestUserController#registerUser(TestUser)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null
ModelAndView:
        View name = null
             View = null
            Model = null
FlashMap:
       Attributes = null
MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"text/plain;charset=UTF-8", Content-Length:"21"]
     Content type = text/plain;charset=UTF-8
             Body = 严大美注册成功
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

org.opentest4j.AssertionFailedError: 
expected: "{"id":null,"userName":"严大美","userPwd":"12345","userAge":3,"userClass":"幼儿园3班"}"
 but was: "严大美注册成功"

可以看到这里的测试的报错给出的信息非常详细,非常便于排查错误

@DataJpaTest 持久层

和@WebMvcTest类似,帮助we进行侧重于存储层的集成测试,仅可作用于JPA组件,使用注解之后,全局的自动配置禁用,取而代之是启用JPA测试的配置

之前提到过,就简单看看即可

@DataJpaTest
public class UserRepositoryTests {

    @Resource
    TestUserRepository userRepository;

    @Test
    public void saveUser_thenFindByName() {
        //保存用户数据
        TestUser user = new TestUser().setUserName("严小欢子").setUserPwd("1567").setUserAge(2).setUserClass("HC3002");
        //保存数据
        userRepository.save(user);
        TestUser found = userRepository.queryTestUserByUserName("严小欢子");
        Assertions.assertNotNull(found);
        assertThat(found).isEqualTo(user);
    }
}

报错: java.lang.IllegalStateException: Failed to load ApplicationContext 是因为测试文件的目录结构应该与main中的repository相同,因为系统找寻路径都是固定的

直接查看报错: i 这里报错提示没有提供emmbed数据库,因为模拟环境不是操作真实的数据库; 而是操作测试用的默认的内存h2数据库,所以如果要使用mysql,需要将pom中的依赖替换scope为默认的compile即可

同时新版的springBoot不需要再使用@ExtendWith表明Spring环境,因为已经作为元注解嵌入在这些集成测试注解中

测试repository可以直接使用@SpirngBootTest加载容器对象使用测试即可

除了@DataJpaTest之外,还有其他的数据层的集成测试注解:

  • @JdbcTest: 基于JDBC组件
  • @DataMongoTest: 测试MongoDB应用,配置一个内存嵌入式MongoDB,配置Template、Reposiroty和@Document类
  • @DataRedisTest: 测试Redis的应用,扫描@RedisHash类,配置Reposiroty

@SpringBootTest

测试该注解使用最广泛,只要在类上加上该注解,就会自动加载SpringBoot容器,获取容器中的对象进行测试,但是像Web的功能测试还是使用@WebMvcTest好一点;该注解默认不会将服务真实启动,如果需要改变行为,那么需要修改webEnvironment的值

  • MOCK: 默认加载ApplicationContext并且提供模拟的网络环境,不会启动服务
  • RANDOM_PORT: 提供真实的网络环境监听随机端口
  • DEFINED_PORT: 真实, 监听已经声明端口
  • NONE: 只是加载ApplicationContext,不会提供任何环境【模拟,真实】

@SpringBootTest + @Test 就可以满足大部分需求🚅

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐