如果你听说过“测试驱动开发”(TDD:Test-Driven Development),单元测试就不陌生。
单元测试是用来对一个模块、一个函数或者一个类来进行正确性检验的测试工作。
比如对函数abs()
,我们可以编写出以下几个测试用例:
-
输入正数,比如
1
、1.2
、0.99
,期待返回值与输入相同; -
输入负数,比如
-1
、-1.2
、-0.99
,期待返回值与输入相反; -
输入
0
,期待返回0
; -
输入非数值类型,比如
None
、[]
、{}
,期待抛出TypeError
。
把上面的测试用例放到一个测试模块里,就是一个完整的单元测试。
如果单元测试通过,说明我们测试的这个函数能够正常工作。如果单元测试不通过,要么函数有bug,要么测试条件输入不正确,总之,需要修复使单元测试能够通过。
单元测试通过后有什么意义呢?如果我们对abs()
函数代码做了修改,只需要再跑一遍单元测试,如果通过,说明我们的修改不会对abs()
函数原有的行为造成影响,如果测试不通过,说明我们的修改与原有行为不一致,要么修改代码,要么修改测试。
这种以测试为驱动的开发模式最大的好处就是确保一个程序模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度地保证该模块行为仍然是正确的。
单元测试是编写测试代码,用来检测特定的、明确的、细颗粒的功能。单元测试并不一定保证程序功能是正确的,更不保证整体业务是准备的。
单元测试不仅仅用来保证当前代码的正确性,更重要的是用来保证代码修复、改进或重构之后的正确性。
一般来说,单元测试任务包括
- 接口功能测试:用来保证接口功能的正确性。
- 局部数据结构测试(不常用):用来保证接口中的数据结构是正确的
- 比如变量有无初始值
- 变量是否溢出
- 边界条件测试
- 变量没有赋值(即为NULL)
- 变量是数值(或字符)
- 主要边界:最小值,最大值,无穷大(对于DOUBLE等)
- 溢出边界(期望异常或拒绝服务):最小值-1,最大值+1
- 临近边界:最小值+1,最大值-1
- 变量是字符串
- 引用“字符变量”的边界
- 空字符串
- 对字符串长度应用“数值变量”的边界
- 变量是集合
- 空集合
- 对集合的大小应用“数值变量”的边界
- 调整次序:升序、降序
- 变量有规律
- 比如对于Math.sqrt,给出n^2-1,和n^2+1的边界
- 所有独立执行通路测试:保证每一条代码,每个分支都经过测试
- 代码覆盖率
- 语句覆盖:保证每一个语句都执行到了
- 判定覆盖(分支覆盖):保证每一个分支都执行到
- 条件覆盖:保证每一个条件都覆盖到true和false(即if、while中的条件语句)
- 路径覆盖:保证每一个路径都覆盖到
- 相关软件
- Cobertura:语句覆盖
- Emma: Eclipse插件Eclemma
- 代码覆盖率
- 各条错误处理通路测试:保证每一个异常都经过测试
JUNIT
JUnit是Java单元测试框架,已经在Eclipse中默认安装。目前主流的有JUnit3和JUnit4。JUnit3中,测试用例需要继承TestCase
类。JUnit4中,测试用例无需继承TestCase
类,只需要使用@Test
等注解。
// 测试java.lang.Math // 必须继承TestCase public class Junit3TestCase extends TestCase { public Junit3TestCase() { super(); } // 传入测试用例名称 public Junit3TestCase(String name) { super(name); } // 在每个Test运行之前运行 @Override protected void setUp() throws Exception { System.out.println("Set up"); } // 测试方法。 // 方法名称必须以test开头,没有参数,无返回值,是公开的,可以抛出异常 // 也即类似public void testXXX() throws Exception {} public void testMathPow() { System.out.println("Test Math.pow"); Assert.assertEquals(4.0, Math.pow(2.0, 2.0)); } public void testMathMin() { System.out.println("Test Math.min"); Assert.assertEquals(2.0, Math.min(2.0, 4.0)); } // 在每个Test运行之后运行 @Override protected void tearDown() throws Exception { System.out.println("Tear down"); } }
JUnit4
与JUnit3不同,JUnit4通过注解的方式来识别测试方法。目前支持的主要注解有:
@BeforeClass
全局只会执行一次,而且是第一个运行@Before
在测试方法运行之前运行@Test
测试方法@After
在测试方法运行之后允许@AfterClass
全局只会执行一次,而且是最后一个运行@Ignore
忽略此方法
import org.junit.After; import org.junit.AfterClass; import org.junit.Assert; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Ignore; import org.junit.Test; public class Junit4TestCase { @BeforeClass public static void setUpBeforeClass() { System.out.println("Set up before class"); } @Before public void setUp() throws Exception { System.out.println("Set up"); } @Test public void testMathPow() { System.out.println("Test Math.pow"); Assert.assertEquals(4.0, Math.pow(2.0, 2.0), 0.0); } @Test public void testMathMin() { System.out.println("Test Math.min"); Assert.assertEquals(2.0, Math.min(2.0, 4.0), 0.0); } // 期望此方法抛出NullPointerException异常 @Test(expected = NullPointerException.class) public void testException() { System.out.println("Test exception"); Object obj = null; obj.toString(); } // 忽略此测试方法 @Ignore @Test public void testMathMax() { Assert.fail("没有实现"); } // 使用“假设”来忽略测试方法 @Test public void testAssume(){ System.out.println("Test assume"); // 当假设失败时,则会停止运行,但这并不会意味测试方法失败。 Assume.assumeTrue(false); Assert.fail("没有实现"); } @After public void tearDown() throws Exception { System.out.println("Tear down"); } @AfterClass public static void tearDownAfterClass() { System.out.println("Tear down After class"); } }
EasyMock
IBM上有几篇介绍EasyMock使用方法和原理的文章:EasyMock 使用方法与原理剖析,使用 EasyMock 更轻松地进行测试。
EasyMock把测试过程分为三步:录制、运行测试代码、验证期望。
录制过程大概就是:期望method(params)执行times次(默认一次),返回result(可选),抛出exception异常(可选)。
验证期望过程将会检查方法的调用次数。
一个简单的样例是:
- @Test
- public void testListInEasyMock() {
- List list = EasyMock.createMock(List.class);
- // 录制过程
- // 期望方法list.set(0,1)执行2次,返回null,不抛出异常
- expect1: EasyMock.expect(list.set(0, 1)).andReturn(null).times(2);
- // 期望方法list.set(0,1)执行1次,返回null,不抛出异常
- expect2: EasyMock.expect(list.set(0, 1)).andReturn(1);
- // 执行测试代码
- EasyMock.replay(list);
- // 执行list.set(0,1),匹配expect1期望,会返回null
- Assert.assertNull(list.set(0, 1));
- // 执行list.set(0,1),匹配expect1(因为expect1期望执行此方法2次),会返回null
- Assert.assertNull(list.set(0, 1));
- // 执行list.set(0,1),匹配expect2,会返回1
- Assert.assertEquals(1, list.set(0, 1));
- // 验证期望
- EasyMock.verify(list);
- }
EasyMock还支持严格的检查,要求执行的方法次序与期望的完全一致。
Mockito
Mockito是Google Code上的一个开源项目,Api相对于EasyMock更好友好。与EasyMock不同的是,Mockito没有录制过程,只需要在“运行测试代码”之前对接口进行Stub,也即设置方法的返回值或抛出的异常,然后直接运行测试代码,运行期间调用Mock的方法,会返回预先设置的返回值或抛出异常,最后再对测试代码进行验证。可以查看此文章了解两者的不同。
官方提供了很多样例,基本上包括了所有功能,可以去看看。
这里从官方样例中摘录几个典型的:
- 验证调用行为
- import static org.mockito.Mockito.*;
- //创建Mock
- List mockedList = mock(List.class);
- //使用Mock对象
- mockedList.add("one");
- mockedList.clear();
- //验证行为
- verify(mockedList).add("one");
- verify(mockedList).clear();
- 对Mock对象进行Stub
- //也可以Mock具体的类,而不仅仅是接口
- LinkedList mockedList = mock(LinkedList.class);
- //Stub
- when(mockedList.get(0)).thenReturn("first"); // 设置返回值
- when(mockedList.get(1)).thenThrow(new RuntimeException()); // 抛出异常
- //第一个会打印 "first"
- System.out.println(mockedList.get(0));
- //接下来会抛出runtime异常
- System.out.println(mockedList.get(1));
- //接下来会打印"null",这是因为没有stub get(999)
- System.out.println(mockedList.get(999));
- // 可以选择性地验证行为,比如只关心是否调用过get(0),而不关心是否调用过get(1)
- verify(mockedList).get(0);
代码覆盖率
比较流行的工具是Emma和Jacoco,Ecliplse插件有eclemma。eclemma2.0之前采用的是Emma,之后采用的是Jacoco。这里主要介绍一下Jacoco。Eclmama由于是Eclipse插件,所以非常易用,就不多做介绍了。
Jacoco
Jacoco可以嵌入到Ant、Maven中,也可以使用Java Agent技术监控任意Java程序,也可以使用Java Api来定制功能。
Jacoco会监控JVM中的调用,生成监控结果(默认保存在jacoco.exec文件中),然后分析此结果,配合源代码生成覆盖率报告。需要注意的是:监控和分析这两步,必须使用相同的Class文件,否则由于Class不同,而无法定位到具体的方法,导致覆盖率均为0%。
Java Agent嵌入
首先,需要下载jacocoagent.jar文件,然后在Java程序启动参数后面加上 -javaagent:[yourpath/]jacocoagent.jar=[option1]=[value1],[option2]=[value2]
,具体的options可以在此页面找到。默认会在JVM关闭时(注意不能是kill -9
),输出监控结果到jacoco.exec文件中,也可以通过socket来实时地输出监控报告(可以在Example代码中找到简单实现)。
Java Report
可以使用Ant、Mvn或Eclipse来分析jacoco.exec文件,也可以通过API来分析。
- public void createReport() throws Exception {
- // 读取监控结果
- final FileInputStream fis = new FileInputStream(new File("jacoco.exec"));
- final ExecutionDataReader executionDataReader = new ExecutionDataReader(fis);
- // 执行数据信息
- ExecutionDataStore executionDataStore = new ExecutionDataStore();
- // 会话信息
- SessionInfoStore sessionInfoStore = new SessionInfoStore();
- executionDataReader.setExecutionDataVisitor(executionDataStore);
- executionDataReader.setSessionInfoVisitor(sessionInfoStore);
- while (executionDataReader.read()) {
- }
- fis.close();
- // 分析结构
- final CoverageBuilder coverageBuilder = new CoverageBuilder();
- final Analyzer analyzer = new Analyzer(executionDataStore, coverageBuilder);
- // 传入监控时的Class文件目录,注意必须与监控时的一样
- File classesDirectory = new File("classes");
- analyzer.analyzeAll(classesDirectory);
- IBundleCoverage bundleCoverage = coverageBuilder.getBundle("Title");
- // 输出报告
- File reportDirectory = new File("report"); // 报告所在的目录
- final HTMLFormatter htmlFormatter = new HTMLFormatter(); // HTML格式
- final IReportVisitor visitor = htmlFormatter.createVisitor(new FileMultiReportOutput(reportDirectory));
- // 必须先调用visitInfo
- visitor.visitInfo(sessionInfoStore.getInfos(), executionDataStore.getContents());
- File sourceDirectory = new File("src"); // 源代码目录
- // 遍历所有的源代码
- // 如果不执行此过程,则在报告中只能看到方法名,但是无法查看具体的覆盖(因为没有源代码页面)
- visitor.visitBundle(bundleCoverage, new DirectorySourceFileLocator(sourceDirectory, "utf-8", 4));
- // 执行完毕
- visitor.visitEnd();
- }
小结
单元测试可以有效地测试某个程序模块的行为,是未来重构代码的信心保证。
单元测试的测试用例要覆盖常用的输入组合、边界条件和异常。
单元测试代码要非常简单,如果测试代码太复杂,那么测试代码本身就可能有bug。
单元测试通过了并不意味着程序就没有bug了,但是不通过程序肯定有bug。