测试的重要性毋庸再说,但如何使测试更加准确和全面,并且独立于项目之外并且避免硬编码,JUnit给了我们一个很好的解决方案。
一、引子
首先假设有一个项目类SimpleObject如下:
public class SimpleObject{
public List methodA(){
.....
}
}
其中定义了一个methodA方法返回一个对象,好,现在我们要对这个方法进行测试,看他是不是返回一个List对象,是不是为空,或者长度是不是符合标准等等。我们写这样一个方法判断返回对象是否不为Null:
public void assertNotNull(Object object){
//判断object是否不为null
....
}
这个方法在JUnit框架中称之为一个断言,JUnit提供给我们了很多断言,比如assertEqual,assertTrue...,我们可以利用这些断言来判断两个值是否相等或者二元条件是否为真等问题。
接下来我们写一个测试类
import junit.framework.*;
public class TestSimpleObject extends TestCase{
public TestSimpleObject(String name){
super(name);
}
public void testSimple(){
SimpleObject so=new SimpleObject();
assertNotNull(so.methodA());
}
}
然后我们可以运行JUnit来检测我们的测试结果,这样我们在不影响Project文件的前提下,实现了对Project单元的测试。
二、JUnit框架的结构
通过前面的引子,其实我们已经了解了JUnit基本的结构:
1、import声明引入必须的JUnit类
2、定义一个测试类从TestCase继承
3、必需一个调用super(String)的构造函数
4、测试类包含一些以test..开头的测试方法
5、每个方法包含一个或者多个断言语句
当然还有一些其他的内容,但满足以上几条的就已经是一个JUnit测试了
三、JUnit的命名规则和习惯
1、如果有一个名为ClassA的被测试函数,那么测试类的名称就是TestClassA
2、如果有一个名为methodA的被测试函数,那么测试函数的名称就是testMethodA
四、JUnit自定义测试组合
在JUnit框相架下,他会自动执行所有以test..开头的测试方法(利用java的反射机制),如果不想让他这么“智能”,一种方法我们可以改变测试方法的名称,比如改成pendingTestMethodA,这样测试框架就会忽略它;第二种方法我们可以自己手工组合我们需要的测试集合,这个魔力我们可以通过创建test suite来取得,任何测试类都能够包含一个名为suite的静态方法:
public static Test suite();
还是以一个例子来说明,假设我们有两个名为TestClassOne、TestClassTwo的测试类,如下:
import junit.framework.*;
public class TestClassOne extends TestCase{
public TestClassOne(String method){
super(method);
}
public void testAddition(){
assertEquals(4,2+2);
}
public void testSubtration(){
assertEquals(0,2-2);
}
}
import junit.framework.*;
public class TestClassTwo extends TestCase{
public TestClassTwo(String method){
super(method);
}
public void testLongest(){
ProjectClass pc=new ProjectClass();
assertEquals(100,pc.longest());
}
public void testShortest(){
ProjectClass pc=new ProjectClass();
assertEquals(1,pc.shortest(10));
}
public void testAnotherShortest(){
ProjectClass pc=new ProjectClass();
assertEquals(2,pc.shortest(5));
}
public static Test suite(){
TestSuite suite=new TestSuite();
//only include short tests
suite.addTest(new TestClassTwo("testShortest"));
suite.addTest(new TestClassTwo("testAnotherShortest"));
}
}
首先看TestClassTwo ,我们通过suite显式的说明了我们要运行哪些test方法,而且,此时我们看到了给构造函数的String参数是做什么用的了:它让TestCase返回一个对命名测试方法的引用。接下来再写一个高一级别的测试来组合两个测试类:
import junit.framework.*;
public class TestClassComposite extends TestCase{
public TestClassComposite(String method){
super(method);
}
static public Test suite(){
TestSuite suite = new TestSuite();
//Grab everything
suite.addTestSuite(TestClassOne.class);
//Use the suite method
suite.addTest(TestClassTwo.suite());
return suite;
}
}
组合后的测试类将执行TestClassOne中的所有测试方法和TestClassTwo中的suite中定义的测试方法。
五、JUnit中测试类的环境设定和测试方法的环境设定
每个测试的运行都应该是互相独立的;从而就可以在任何时候,以任意的顺序运行每个单独的测试。
虽然这样是有好处的,但我们如果在每个测试方法里都写上相同的设置和销毁测试环境的代码,那显然是不可取的,比如取得数据库联接和关闭连接。好在JUnit的TestCase基类提供了两个方法供我们改写,分别用于环境的建立和清理:
protected void setUp();
protected void tearDown();
同样道理,在某些情况下,我们需要为整个test suite设置一些环境,以及在test suite中的所有方法都执行完成后做一些清理工作。要达到这种效果,我们需要针对suite做一个setUp和tearDown,这可能稍微复杂一点,它需要提供所需的一个suite(无论通过什么样的方法)并且把它包装进一个TestSetup对象
看下面这个例子:
public class TestDB extends TestCase{
private Connection dbConn;
private String dbName;
private String dbPort;
private String dbUser;
private String dbPwd;
public TestDB(String method){
super(method);
}
//Runs before each test method
protected void setUp(){
dbConn=new Connection(dbName,dbPort,dbUser,dbPwd);
dbConn.connect();
}
//Runs after each test method
protected void tearDown(){
dbConn.disConnect();
dbConn=null;
}
public void testAccountAccess(){
//Uses dbConn
....
}
public void testEmployeeAccess(){
//Uses dbConn
....
}
public static Test suite(){
TestSuite suite=new TestSuite();
suite.addTest(new TestDB("testAccountAccess"));
suite.addTest(new TestDB("testEmployeeAccess"));
TestSetup wrapper=new TestSetup(suite){
protected void setUp(){
oneTimeSetUp();
}
protected void tearDown(){
oneTimeTearDown();
}
}
return wrapper;
}
//Runs at start of suite
public static void oneTimeSetUp(){
//load properties of initialization
//one-time initialize the dbName,dbPort...
}
//Runs at end of suite
public static void oneTimeTearDown(){
//one-time cleanup code goes here...
}
}
上面这段代码的执行顺序是这样的:
1、oneTimeSetUp()
2、 setUp();
3、 testAccountAccess();
4、 tearDown();
5、 setUp();
6、 testEmployeeAccess();
7、 tearDown();
8、oneTimeTearDown();
六、自定义JUnit断言
通常而言,JUnit所提供的标准断言对大多数测试已经足够了。然而,在某些环境下,我们可能更需要自定义一些断言来满足我们的需要。
通常的做法是定义一个TestCase的子类,并且使用这个子类来满足所有的测试。新定义的共享的断言或者公共代码放到这个子类中。
七、测试代码的放置
三种放置方式:
1、同一目录——针对小型项目
假设有一个项目类,名字为
com.peiyuan.business.Account
相应的测试位于
com.peiyuan.business.TestAccount
即物理上存在于同一目录
优点是TestAccount能够访问Account的protected成员变量和函数
缺点是测试代码到处都是,且堆积在产品代码的目录中
2、子目录
这个方案是在产品代码的目录之下创建一个test子目录
同上,假设有一个项目类,名字为
com.peiyuan.business.Account
相应的测试位于
com.peiyuan.business.test.TestAccount
优点是能把测试代码放远一点,但又不置于太远
缺点是测试代码在不同的包中,所以测试类无法访问产品代码中的protected成员,解决的办法是写一个产品代码的子类来暴露那些成员。然后在测试代码中使用子类。
举一个例子,假设要测试的类是这样的:
package com.peiyuan.business;
public class Pool{
protected Date lastCleaned;
....
}
为了测试中获得non-public数据,我们需要写一个子类来暴露它
package com.peiyuan.business.test;
import com.peiyuan.business.Pool;
public class PoolForTesting extends Pool{
public Date getLastCleaned(){
return lastCleaned;
}
....
}
3、并行树
把测试类和产品代码放在同一个包中,但位于不同的源代码树,注意两棵树的根都在编译器的CLASSPATH中。
假设有一个项目类,位于
prod/ com.peiyuan.business.Account
相应的测试位于
test/ com.peiyuan.business.TestAccount
很显然这种做法继承了前两种的优点而摒弃了缺点,并且test代码相当独立
八、Mock的使用
1、基础
截至目前,前面提到的都是针对基本的java代码的测试,但是倘若遇到这样的情况:某个方法依赖于其他一些难以操控的东西,诸如网络、数据库、甚至是servlet引擎,那么在这种测试代码依赖于系统的其他部分,甚至依赖的部分还要再依赖其他环节的情况下,我们最终可能会发现自己几乎初始化了系统的每个组件,而这只是为了给某一个测试创造足够的运行环境让他可以运行起来。这样不仅仅消耗了时间,还给测试过程引入了大量的耦合因素。
他的实质是一种替身的概念。
举一个例子来看一下:假设我们有一个项目接口和一个实现类。如下:
public interface Environmental{
public long getTime();
}
public class SystemEnvironment implements Environmental{
public long getTime(){
return System.currentTimeMillis();
}
}
再有一个业务类,其中有一个依赖于getTime的新方法
public class Checker{
Environmental env;
public Checker(Environmental anEnv){
env=anEnv;
}
public void reminder(){
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(env.getTime());
int hour =cal.get(Calendar.HOUR_OF_DAY);
if(hour>=17){
......
}
}
}
由上可见,reminder方法依赖于getTime为他提供时间,程序逻辑实在下午5点之后进行提醒动作,但我们做测试的时候不可能等到那个时候,所以就要写一个假的Environmental来提供getTime方法,如下:
public class MockSystemEnvironment implements Environmental{
private long currentTime;
public long getTime(){
return currentTime;
}
public void setTime(long aTime){
currentTime= aTime;
}
}
写测试的时候以这个类来替代SystemEnvironment就实现了替身的作用。
2、MockObject
接下来再看如何测试servlet,同样我们需要一个web服务器和一个servlet容器环境的替身,按照上面的逻辑,我们需要实现HttpServletRequest和HttpServletResponse两个接口。不幸的是一看接口,我们有一大堆的方法要实现,呵呵,好在有人已经帮我们完成了这个工作,这就是mockobjects对象。
import junit.framework.*;
import com.mockobjects.servlet.*;
public class TestTempServlet extends TestCase {
public void test_bad_parameter() throws Exception {
TemperatureServlet s = new TemperatureServlet();
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
//在请求对象中设置参数
request.setupAddParameter( "Fahrenheit", "boo!");
//设置response的content type
response.setExpectedContentType( "text/html");
s.doGet(request,response);
//验证是否响应
response.verify();
assertEquals("Invalid temperature: boo!\ n",
response.getOutputStreamContents());
}
public void test_boil() throws Exception {
TemperatureServlet s = new TemperatureServlet();
MockHttpServletRequest request =
new MockHttpServletRequest();
MockHttpServletResponse response =
new MockHttpServletResponse();
request.setupAddParameter( "Fahrenheit", "212");
response.setExpectedContentType( "text/html");
s.doGet(request,response);
response.verify();
assertEquals("Fahrenheit: 212, Celsius: 100.0\ n",
response.getOutputStreamContents());
}
}
3、EasyMock
EasyMock采用“记录-----回放”的工作模式,基本使用步骤:
* 创建Mock对象的控制对象Control。
* 从控制对象中获取所需要的Mock对象。
* 记录测试方法中所使用到的方法和返回值。
* 设置Control对象到“回放”模式。
* 进行测试。
* 在测试完毕后,确认Mock对象已经执行了刚才定义的所有操作
项目类:
package com.peiyuan.business;
import java.io.IOException;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* <p>Title: 登陆处理</p>
* <p>Description: 业务类</p>
* <p>Copyright: Copyright (c) 2006</p>
* <p>Company: </p>
* @author Peiyuan
* @version 1.0
*/
public class LoginServlet extends HttpServlet {
/* (非 Javadoc)
* @see javax.servlet.http.HttpServlet#doPost(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)
*/
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String username = request.getParameter("username");
String password = request.getParameter("password");
// check username & password:
if("admin".equals(username) && "123456".equals(password)) {
ServletContext context = getServletContext();
RequestDispatcher dispatcher = context.getNamedDispatcher("dispatcher");
dispatcher.forward(request, response);
}
else {
throw new RuntimeException("Login failed.");
}
}
}
测试类:
package com.peiyuan.business;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import org.easymock.MockControl;
import junit.framework.TestCase;
/**
* <p>Title:LoginServlet测试类 </p>
* <p>Description: 基于easymock1.2</p>
* <p>Copyright: Copyright (c) 2006</p>
* <p>Company: </p>
* @author Peiyuan
* @version 1.0
*/
public class LoginServletTest extends TestCase {
/**
* 测试登陆失败的情况
* @throws Exception
*/
public void testLoginFailed() throws Exception {
//首先创建一个MockControl
MockControl mc = MockControl.createControl(HttpServletRequest.class);
//从控制对象中获取所需要的Mock对象
HttpServletRequest request = (HttpServletRequest)mc.getMock();
//“录制”Mock对象的预期行为
//在LoginServlet中,先后调用了request.getParameter("username")和request.getParameter("password")两个方法,
//因此,需要在MockControl中设置这两次调用后的指定返回值。
request.getParameter("username"); // 期望下面的测试将调用此方法,参数为"username"
mc.setReturnValue("admin", 1); // 期望返回值为"admin",仅调用1次
request.getParameter("password"); // 期望下面的测试将调用此方法,参数为" password"
mc.setReturnValue("1234", 1); // 期望返回值为"1234",仅调用1次
//调用mc.replay(),表示Mock对象“录制”完毕
mc.replay();
//开始测试
LoginServlet servlet = new LoginServlet();
try {
//由于本次测试的目的是检查当用户名和口令验证失败后,LoginServlet是否会抛出RuntimeException,
//因此,response对象对测试没有影响,我们不需要模拟它,仅仅传入null即可。
servlet.doPost(request, null);
fail("Not caught exception!");
}
catch(RuntimeException re) {
assertEquals("Login failed.", re.getMessage());
}
// verify:
mc.verify();
}
/**
* 测试登陆成功的情况
* @throws Exception
*/
public void testLoginOK() throws Exception {
//首先创建一个request的MockControl
MockControl requestCtrl = MockControl.createControl(HttpServletRequest.class);
//从控制对象中获取所需要的request的Mock对象
HttpServletRequest requestObj = (HttpServletRequest)requestCtrl.getMock();
//创建一个ServletContext的MockControl
MockControl contextCtrl = MockControl.createControl(ServletContext.class);
//从控制对象中获取所需要的ServletContext的Mock对象
final ServletContext contextObj = (ServletContext)contextCtrl.getMock();
//创建一个RequestDispatcher的MockControl
MockControl dispatcherCtrl = MockControl.createControl(RequestDispatcher.class);
//从控制对象中获取所需要的RequestDispatcher的Mock对象
RequestDispatcher dispatcherObj = (RequestDispatcher)dispatcherCtrl.getMock();
requestObj.getParameter("username"); // 期望下面的测试将调用此方法,参数为"username"
requestCtrl.setReturnValue("admin", 1); // 期望返回值为"admin",仅调用1次
requestObj.getParameter("password"); // 期望下面的测试将调用此方法,参数为" password"
requestCtrl.setReturnValue("123456", 1); // 期望返回值为"1234",仅调用1次
contextObj.getNamedDispatcher("dispatcher");
contextCtrl.setReturnValue(dispatcherObj, 1);
dispatcherObj.forward(requestObj, null);
dispatcherCtrl.setVoidCallable(1);
requestCtrl.replay();
contextCtrl.replay();
dispatcherCtrl.replay();
//为了让getServletContext()方法返回我们创建的ServletContext Mock对象,
//我们定义一个匿名类并覆写getServletContext()方法
LoginServlet servlet = new LoginServlet() {
public ServletContext getServletContext() {
return contextObj;
}
};
servlet.doPost(requestObj, null);
}
}