Spring MVC -- 单元测试和集成测试

测试在软件开发中的重要性不言而喻。测试的主要目的是尽早发现错误,最好是在代码开发的同时。逻辑上认为,错误发现的越早,修复的成本越低。如果在编程中发现错误,可以立即更改代码;如果软件发布后,客户发现错误所需要的修复成本会很大。

在软件开发中有许多不同的测试,其中两个是单元测试和集成测试。通常从单元测试开始测试类中的单个方法,然后进行集成测试,以测试不同的模块是否可以无缝协同工作。

本篇博客中的示例使用JUnit测试框架以及Spring test模块。Spring test模块中的API可用于单元测试和集成测试。可以在org.springframework.test及其子包以及org.springframework.mock.*包中找到Spring测试相关的类型。

一 单元测试

单元测试的理想情况下是为每个类创建一个测试类,并为类中的每个方法创建一个测试方法,像getter和setter这样的简单方法除外,他们直接从字段返回值或赋值给字段。

在测试语中,被测试的类称为被测系统(SUT)。

单元测试旨在快速且多次运行。单元测试仅验证代码本身,而不涉及它的依赖,其任何依赖应该被帮助对象代替。设计依赖项的测试通常在集成测试中完成,而不是在单元测试。

你可能会问,我们可以使用main()方法从类本身内测试一个类,为什么还需要单元测试内?这主要是因为,单元测试具有以下好处:

  • 在单独测试类在编写测试代码不会混淆你的类;
  • 单元测试可以用于回归测试,在一些逻辑发生变化时,以确保一切仍然工作;
  • 单元测试可以在持续集成设置中自动化测试;持续集成是指一种开发方法,当程序员将他们的代码提交到共享库时,每次代码提交将触发一次自动构建并运行所有单元测试,持续集成可以尽早的检测问题。

在单元测试中,类使用new运算符实例化。不依赖Spring框架的依赖注入容易来创建bean。

下面我们创建一个被测试类MyUtility:

package com.example.util;
public class MyUtility{
    public int method1(int a,int b){...}
    public long method(long a){...}  
}

为了对这个类进行测试,创建一个MyUtilityTest类,注意每个方法应该至少有一个测试方法:

package com.example.util;
public class MyUtilityTest{
    public void testMethod1(){
        MyUtility utility = new MyUtility();
        int result = utility.method1(100,200);
        //assert that result equals the expected value
    }
     public void testMethod2(){
        MyUtility utility = new MyUtility();
        long result = utility.method2(100L);
        //assert that result equals the expected value
    }
}

单元测试有些约定俗成,首先是将测试类命名为与带有Test的SUT相同的名称。因此,MyUtility的测试类应命名为MyUtilityTest;其次,测试类的包路径应与SUT相同,以允许前者访问后者的公开和默认成员。但是,测试类应位于不同于测试的类的源文件夹下。

测试方法没有返回值。在测试方法中,你实例化要测试的类,调用要测试的方法并验证结果。为了使测试类更容易编写,你应该使用测试框架,例如JUnit或TestNG。

这一节将会介绍用JUnit编写测试类的例子,JUnit事实上是Java的标准单元测试框架。

1、应用JUnit

对于单元测试,推荐使用JUnit。我们可以从http://junit.org下载它。我们需要下载junit.jar和org.hamcrest.core.jar文件,后者是JUnit的依赖项,目前junit.jar版本为4.12。

如果你使用的是Maven或STS,请将元素添加到pom.xml文件以下载JUnit及其依赖关系:

<dependency>
    <groupId>junitgroupId>
    <artifactId>junitartifactId>
    <version>4.12version>
    <scope>testscope>
dependency>

如果想了解更多JUnit的信息和使用,可以阅读官方文档。

2、开发一个单元测试

编写单元测试很简单,使用@Test简单的注解所有测试方法。此外,可以通过@Before注解来创建初始化方法,初始化方法在调用任何测试方法之前调用。我们还可以通过@After注解方法来创建清理方法,清理方法在测试类中的所有测试方法执行之后调用,并且可以来释放测试期间使用的资源。

下面展示了需要进行单元测试的Calculator类:

package com.example;

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
}

然后我们创建一个单元测试CalculatorTest类:

package com.example;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;

public class CalculatorTest {

    @Before
    public void init() {
    }
    
    @After
    public void cleanUp() {
    }
    
    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(5, 8);
        Assert.assertEquals(13, result);
    }
    
    @Test
    public void testSubtract() {
        Calculator calculator = new Calculator();
        int result = calculator.subtract(5, 8);
        Assert.assertEquals(-3, result);
    }
    
}

CalculatorTest类有两个方法,一个初始化方法和一个清除方法。org.junit.Assert类提供用于声明结果的静态方法。例如,assertEquals()方法用来能比较两个值。

3、运行一个单元测试 

Eclipse知道一个类是否是一个JUnit测试类。要运行测试类,请右键单击包资源管理器中的测试类,然后选择运行方式Run As JUnit Test。

测试完成后,如果JUnit视图尚未打开,Eclipse将打开它。如果单元测试成功完成后,将会在JUnit视图中看到一个绿色条:

4、通过测试套件来运行

在有十几个类的小项目中,你将有十几个测试类。在一个更大的项目中,你会有更多测试类。在Eclipse中,运行一个测试类很容易,但是如何运行所有的测试类。

使用JUnit的解决方案非常简单。创建一个Java类并使用@RunWith(Suite.class)和@SuiteClasses()注解它。后者应该列出你想要运行的所有类和其他套件测试。

下面演示一个测试套件:

package com.example;

import org.junit.runner.RunWith;
import org.junit.runners.Suite;
import org.junit.runners.Suite.SuiteClasses;

@RunWith(Suite.class)
@SuiteClasses({ CalculatorTest.class, MathUtilTest.class })
public class MyTestSuite {

}

二 应用测试挡板(Test Doubles)

被测系统(SUT)很少孤立存在。通常为了测试一个类,你需要依赖。在测试中,你的SUT所需要的依赖称为协作者。

协作者经常被称为测试挡板的其他对象取代。使用测试挡板有几个原因:

  • 在编写测试类时,真正的依赖还没有准备好;
  • 一些依赖项,例如HttpServletRequest和HttpServletResponse对象,是从servlet容器获取的,而自己创建这些对象将会非常耗时;
  • 一些依赖关系启动和初始化速度较慢。例如,DAO对象访问数据库导致单元测试执行很慢;

测试挡板在单元测试中广泛使用,也用于集成测试。当前有许多用于创建测试挡板的框架,Spring也有自己的类来创建测试挡板。

模拟框架可用于创建测试模板和验证代码行为,这里有一些流行的框架:

  • Mockito;
  • EasyMock;
  • jMock

除了上面的库,Spring还附带了创建模拟对象的类。不过,这一节,只详细介绍如何使用Mockito。

使用Mockito需要Mockito的发布包(一个mockito.jar文件)及其依赖(一个objenesis.jar文件),这里给出一个整合后的jar包下载网址:http://maven.outofmemory.cn/org.mockito/mockito-all/。

在开始写测试挡板之前,需要先学习理论知识。如下是测试挡板的5种类型:

  • dummy;
  • stub;
  • spy;
  • fake;
  • mock;

这些类型中的每一种将在下面的小节中解释。

1、dummy

dummy是最基本的测试挡板类型。一个dummy是一个协作者的实现,它不做任何事情,并不改变SUT的行为。它通常用于使SUT可以实例化。dummy只是在开发的早起阶段使用。

例如,创建一个ProductServiceImpl类,这个类依赖于传递给构造函数的ProductDAO:

package com.example.service;
import java.math.BigDecimal;

import com.example.dao.ProductDAO;

public class ProductServiceImpl implements ProductService {

    private ProductDAO productDAO;

    public ProductServiceImpl(ProductDAO productDAOArg) {
        if (productDAOArg == null) {
            throw new NullPointerException("ProductDAO cannot be null.");
        }
        this.productDAO = productDAOArg; 
    }

    @Override
    public BigDecimal calculateDiscount() {
        return productDAO.calculateDiscount();
    }
    
    @Override
    public boolean isOnSale(int productId) {
        return productDAO.isOnSale(productId);
    }
}

ProductServiceImpl类需要一个非空的ProductDAO对象来实例化。同时,要测试的方法不使用ProductDAO。因此,可以创建一个dummy对象,只需让ProductServiceImpl可实例化:

package com.example.dummy;

import java.math.BigDecimal;

import com.example.dao.ProductDAO;

public class ProductDAODummy implements ProductDAO {
    public BigDecimal calculateDiscount() {
        return null;
    }
    public boolean isOnSale(int productId) {
        return false;
    };
}

在dummy类中的方法实现什么也不做,它的返回值也不重要,因为这些方法从未使用过。

下面显示一个可以运行的测试类ProductServiceImplTest:

package com.example.dummy;

import static org.junit.Assert.assertNotNull;

import org.junit.Test;

import com.example.dao.ProductDAO;
import com.example.service.ProductService;
import com.example.service.ProductServiceImpl;

public class ProductServiceImplTest {

    @Test
    public void testCalculateDiscount() {
        ProductDAO productDAO = new ProductDAODummy();
        ProductService productService = new ProductServiceImpl(productDAO);
        assertNotNull(productService);
    }

}

ProductService接口:

package com.example.service;

import java.math.BigDecimal;

public interface ProductService {
    BigDecimal calculateDiscount();
    boolean isOnSale(int productId);

}
View Code

ProductDAO接口:

package com.example.dao;

import java.math.BigDecimal;

public interface ProductDAO {
    BigDecimal calculateDiscount();
    boolean isOnSale(int productId);
}
View Code

2、stub

像dummy一样,stub也是依赖接口的实现。和dummy 不同的是,stub中的方法返回硬编码值,并且这些方法被实际调用。

下面创建一个stub,可以用于测试ProductServiceImpl类:

package com.example.stub;

import java.math.BigDecimal;

import com.example.dao.ProductDAO;

public class ProductDAOStub implements ProductDAO {
    public BigDecimal calculateDiscount() {
        return new BigDecimal(14);
    }
    public boolean isOnSale(int productId) {
        return false;
    };
}

下面显示一个可以运行的测试类ProductServiceImplTest:

package com.example.stub;

import static org.junit.Assert.assertNotNull;

import org.junit.Test;

import com.example.dao.ProductDAO;
import com.example.service.ProductService;
import com.example.service.ProductServiceImpl;

public class ProductServiceImplTest {

    @Test
    public void testCalculateDiscount() {
        ProductDAO productDAO = new ProductDAOStub();
        ProductService productService = new ProductServiceImpl(productDAO);
        assertNotNull(productService);
    }

}

3、spy

spy是一个略微智能一些的sub,因为spy可以保留状态。考虑下面的汽车租赁应用程序。其中包含一个GarageService接口和一个GarageServiceImpl类。

GarageService接口:

package com.example.service;

import com.example.MyUtility;

public interface GarageService {
    MyUtility rent();
}

GarageServiceImpl类:

package com.example.service;

import com.example.MyUtility;
import com.example.dao.GarageDAO;

public class GarageServiceImpl implements GarageService {
    private GarageDAO garageDAO;
    public GarageServiceImpl(GarageDAO garageDAOArg) {
        this.garageDAO = garageDAOArg;
    }
    public MyUtility rent() {
        return garageDAO.rent();
    }
}

GarageService接口只有一个方法:rent()。GarageServiceImpl类是GarageService的一个实现,并且依赖一个GarageDAO ,GarageServiceImpl中的rent()方法调用GarageDAO 中的rent()方法。

package com.example.dao;

import com.example.MyUtility;

public interface GarageDAO {
    MyUtility rent();
}

GarageDAO 的实现rent()方法应该返回一个汽车,如果还有汽车在车库:或者返回null,如果没有更多的汽车。

由于GarageDAO的真正实现还没有完成。创建一个GarageDAOSpy类被用作测试挡板,它是一个spy,因为它的方法返回一个硬编码值,并且它通过一个carCount变量来确保车库里的车数。

package com.example.spy;

import com.example.MyUtility;
import com.example.dao.GarageDAO;

public class GarageDAOSpy implements GarageDAO {
    private int carCount = 3;
    
    @Override
    public MyUtility rent() {
        if (carCount == 0) {
            return null;
        } else {
            carCount--;
            return new MyUtility();
        }   
    }
}

下面显示了使用GarageDAOSpy测试GarageServiceImplTest类的一个测试类:

package com.example.spy;

import org.junit.Test;

import com.example.MyUtility;
import com.example.dao.GarageDAO;
import com.example.service.GarageService;
import com.example.service.GarageServiceImpl;

import static org.junit.Assert.*;

public class GarageServiceImplTest {

    @Test
    public void testRentCar() {
        GarageDAO garageDAO = new GarageDAOSpy();
        GarageService garageService = new GarageServiceImpl(garageDAO);
        MyUtility car1 = garageService.rent();
        MyUtility car2 = garageService.rent();
        MyUtility car3 = garageService.rent();
        MyUtility car4 = garageService.rent();
        
        assertNotNull(car1);
        assertNotNull(car2);
        assertNotNull(car3);
        assertNull(car4);
    }

}

由于在车库中只有3辆车,spy智能返回3辆车,当第四次调用其rent()方法时,返回null。

4、fake

fake的行为就像一个真正的协作者,但不适合生成,因为它走“捷径”。内存存储是一个fake的完美示例,因为它的行为像一个DAO,不会将其状态保存到硬盘驱动器。

我们创建一个Member实体类:

package com.example.model;

public class Member {
    private int id;
    private String name;
    public Member(int idArg, String nameArg) {
        this.id = idArg;
        this.name = nameArg;
    }

    public int getId() {
        return id;
    }
    public void setId(int idArg) {
        this.id = idArg;
    }

    public String getName() {
        return name;
    }
    public void setName(String nameArg) {
        this.name = nameArg;
    }
}

然后创建一个MemberServiceImpl类,其实现了MemberService接口:

package com.example.service;

import java.util.List;

import com.example.model.Member;

public interface MemberService {
    public void add(Member member);

    public List getMembers();

}

MemberServiceImpl类可以将Member对象成员添加到memberDAO并检索所有存储的成员:

package com.example.service;

import java.util.List;

import com.example.dao.MemberDAO;
import com.example.model.Member;

public class MemberServiceImpl implements MemberService {

    private MemberDAO memberDAO;

    public void setMemberDAO(MemberDAO memberDAOArg) {
        this.memberDAO = memberDAOArg;
    }

    @Override
    public void add(Member member) {
        memberDAO.add(member);
    }

    @Override
    public List getMembers() {
        return memberDAO.getMembers();
    }

}

MemberServiceImpl依赖于MemberDAO。但是,由于没有可用的MemberDAO实现,我们可以创建一个MemberDAO的fake实现MemberDAOFake类,以便可以立即测试MemberServiceImpl。MemberDAOFake类它将成员存储在ArrayList中,而不是持久化存储。因此,不能在生成中使用它,但是对于单元测试是足够的:

package com.example.fake;
import java.util.ArrayList;
import java.util.List;

import com.example.dao.MemberDAO;
import com.example.model.Member;

public class MemberDAOFake implements MemberDAO {
    private List members = new ArrayList<>();
        
    @Override
    public void add(Member member) {
        members.add(member);
    }

    @Override
    public List getMembers() {
        return members;
    }
}

下面我们将会展示一个测试类MemberServiceImplTest ,它使用MemberDAOFake作为MemberDAO的测试挡板来测试MemberServiceImpl类:

package com.example.service;

import org.junit.Assert;
import org.junit.Test;

import com.example.dao.MemberDAO;
import com.example.fake.MemberDAOFake;
import com.example.model.Member;

public class MemberServiceImplTest {

    @Test
    public void testAddMember() {
        MemberDAO memberDAO = new MemberDAOFake();
        MemberServiceImpl memberService = new MemberServiceImpl();   
        memberService.setMemberDAO(memberDAO);
        memberService.add(new Member(1, "John Diet"));
        memberService.add(new Member(2, "Jane Biteman"));
        Assert.assertEquals(2, memberService.getMembers().size());
    }
}

5、mock

mock导入理念上不同于其它测试挡板。使用dummy、stub、spy和fake来进行状态测试,即验证方法的输出。而使用mock来执行行为(交互)测试,以确保某个方法真正被调用,或者验证一个方法在执行另一个方法期间被调用了一定的次数。

创建一个MathUtil类:

package com.example;
public class MathUtil {
    private MathHelper mathHelper;
    public MathUtil(MathHelper mathHelper) {
        this.mathHelper = mathHelper;
    }
    public MathUtil() {
        
    }
    
    public int multiply(int a, int b) {
        int result = 0;
        for (int i = 1; i <= a; i++) {
            result = mathHelper.add(result, b);
        }
        return result;
    }
    
}

MathUtil类有一个方法multiply(),它非常直接,使用多个add()方法类。换句话说,3x8计算为8+8+8。MathUtil类并不知道如何执行add()。因为,它依赖于MathHelper对象:

package com.example;

public class MathHelper {
    public int add(int a, int b) {
        return a + b;
    }
}

测试所关心的并不是multiply()方法的结果,而是找出方法是否如预期一样执行。因此,在计算3x8时,它应该调用MathHelper对象add()方法3次。

下面我们展示了一个使用MathHelper模拟的测试类。Mockito是一个流行的模拟框架,用于创建模拟对象。

package com.example;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.junit.Test;

public class MathUtilTest {
    
    @Test
    public void testMultiply() {
        MathHelper mathHelper = mock(MathHelper.class);
        for (int i = 0; i < 10; i++) {
            when(mathHelper.add(i * 8, 8)).thenReturn(i * 8 + 8);
        }
        MathUtil mathUtil = new MathUtil(mathHelper);
        mathUtil.multiply(3, 8);
        verify(mathHelper, times(1)).add(0, 8);
        verify(mathHelper, times(1)).add(8, 8);
        verify(mathHelper, times(1)).add(16, 8);
    }
}

使用Mockito创建mock对象非常简单,只需调用org.mockito.Mockito的静态方法mock(),下面展示如何创建MathHelper  mock对象:

MathHelper mathHelper = mock(MathHelper.class);

解下来,你需要使用when()方法准备mock对象。基本上,你告诉它,给定使用这组参数的方法调用,mock对象必须返回这个值。例如,这条语句是说如果调用mathHelper.add(10,20),返回值必须是10+20:

when(mathHelper.add(i * 8, 8)).thenReturn(i * 8 + 8);

对于此测试,准备具有十组参数的mock对象(但不是所有的参数都会被使用)。

for (int i = 0; i < 10; i++) {
     when(mathHelper.add(i * 8, 8)).thenReturn(i * 8 + 8);
}

然后创建要测试的对象并调用其参数:

MathUtil mathUtil = new MathUtil(mathHelper);
mathUtil.multiply(3, 8);

接下来的3条语句是行为测试。为此,调用verify()方法:

verify(mathHelper, times(1)).add(0, 8);
verify(mathHelper, times(1)).add(8, 8);
verify(mathHelper, times(1)).add(16, 8);

第一条语句验证mathHelper.add(0,8)被调用了一次,第二条语句验证mathHelper.add(8,8)被调用了一次,第三条语句验证mathHelper.add(16,8)被调用了一次。

三 对Spring MVC Controller单元测试

在前几节中已经介绍了如何在Spring MVC应用程序中测试各个类。但是Controller有点不同,因为它们通常与Servlet API对象(如HttpServletRequest、HttpServletResponse、HttpSession等)交互。在许多情况下,你将需要模拟这些对象以正确测试控制器。

像Mockito或EasyMock这样的框架是可以模拟任何Java对象的通用模拟框架,但是你必须自己配置生成的对象(使用一系列的when语句)。而Spring Test模拟对象是专门为使用Spring而构建的,并且与真实对象更接近,更容易使用,以下讨论其中一些重要的单元测试控制器类型。

1、MockHttpServletRequest和MockHttpServletResponse

当调用控制器时,你可能需要传递HttpServletRequest和HttpServletResponse。在生产环境中,两个对象都由servlet容器本身提供。在测试环境中,你可以使用org.springframework.mock.web包中的MockHttpServletRequest和MockHttpServletResponse类。

这两个类很容易使用。你可以通过调用其无参构造函数来创建实例:

 MockHttpServletRequest request = new MockHttpServletRequest();
 MockHttpServletResponse response = new MockHttpServletResponse();

MockHttpServletRequest类实现了javax.servlet.http.HttpServletRequest,并允许你将实例配置看起来像一个真正的HttpServletRequest。它提供了方法来设设置HttpServletRequest中的所有属性以及获取器属性的值,下表显示了它的一些方法

方法 描述
addHeader 添加一个HTTP请求头
addParameter 添加一个请求参数
getAttribute 返回一个属性
getAttributeNames 返回包含了全部属性名的一个Enumeration对象
getContextPath 返回上下文路径
getCookies 返回全部的cookies
setMethod 设置HTTP方法
setParameter 设置一个参数值
setQueryString 设置查询语句
setRequestURI 设置请求URI

MockHttpServletResponse类实现了javax.servlet.http.HttpServletResponse,并提供了配置实例的其它方法,下表显示了其中一些主要的方法:

方法 描述
addCookie 添加一个cookie
addHeader 添加一个HTTP请求头
getContentLength 返回内容长度
getWriter 返回Writer
getOutputStream 返回ServletOutputStream

下面演示一个例子。首先创建一个控制器类VideoController:

package com.example.controller;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class VideoController {
    @RequestMapping(value = "/mostViewed")
    public String getMostViewed(HttpServletRequest request, HttpServletResponse response) {
        Integer id = (Integer) request.getAttribute("id");
        if (id == null) {
            response.setStatus(500);
        } else if (id == 1) {
            request.setAttribute("viewed", 100);
        } else if (id == 2) {
            request.setAttribute("viewed", 200);
        }
        return "mostViewed";
    }
}

VideoController类的getMostViewed()方法中,若请求属性id存在且值为1或2,则添加请求属性“viewed”。否则,不添加请求属性。

我们创建一个测试类VideoControllerTest,使用两个测试方法来验证VideoController:

package com.example.controller;

import org.junit.Test;
import static org.junit.Assert.*;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

public class VideoControllerTest {
    @Test
    public void testGetMostViewed() {
        VideoController videoController = new VideoController();
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setRequestURI("/mostViewed");
        request.setAttribute("id", 1);
        MockHttpServletResponse response = new MockHttpServletResponse();

        // must invoke
        videoController.getMostViewed(request, response);
        
        assertEquals(200, response.getStatus());
        assertEquals(100L, (int) request.getAttribute("viewed"));
        
    }
    
    @Test
    public void testGetMostViewedWithNoId() {
        VideoController videoController = new VideoController();
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setRequestURI("/mostViewed");
        MockHttpServletResponse response = new MockHttpServletResponse();

        // must invoke
        videoController.getMostViewed(request, response);
        
        assertEquals(500, response.getStatus());
        assertNull(request.getAttribute("viewed"));        
    }
}

testGetMostViewed()方法实例化VideoController类并创建两个mock对象,一个MockHttpServletRequest和一个MockHttpServletResponse。它还设置请求URI,并向MockHttpServletRequest添加属性“id”。

   VideoController videoController = new VideoController();
     MockHttpServletRequest request = new MockHttpServletRequest();
     request.setRequestURI("/mostViewed");
     request.setAttribute("id", 1);
     MockHttpServletResponse response = new MockHttpServletResponse();

然后调用VideoController的getMostViewed()方法,传递mock对象,然后验证响应的状态码为200,请求包含一个值为100的“viewed”属性:

        // must invoke
        videoController.getMostViewed(request, response);
        
        assertEquals(200, response.getStatus());
        assertEquals(100L, (int) request.getAttribute("viewed"));

VideoControllerTest的第二个方法类似方法一,但不会向MockHttpServletRequest对象添加"id"属性。因此,在调用控制器的方法时,它接收HTTP响应状态代码500,并且在MockHttpServletRequest对象中没有“viewed”属性。

2、ModelAndViewAssert

ModelAndViewAssert类是org.springframework.test.web包的一部分,是另一个有用的Spring类,用于测试模型从控制器请求处理方法返回的ModelAndView。在Spring MVC -- 基于注解的控制器中介绍过,ModelAndView是请求处理方法可以返回得到类型之一,该类型包含有关请求方法的模型和视图信息,其中模型是用来提供给目标视图,用于界面显示的。

/*
 * Copyright 2002-2018 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.test.web;

import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.springframework.util.ObjectUtils;
import org.springframework.web.servlet.ModelAndView;

import static org.springframework.test.util.AssertionErrors.assertTrue;
import static org.springframework.test.util.AssertionErrors.fail;

/**
 * A collection of assertions intended to simplify testing scenarios dealing
 * with Spring Web MVC {@link org.springframework.web.servlet.ModelAndView
 * ModelAndView} objects.
 *
 * 

Intended for use with JUnit 4 and TestNG. All {@code assert*()} methods * throw {@link AssertionError AssertionErrors}. * * @author Sam Brannen * @author Alef Arendsen * @author Bram Smeets * @since 2.5 * @see org.springframework.web.servlet.ModelAndView */ public abstract class ModelAndViewAssert { /** * Checks whether the model value under the given {@code modelName} * exists and checks it type, based on the {@code expectedType}. If the * model entry exists and the type matches, the model value is returned. * @param mav the ModelAndView to test against (never {@code null}) * @param modelName name of the object to add to the model (never {@code null}) * @param expectedType expected type of the model value * @return the model value */ @SuppressWarnings("unchecked") public static T assertAndReturnModelAttributeOfType(ModelAndView mav, String modelName, Class expectedType) { Map model = mav.getModel(); Object obj = model.get(modelName); if (obj == null) { fail("Model attribute with name '" + modelName + "' is null"); } assertTrue("Model attribute is not of expected type '" + expectedType.getName() + "' but rather of type '" + obj.getClass().getName() + "'", expectedType.isAssignableFrom(obj.getClass())); return (T) obj; } /** * Compare each individual entry in a list, without first sorting the lists. * @param mav the ModelAndView to test against (never {@code null}) * @param modelName name of the object to add to the model (never {@code null}) * @param expectedList the expected list */ @SuppressWarnings("rawtypes") public static void assertCompareListModelAttribute(ModelAndView mav, String modelName, List expectedList) { List modelList = assertAndReturnModelAttributeOfType(mav, modelName, List.class); assertTrue("Size of model list is '" + modelList.size() + "' while size of expected list is '" + expectedList.size() + "'", expectedList.size() == modelList.size()); assertTrue("List in model under name '" + modelName + "' is not equal to the expected list.", expectedList.equals(modelList)); } /** * Assert whether or not a model attribute is available. * @param mav the ModelAndView to test against (never {@code null}) * @param modelName name of the object to add to the model (never {@code null}) */ public static void assertModelAttributeAvailable(ModelAndView mav, String modelName) { Map model = mav.getModel(); assertTrue("Model attribute with name '" + modelName + "' is not available", model.containsKey(modelName)); } /** * Compare a given {@code expectedValue} to the value from the model * bound under the given {@code modelName}. * @param mav the ModelAndView to test against (never {@code null}) * @param modelName name of the object to add to the model (never {@code null}) * @param expectedValue the model value */ public static void assertModelAttributeValue(ModelAndView mav, String modelName, Object expectedValue) { Object modelValue = assertAndReturnModelAttributeOfType(mav, modelName, Object.class); assertTrue("Model value with name '" + modelName + "' is not the same as the expected value which was '" + expectedValue + "'", modelValue.equals(expectedValue)); } /** * Inspect the {@code expectedModel} to see if all elements in the * model appear and are equal. * @param mav the ModelAndView to test against (never {@code null}) * @param expectedModel the expected model */ public static void assertModelAttributeValues(ModelAndView mav, Map expectedModel) { Map model = mav.getModel(); if (!model.keySet().equals(expectedModel.keySet())) { StringBuilder sb = new StringBuilder("Keyset of expected model does not match.\n"); appendNonMatchingSetsErrorMessage(expectedModel.keySet(), model.keySet(), sb); fail(sb.toString()); } StringBuilder sb = new StringBuilder(); model.forEach((modelName, mavValue) -> { Object assertionValue = expectedModel.get(modelName); if (!assertionValue.equals(mavValue)) { sb.append("Value under name '").append(modelName).append("' differs, should have been '").append( assertionValue).append("' but was '").append(mavValue).append("'\n"); } }); if (sb.length() != 0) { sb.insert(0, "Values of expected model do not match.\n"); fail(sb.toString()); } } /** * Compare each individual entry in a list after having sorted both lists * (optionally using a comparator). * @param mav the ModelAndView to test against (never {@code null}) * @param modelName name of the object to add to the model (never {@code null}) * @param expectedList the expected list * @param comparator the comparator to use (may be {@code null}). If not * specifying the comparator, both lists will be sorted not using any comparator. */ @SuppressWarnings({"unchecked", "rawtypes"}) public static void assertSortAndCompareListModelAttribute( ModelAndView mav, String modelName, List expectedList, Comparator comparator) { List modelList = assertAndReturnModelAttributeOfType(mav, modelName, List.class); assertTrue("Size of model list is '" + modelList.size() + "' while size of expected list is '" + expectedList.size() + "'", expectedList.size() == modelList.size()); modelList.sort(comparator); expectedList.sort(comparator); assertTrue("List in model under name '" + modelName + "' is not equal to the expected list.", expectedList.equals(modelList)); } /** * Check to see if the view name in the ModelAndView matches the given * {@code expectedName}. * @param mav the ModelAndView to test against (never {@code null}) * @param expectedName the name of the model value */ public static void assertViewName(ModelAndView mav, String expectedName) { assertTrue("View name is not equal to '" + expectedName + "' but was '" + mav.getViewName() + "'", ObjectUtils.nullSafeEquals(expectedName, mav.getViewName())); } private static void appendNonMatchingSetsErrorMessage( Set assertionSet, Set incorrectSet, StringBuilder sb) { Set tempSet = new HashSet<>(incorrectSet); tempSet.removeAll(assertionSet); if (!tempSet.isEmpty()) { sb.append("Set has too many elements:\n"); for (Object element : tempSet) { sb.append('-'); sb.append(element); sb.append('\n'); } } tempSet = new HashSet<>(assertionSet); tempSet.removeAll(incorrectSet); if (!tempSet.isEmpty()) { sb.append("Set is missing elements:\n"); for (Object element : tempSet) { sb.append('-'); sb.append(element); sb.append('\n'); } } } }

View Code

下表给出ModelAndViewAssert的一些主要方法:

方法 描述
assertViewName 检查ModelAndView的视图名称是都与预期名称匹配
assertModelAttributeValue 检查ModelAndView的模型是否包含具有指定名称和值的属性
assertModelAttributeAvailable 检查ModelAndView的模型是否包含具有指定名称的属性
assertSortAndCompareListModelAttribute 对ModelAndView的模型列表属性进行排序,然后将其与预期列表进行比较
assertAndReturnModelAttributeOfType 检查ModelAndView的模型是否包含具有指定名称和类型的属性

考虑一个Book实体类,有4个属性,isbn、title、author和pubDate:

package com.example.model;

import java.time.LocalDate;

public class Book {
    private String isbn;
    private String title;
    private String author;
    private LocalDate pubDate;
    
    public Book(String isbn, LocalDate pubDate) {
        this.isbn = isbn;
        this.pubDate = pubDate;
    }
    
    public Book(String isbn, String title, String author,
            LocalDate pubDate) {
        this.isbn = isbn;
        this.title = title;
        this.author = author;
        this.pubDate = pubDate;
    }
    
    public String getIsbn() {
        return isbn;
    }
    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }
    public String getTitle() {
        return title;
    }
    public void setTitle(String title) {
        this.title = title;
    }
    public String getAuthor() {
        return author;
    }
    public void setAuthor(String author) {
        this.author = author;
    }
    public LocalDate getPubDate() {
        return pubDate;
    }
    public void setPubDate(LocalDate pubDate) {
        this.pubDate = pubDate;
    }
    
    @Override
    public boolean equals(Object otherBook) {
        return isbn.equals(((Book)otherBook).getIsbn());
    }
}

创建一个Spring MVC控制器BookController,它包含一个请求处理方法getLatestTitles(),该方法接受putYear路径变量,并返回一个ModelAndView,如果putYear值为“2016”,它将包含书籍列表:

package com.example.controller;

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import com.example.model.Book;

@Controller
public class BookController {
    @RequestMapping(value = "/latest/{pubYear}")
    public ModelAndView getLatestTitles(
            @PathVariable String pubYear) {
        ModelAndView mav = new ModelAndView("Latest Titles");
        
        if ("2016".equals(pubYear)) {
            List list = Arrays.asList(
                    new Book("0001", "Spring MVC: A Tutorial", 
                            "Paul Deck", 
                            LocalDate.of(2016, 6, 1)),
                    new Book("0002", "Java Tutorial",
                            "Budi Kurniawan", 
                            LocalDate.of(2016, 11, 1)),
                    new Book("0003", "SQL", "Will Biteman", 
                            LocalDate.of(2016, 12, 12)));
            mav.getModel().put("latest", list);
        }
        return mav;
    }
}

测试BookController控制器的一种简单方式是使用ModelAndViewAssert中的静态方法,我们创建一个测试类BookControllerTest:

package com.example.controller;

import static org.springframework.test.web.ModelAndViewAssert.*;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import org.junit.Test;
import org.springframework.web.servlet.ModelAndView;

import com.example.model.Book;

public class BookControllerTest {
    @Test
    public void test() {
        BookController bookController = new BookController();
        ModelAndView mav = bookController
                .getLatestTitles("2016");
        assertViewName(mav, "Latest Titles");
        assertModelAttributeAvailable(mav, "latest");
        List expectedList = Arrays.asList(
                new Book("0002", LocalDate.of(2016, 11, 1)),
                new Book("0001", LocalDate.of(2016, 6, 1)),
                new Book("0003", LocalDate.of(2016, 12, 12)));
        assertAndReturnModelAttributeOfType(mav, "latest", 
                expectedList.getClass());
        Comparator pubDateComparator = 
                (a, b) -> a.getPubDate()
                .compareTo(b.getPubDate());
        assertSortAndCompareListModelAttribute(mav, "latest", 
                expectedList, pubDateComparator);
    }
}

assertSortAndCompareListModelAttribute()方法的第4个参数需要传入一个比较器对象,其实现了Comparator接口。

四 应用Spring Test进行集成测试

集成测试用来测试不同的模块是否可以一起工作。它还确保两个模块之间数据的传递,使用Spring框架依赖注入容器,必须检查bean依赖注入。

若没有合适的工具,集成测试可能需要很多时间。想想一下,如果你正在建立一个网上商店,你必须使用浏览器来测试购物车是否正确计算。每次更改代码,你必须重新启动浏览器,登录系统,将几个项目条件到购物车,并检查总数是否正确,每次迭代会花费几分钟。

好在,Spring提供了一个用于集成测试的模块:Spring Test。

Spring的MockHttpServletRequest、MockHttpServletResponse、MockHttpSession类适用于对Spring MVC控制器进行单元测试,但它们缺少与集成测试相关的功能。例如,它们直接调用请求处理方法,无法测试请求映射和数据绑定。它们也不测试bean依赖注入,因为SUV类使用new运算符实例化。

对于集成测试,你需要一组不同的Spring MVC测试类型。以下小结讨论集成测试的API,并提供一个示例。

1、API

作为Spring的一个模块,Spring Test提供了一些实用类,可以放的在Spring MVC应用程序上执行集成测试。bean是使用Spring依赖注入器创建的,并从ApplicationContext中获取(ApplicationContext代表一个Spring反转控制容器),就像在一个真正的Spring应用程序中一样。

MockMvc类位于org.springframework.test.web.servlet包下,是Spring Test中的主类,用于帮助集成测试。此类允许你使用预定义的请求映射来调用请求处理方法。

/*
 * Copyright 2002-2018 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.test.web.servlet;

import java.util.ArrayList;
import java.util.List;
import javax.servlet.AsyncContext;
import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import javax.servlet.ServletContext;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;

import org.springframework.beans.Mergeable;
import org.springframework.lang.Nullable;
import org.springframework.mock.web.MockFilterChain;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.util.Assert;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.DispatcherServlet;

/**
 * Main entry point for server-side Spring MVC test support.
 *
 * 

Example

* *
 * import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
 * import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
 * import static org.springframework.test.web.servlet.setup.MockMvcBuilders.*;
 *
 * // ...
 *
 * WebApplicationContext wac = ...;
 *
 * MockMvc mockMvc = webAppContextSetup(wac).build();
 *
 * mockMvc.perform(get("/form"))
 *     .andExpect(status().isOk())
 *     .andExpect(content().mimeType("text/html"))
 *     .andExpect(forwardedUrl("/WEB-INF/layouts/main.jsp"));
 * 
* *
@author Rossen Stoyanchev * @author Rob Winch * @author Sam Brannen * @since 3.2 */ public final class MockMvc { static final String MVC_RESULT_ATTRIBUTE = MockMvc.class.getName().concat(".MVC_RESULT_ATTRIBUTE"); private final TestDispatcherServlet servlet; private final Filter[] filters; private final ServletContext servletContext; @Nullable private RequestBuilder defaultRequestBuilder; private List defaultResultMatchers = new ArrayList<>(); private List defaultResultHandlers = new ArrayList<>(); /** * Private constructor, not for direct instantiation. * @see org.springframework.test.web.servlet.setup.MockMvcBuilders */ MockMvc(TestDispatcherServlet servlet, Filter... filters) { Assert.notNull(servlet, "DispatcherServlet is required"); Assert.notNull(filters, "Filters cannot be null"); Assert.noNullElements(filters, "Filters cannot contain null values"); this.servlet = servlet; this.filters = filters; this.servletContext = servlet.getServletContext(); } /** * A default request builder merged into every performed request. * @see org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder#defaultRequest(RequestBuilder) */ void setDefaultRequest(@Nullable RequestBuilder requestBuilder) { this.defaultRequestBuilder = requestBuilder; } /** * Expectations to assert after every performed request. * @see org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder#alwaysExpect(ResultMatcher) */ void setGlobalResultMatchers(List resultMatchers) { Assert.notNull(resultMatchers, "ResultMatcher List is required"); this.defaultResultMatchers = resultMatchers; } /** * General actions to apply after every performed request. * @see org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder#alwaysDo(ResultHandler) */ void setGlobalResultHandlers(List resultHandlers) { Assert.notNull(resultHandlers, "ResultHandler List is required"); this.defaultResultHandlers = resultHandlers; } /** * Return the underlying {@link DispatcherServlet} instance that this * {@code MockMvc} was initialized with. *

This is intended for use in custom request processing scenario where a * request handling component happens to delegate to the {@code DispatcherServlet} * at runtime and therefore needs to be injected with it. *

For most processing scenarios, simply use {@link MockMvc#perform}, * or if you need to configure the {@code DispatcherServlet}, provide a * {@link DispatcherServletCustomizer} to the {@code MockMvcBuilder}. * @since 5.1 */ public DispatcherServlet getDispatcherServlet() { return this.servlet; } /** * Perform a request and return a type that allows chaining further * actions, such as asserting expectations, on the result. * @param requestBuilder used to prepare the request to execute; * see static factory methods in * {@link org.springframework.test.web.servlet.request.MockMvcRequestBuilders} * @return an instance of {@link ResultActions} (never {@code null}) * @see org.springframework.test.web.servlet.request.MockMvcRequestBuilders * @see org.springframework.test.web.servlet.result.MockMvcResultMatchers */ public ResultActions perform(RequestBuilder requestBuilder) throws Exception { if (this.defaultRequestBuilder != null && requestBuilder instanceof Mergeable) { requestBuilder = (RequestBuilder) ((Mergeable) requestBuilder).merge(this.defaultRequestBuilder); } MockHttpServletRequest request = requestBuilder.buildRequest(this.servletContext); AsyncContext asyncContext = request.getAsyncContext(); MockHttpServletResponse mockResponse; HttpServletResponse servletResponse; if (asyncContext != null) { servletResponse = (HttpServletResponse) asyncContext.getResponse(); mockResponse = unwrapResponseIfNecessary(servletResponse); } else { mockResponse = new MockHttpServletResponse(); servletResponse = mockResponse; } if (requestBuilder instanceof SmartRequestBuilder) { request = ((SmartRequestBuilder) requestBuilder).postProcessRequest(request); } final MvcResult mvcResult = new DefaultMvcResult(request, mockResponse); request.setAttribute(MVC_RESULT_ATTRIBUTE, mvcResult); RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes(); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, servletResponse)); MockFilterChain filterChain = new MockFilterChain(this.servlet, this.filters); filterChain.doFilter(request, servletResponse); if (DispatcherType.ASYNC.equals(request.getDispatcherType()) && asyncContext != null && !request.isAsyncStarted()) { asyncContext.complete(); } applyDefaultResultActions(mvcResult); RequestContextHolder.setRequestAttributes(previousAttributes); return new ResultActions() { @Override public ResultActions andExpect(ResultMatcher matcher) throws Exception { matcher.match(mvcResult); return this; } @Override public ResultActions andDo(ResultHandler handler) throws Exception { handler.handle(mvcResult); return this; } @Override public MvcResult andReturn() { return mvcResult; } }; } private MockHttpServletResponse unwrapResponseIfNecessary(ServletResponse servletResponse) { while (servletResponse instanceof HttpServletResponseWrapper) { servletResponse = ((HttpServletResponseWrapper) servletResponse).getResponse(); } Assert.isInstanceOf(MockHttpServletResponse.class, servletResponse); return (MockHttpServletResponse) servletResponse; } private void applyDefaultResultActions(MvcResult mvcResult) throws Exception { for (ResultMatcher matcher : this.defaultResultMatchers) { matcher.match(mvcResult); } for (ResultHandler handler : this.defaultResultHandlers) { handler.handle(mvcResult); } } }

View Code

这是一种常见的创建MocklMvc实例的方法:

MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();

这里webAppContext是WebApplicationContext实例的一个引用,WebApplicationContext是ApplicationContext的子类,每个Spring开发人员都应该熟悉。要获取一个WebApplicationContext,你必须在测试类中声明这一点,即应用@Autowired进行依赖注入:

    @Autowired
    private WebApplicationContext webAppContext;

MockMvcBuilders读取一个为测试类定义的配置文件(一般为Spring配置文件)。后面的示例程序将演示如何指定测试类的配置文件,在这之前,我们先讨论MockMvc。

MockMvc是一个非常简单的类。事实上,它只有一个方法:perform(),用于通过URI间接调用Spring MVC控制器。

perform()方法具有以下签名:

public ResultActions perform(RequestBuilder requestBuilder) 

要测试请求处理方法,你需要创建一个RequestBuilder。好在,MockMvcRequestBuilders类提供了与HTTP method具有相同名称的静态方法:get()、post()、head()、put()、patch()、delete()等。要使用HTTP GET方法测试控制器,你可以调用get()静态方法,要使用HTTP POST方法测试,则调用post(0静态方法。这些静态方法也很容易使用,你只需要传递一个字符串——控制器的请求处理方法的URI。

例如,要调用名为getEmployee的请求处理方法,你将编写如下代码:

ResultActions resultActions = mockMvc.perform(get("getRmployee"));

当然,你必须导入MockMvcRequestBuilders的静态get()方法:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

要验证测试是否成功,你需要调用ResultActions的andExpect ()方法,andExpect()方法签名如下:

ResultActions andExpect(ResultMatcher matcher) 

注意:andExpect()返回ResultActions的另一个实例,这意味着可以链式调用多个andExpect()方法。

MockMvcResultMatchers类提供了静态方法来轻松创建ResultMatcher。

/*
 * Copyright 2002-2018 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.springframework.test.web.servlet.result;

import java.util.Map;
import javax.xml.xpath.XPathExpressionException;

import org.hamcrest.Matcher;

import org.springframework.lang.Nullable;
import org.springframework.test.web.servlet.ResultMatcher;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.util.UriComponentsBuilder;

import static org.springframework.test.util.AssertionErrors.assertEquals;
import static org.springframework.test.util.AssertionErrors.assertTrue;

/**
 * Static factory methods for {@link ResultMatcher}-based result actions.
 *
 * 

Eclipse Users

*

Consider adding this class as a Java editor favorite. To navigate to * this setting, open the Preferences and type "favorites". * * @author Rossen Stoyanchev * @author Brian Clozel * @author Sam Brannen * @since 3.2 */ public abstract class MockMvcResultMatchers { private static final AntPathMatcher pathMatcher = new AntPathMatcher(); /** * Access to request-related assertions. */ public static RequestResultMatchers request() { return new RequestResultMatchers(); } /** * Access to assertions for the handler that handled the request. */ public static HandlerResultMatchers handler() { return new HandlerResultMatchers(); } /** * Access to model-related assertions. */ public static ModelResultMatchers model() { return new ModelResultMatchers(); } /** * Access to assertions on the selected view. */ public static ViewResultMatchers view() { return new ViewResultMatchers(); } /** * Access to flash attribute assertions. */ public static FlashAttributeResultMatchers flash() { return new FlashAttributeResultMatchers(); } /** * Asserts the request was forwarded to the given URL. *

This method accepts only exact matches. * @param expectedUrl the exact URL expected */ public static ResultMatcher forwardedUrl(@Nullable String expectedUrl) { return result -> assertEquals("Forwarded URL", expectedUrl, result.getResponse().getForwardedUrl()); } /** * Asserts the request was forwarded to the given URL template. *

This method accepts exact matches against the expanded and encoded URL template. * @param urlTemplate a URL template; the expanded URL will be encoded * @param uriVars zero or more URI variables to populate the template * @see UriComponentsBuilder#fromUriString(String) */ public static ResultMatcher forwardedUrlTemplate(String urlTemplate, Object... uriVars) { String uri = UriComponentsBuilder.fromUriString(urlTemplate).buildAndExpand(uriVars).encode().toUriString(); return forwardedUrl(uri); } /** * Asserts the request was forwarded to the given URL. *

This method accepts {@link org.springframework.util.AntPathMatcher} * patterns. * @param urlPattern an AntPath pattern to match against * @since 4.0 * @see org.springframework.util.AntPathMatcher */ public static ResultMatcher forwardedUrlPattern(String urlPattern) { return result -> { assertTrue("AntPath pattern", pathMatcher.isPattern(urlPattern)); String url = result.getResponse().getForwardedUrl(); assertTrue("Forwarded URL does not match the expected URL pattern", (url != null && pathMatcher.match(urlPattern, url))); }; } /** * Asserts the request was redirected to the given URL. *

This method accepts only exact matches. * @param expectedUrl the exact URL expected */ public static ResultMatcher redirectedUrl(String expectedUrl) { return result -> assertEquals("Redirected URL", expectedUrl, result.getResponse().getRedirectedUrl()); } /** * Asserts the request was redirected to the given URL template. *

This method accepts exact matches against the expanded and encoded URL template. * @param urlTemplate a URL template; the expanded URL will be encoded * @param uriVars zero or more URI variables to populate the template * @see UriComponentsBuilder#fromUriString(String) */ public static ResultMatcher redirectedUrlTemplate(String urlTemplate, Object... uriVars) { String uri = UriComponentsBuilder.fromUriString(urlTemplate).buildAndExpand(uriVars).encode().toUriString(); return redirectedUrl(uri); } /** * Asserts the request was redirected to the given URL. *

This method accepts {@link org.springframework.util.AntPathMatcher} * patterns. * @param urlPattern an AntPath pattern to match against * @since 4.0 * @see org.springframework.util.AntPathMatcher */ public static ResultMatcher redirectedUrlPattern(String urlPattern) { return result -> { assertTrue("No Ant-style path pattern", pathMatcher.isPattern(urlPattern)); String url = result.getResponse().getRedirectedUrl(); assertTrue("Redirected URL does not match the expected URL pattern", (url != null && pathMatcher.match(urlPattern, url))); }; } /** * Access to response status assertions. */ public static StatusResultMatchers status() { return new StatusResultMatchers(); } /** * Access to response header assertions. */ public static HeaderResultMatchers header() { return new HeaderResultMatchers(); } /** * Access to response body assertions. */ public static ContentResultMatchers content() { return new ContentResultMatchers(); } /** * Access to response body assertions using a * https://github.com/jayway/JsonPath">JsonPath expression * to inspect a specific subset of the body. *

The JSON path expression can be a parameterized string using * formatting specifiers as defined in * {@link String#format(String, Object...)}. * @param expression the JSON path expression, optionally parameterized with arguments * @param args arguments to parameterize the JSON path expression with */ public static JsonPathResultMatchers jsonPath(String expression, Object... args) { return new JsonPathResultMatchers(expression, args); } /** * Access to response body assertions using a * https://github.com/jayway/JsonPath">JsonPath expression * to inspect a specific subset of the body and a Hamcrest matcher for * asserting the value found at the JSON path. * @param expression the JSON path expression * @param matcher a matcher for the value expected at the JSON path */ public static ResultMatcher jsonPath(String expression, Matcher matcher) { return new JsonPathResultMatchers(expression).value(matcher); } /** * Access to response body assertions using an XPath expression to * inspect a specific subset of the body. *

The XPath expression can be a parameterized string using formatting * specifiers as defined in {@link String#format(String, Object...)}. * @param expression the XPath expression, optionally parameterized with arguments * @param args arguments to parameterize the XPath expression with */ public static XpathResultMatchers xpath(String expression, Object... args) throws XPathExpressionException { return new XpathResultMatchers(expression, null, args); } /** * Access to response body assertions using an XPath expression to * inspect a specific subset of the body. *

The XPath expression can be a parameterized string using formatting * specifiers as defined in {@link String#format(String, Object...)}. * @param expression the XPath expression, optionally parameterized with arguments * @param namespaces namespaces referenced in the XPath expression * @param args arguments to parameterize the XPath expression with */ public static XpathResultMatchers xpath(String expression, Map namespaces, Object... args) throws XPathExpressionException { return new XpathResultMatchers(expression, namespaces, args); } /** * Access to response cookie assertions. */ public static CookieResultMatchers cookie() { return new CookieResultMatchers(); } }

View Code

MockMvcResultMatchers属于org.springframework.test.web.servlet.result包。下表显示了它的一些方法:

方法 返回类型 描述
cookie CookieResultMatchers 返回一个ResultMatchers,用来断言cookie值
header HeaderResultMatchers 返回一个ResultMatchers,用来断言HTTP香影头部
model ModelResultMatchers 返回一个ResultMatchers,用来断言请求处理的模型
status StatusResultMatchers 返回一个ResultMatchers,用来断言HTTP响应状态
view ViewResultMatchers 返回一个ResultMatchers,用来断言请求处理的视图

例如,要确保控制器方法的请求映射正确,可以使用状态方法:

mockMvc.perform(get("/getBook")).andExpect(status().isOk());

你必须导入MockMvcResultMatchers的静态status()方法:

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

isOK()方法断言响应状态码是200:

    /**
     * Assert the response status code is {@code HttpStatus.OK} (200).
     */
    public ResultMatcher isOk() {
        return matcher(HttpStatus.OK);
    }

可以看到MockMvc及其相关类使得集成测试控制器变得非常容易。

2、Spring Test测试类的框架

了解了Spring Test中的一些重要的API,现在来看下Spring MVC测试类的框架:

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration("...")
public class ProductControllerTest {
    @Autowired
    private WebApplicationContext webAppContext;

    private MockMvc mockMvc;

    @Before
    public void setup() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }
    
    @After
    public void cleanUp() {
        
    }

    @Test
    public void test1() throws Exception {
        mockMvc.perform(...) .andExpect(...);
    }

    @Test
    public void test2() throws Exception {
        mockMvc.perform(...) .andExpect(...);
    }
}

首先,看下你要导入的类型。在导入列表的顶部,是来自JUnit和Spring Test的类型。像单元测试类一样,Spring MVC测试类可以包括用@Before和@After注解的方法。两种注解类型都是JUnit的一部分。

接下去,测试框架开始与单元测试有所不用。首先是测试类运行期。你需要一个SpringJUnit4ClassRunner.class在@RunWith注解内:

@RunWith(SpringJUnit4ClassRunner.class)

这个runner允许你使用Spring。然后,你需要添加如下注解类型:

@WebAppConfiguration
@ContextConfiguration("...")

WebAppConfiguration注解类型用于声明为集成测试加载的ApplicationContext应该是WebApplicationContext类型。ContextConfiguration注解类型告诉测试运行器如何加载和配置WebApplicationContext。

此外,测试类中还需要两个对象:

    @Autowired
    private WebApplicationContext webAppContext;
    private MockMvc mockMvc;

3、示例

以下示例展示如何对Spring MVC控制器开展集成测试。integration-test应用目录结构如下:

Spring MVC -- 单元测试和集成测试_第1张图片

test-config.xml配置文件展示了将要被扫描的包。这个文件是一个典型的Spring MVC配置文件,但是去除了任何资源映射和视图解析器。但是,你可以使用实际的配置文件:

xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns:p="http://www.springframework.org/schema/p"
    xmlns:mvc="http://www.springframework.org/schema/mvc"
    xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="
        http://www.springframework.org/schema/beans
        http://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/mvc
        http://www.springframework.org/schema/mvc/spring-mvc.xsd     
        http://www.springframework.org/schema/context
        http://www.springframework.org/schema/context/spring-context.xsd">
    <context:component-scan base-package="controller"/>
    <context:component-scan base-package="service"/>    
    <mvc:annotation-driven/>
beans>

控制器EmployeeController类,只有一个请求处理方法getHighestPaid(),它映射到/highest-paid:

package controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import service.EmployeeService;
import domain.Employee;

@Controller
public class EmployeeController {
    
    @Autowired
    EmployeeService employeeService;
    
    @RequestMapping(value="/highest-paid/{category}")
    public String getHighestPaid(@PathVariable int category, Model model) {
        Employee employee = employeeService.getHighestPaidEmployee(category);
        model.addAttribute("employee", employee);
        return "success";
    }
}

EmployeeControllerTest是控制器EmployeeController的测试类:

package com.example.controller;

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.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

@RunWith(SpringJUnit4ClassRunner.class)
@WebAppConfiguration
@ContextConfiguration("test-config.xml")
public class EmployeeControllerTest {
    @Autowired
    private WebApplicationContext webAppContext;

    private MockMvc mockMvc;

    @Before
    public void setup() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext).build();
    }
    
    @After
    public void cleanUp() {
        
    }

    @Test
    public void testGetHighestPaidEmployee() throws Exception {
        mockMvc.perform(get("/highest-paid/2"))
                .andExpect(status().isOk())
                .andExpect(model().attributeExists("employee"))
                .andDo(print());
    }
}

EmployeeControllerTest类包含一个setup()方法,它创建一个MockMvc对象,testGetHighestPaidEmployee()方法执行测试,并期望响应状态代码为200,并且模型具有employee属性。

测试方法还调用了andDo(print())在响应对象中打印各种值,如果测试成功通过,则可以看到类似结果:

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /highest-paid/2
       Parameters = {}
          Headers = []
             Body = 
    Session Attrs = {}

Handler:
             Type = controller.EmployeeController
           Method = public java.lang.String controller.EmployeeController.getHighestPaid(int,org.springframework.ui.Model)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = null

ModelAndView:
        View name = success
             View = null
        Attribute = employee
            value = Xiao Ming ($200000)
           errors = []

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Language:"en"]
     Content type = null
             Body = 
    Forwarded URL = success
   Redirected URL = null
          Cookies = []

其它一些不太重要的类:

Employee类:

package domain;

import java.math.BigDecimal;

public class Employee {
    private String name;
    private BigDecimal salary;
    
    public Employee(String name, BigDecimal salary) {
        this.name = name;
        this.salary = salary;
    }
    
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public BigDecimal getSalary() {
        return salary;
    }
    public void setSalary(BigDecimal salary) {
        this.salary = salary;
    }
    
    @Override
    public String toString() {
        return name + " ($" + salary + ")"; 
    }
}
View Code

EmployeeService类:

package service;

import domain.Employee;

public interface EmployeeService {
    Employee getHighestPaidEmployee(int employeeCategory);
}
View Code

EmployeeServiceImpl类

package service;

import java.math.BigDecimal;

import org.springframework.stereotype.Service;

import domain.Employee;

@Service
public class EmployeeServiceImpl implements EmployeeService {

    public Employee getHighestPaidEmployee(int employeeCategory) {
        switch (employeeCategory) {
        case 1:
            return new Employee("Alicia Coder", new BigDecimal(123_000));
        case 2:
            return new Employee("Xiao Ming", new BigDecimal(200_000));
        default:
            return new Employee("Jay Boss", new BigDecimal(400_000));
        }
    };
}
View Code

五 总结

测试是软件开发中的一个重要步骤,你应该在开发周期中尽早的执行单元测试和集成测试这两种类型的测试:

  • 单元测试用于类的功能性验证。在单元测试中,所涉及依赖通常被测试挡板替换,其可以包括dummy、stub、spy、fake和mock对象。JUnit是一个流行的用于单元测试的框架,并且通常与mock框架(如Mockito和EasyMock)结合使用;
  • 集成测试用于确保同一个应用程序中的不同模块可以一起工作,同时确保请求映射和数据绑定也可以工作。Spring Test是一个Spring模块,它提供一组API,可以轻松的对Spring应用程序执行集成测试。

参考文章

[1] Spring MVC学习指南

你可能感兴趣的:(Spring MVC -- 单元测试和集成测试)