使用MRUnit,Mockito和PowerMock进行Hadoop MapReduce作业的单元测试

引言

Hadoop MapReduce作业有着独一无二的代码架构,这种代码架构拥有特定的模板和结构。这样的架构会给测试驱动开发和单元测试带来一些麻烦。这篇文章是运用MRUnit,Mockito和PowerMock的真实范例。我会介绍

  1. 使用MRUnit来编写Hadoop MapReduce应用程序的JUnit测试
  2. 使用PowerMock和Mockito模拟静态方法
  3. 模拟其他类型中的业务逻辑(译注:也就是编写测试驱动模块)
  4. 查看模拟的业务逻辑是否被调用(译注:测试驱动模块是否运行正常)
  5. 计数器
  6. 测试用例与log4j的集成
  7. 异常处理

本文的前提是读者应该已经熟悉JUnit 4的使用。

使用MRUnit可以把测试桩输入到mapper和/或reducer中,然后在JUnit环境中判断是否通过测试。这个过程和任何JUnit测试 一样,你可以调试你的代码。MRUnit中的MapReduce Driver可以测试一组Map/Reduce或者Combiner。 PipelineMapReduceDriver可以测试Map/Reduce作业工作流。目前,MRUnit还没有Partitioner对应的驱动。 MRUnit使开发人员在面对Hadoop特殊的架构的时候也能进行TDD和轻量级的单元测试。

实例

下面的例子中,我们会处理一些用来构建地图的路面数据。输入的数据包括线性表面(表示道路)和交叉点(表示十字路口)。Mapper会处理每条路面 数据并把它们写入HDFS文件系统,并舍弃诸如十字路口之类的非线性路面数据。我们还会统计并打印所有输入的非路面数据的数量。为了调试方便,我们也会额 外打印路面数据的数量。

public class MergeAndSplineMapper extends Mapper<LongWritable, BytesWritable, LongWritable, BytesWritable> {
	 private static Logger LOG = Logger.getLogger(MergeAndSplineMapper.class);
	 enum SurfaceCounters {
	         ROADS, NONLINEARS, UNKNOWN
	 }     
	 @Override
	 public void map(LongWritable key, BytesWritable value, Context context) throws IOException, InterruptedException {
	          // A list of mixed surface types
	          LinkSurfaceMap lsm = (LinkSurfaceMap) BytesConverter.bytesToObject(value.getBytes());	        
	          List<RoadSurface> mixedSurfaces = lsm.toSurfaceList();        
	          for (RoadSurface surface : mixedSurfaces)  {
	                   Long surfaceId = surface.getNumericId();
	                   Enums.SurfaceType surfaceType = surface.getSurfaceType();	            
	                   if ( surfaceType.equals(SurfaceType.INTERSECTION)  )  {
	                             // Ignore non-linear surfaces.
	                             context.getCounter(SurfaceCounters.NONLINEARS).increment(1);
	                             continue;
	                   }
	                   else if ( ! surfaceType.equals(SurfaceType.ROAD) ) {
	                            // Ignore anything that wasn’t an INTERSECTION or ROAD, ie any future additions.
	                            context.getCounter(SurfaceCounters.UNKNOWN).increment(1);
	                            continue;
	                   }

	                   PopulatorPreprocessor.processLinearSurface(surface);

	                   // Write out the processed linear surface.
	                   lsm.setSurface(surface);
	                   context.write(new LongWritable(surfaceId), new BytesWritable(BytesConverter.objectToBytes(lsm)));
	                   if (LOG.isDebugEnabled()) {
	                             context.getCounter(SurfaceCounters.ROADS).increment(1);
	                   }
	          }
	 }
}

下面是单元测试代码,这段代码中用到了MRUnit,Mockito和PowerMock。

@RunWith(PowerMockRunner.class)
@PrepareForTest(PopulatorPreprocessor.class)
public class MergeAndSplineMapperTest {

	 private MapDriver<LongWritable, BytesWritable, LongWritable, BytesWritable> mapDriver;
	 @Before
         public void setUp() {
	          MergeAndSplineMapper mapper = new MergeAndSplineMapper();
	          mapDriver = new MapDriver<LongWritable, BytesWritable, LongWritable, BytesWritable>();
	          mapDriver.setMapper(mapper);
	 }

	 @Test
	 public void testMap_INTERSECTION() throws IOException {
	          LinkSurfaceMap lsm = new LinkSurfaceMap();
	          RoadSurface rs = new RoadSurface(Enums.RoadType.INTERSECTION);
	          byte[] lsmBytes = append(lsm, rs);	      
	          PowerMockito.mockStatic(PopulatorPreprocessor.class);	        
	          mapDriver.withInput(new LongWritable(1234567), new BytesWritable(lsmBytes));
	          mapDriver.runTest();

	          Assert.assertEquals("ROADS count incorrect.", 0,
	                                    mapDriver.getCounters().findCounter(SurfaceCounters.ROADS).getValue());
	          Assert.assertEquals("NONLINEARS count incorrect.", 1,
	                                    mapDriver.getCounters().findCounter(SurfaceCounters.NONLINEARS).getValue());
	          Assert.assertEquals("UNKNOWN count incorrect.", 0,
	                                    mapDriver.getCounters().findCounter(SurfaceCounters.UNKNOWN).getValue());

	          PowerMockito.verifyStatic(Mockito.never());
	          PopulatorPreprocessor.processLinearSurface(rs);
	 }		    

	 @Test
	 public void testMap_ROAD() throws IOException {
	          LinkSurfaceMap lsm = new LinkSurfaceMap();
	          RoadSurface rs = new RoadSurface(Enums.RoadType.ROAD);
	          byte[] lsmBytes = append(lsm, rs);

                  // save logging level since we are modifying it.
                  Level originalLevel = Logger.getRootLogger().getLevel();
     	          Logger.getRootLogger().setLevel(Level.DEBUG);
	          PowerMockito.mockStatic(PopulatorPreprocessor.class);

	          mapDriver.withInput(new LongWritable(1234567), new BytesWritable(lsmBytes));
	          mapDriver.withOutput(new LongWritable(1000000), new BytesWritable(lsmBytes));
	          mapDriver.runTest();

	          Assert.assertEquals("ROADS count incorrect.", 1,
	                                    mapDriver.getCounters().findCounter(SurfaceCounters.ROADS).getValue());
	          Assert.assertEquals("NONLINEARS count incorrect.", 0,
	                                    mapDriver.getCounters().findCounter(SurfaceCounters.NONLINEARS).getValue());
	          Assert.assertEquals("UNKNOWN count incorrect.", 0,
	                                    mapDriver.getCounters().findCounter(SurfaceCounters.UNKNOWN).getValue());

	          PowerMockito.verifyStatic(Mockito.times(1));
	          PopulatorPreprocessor.processLinearSurface(rs);
                  // set logging level back to it's original state so as not to affect other tests
                  Logger.getRootLogger().setLevel(originalLevel);
	}
}

详解

上面的代码中,我们仅仅检测数据的ID和类型,舍弃非路面数据,进行计数,以及处理路面数据。让我们来看一下第一个测试用例。

testMap_INTERSECTION

这个测试用例的预期结果应该是

  1. SurfaceCounters.NONLINEARS 类型计数器应该自增。
  2. for循环应该可以正常工作,即使没有运行到循环体中的PopulatorPreprocessor.processLinearSurface(surface)方法。
  3. 另外两种计数器SurfaceCounters.ROADS和SurfaceCounters.UNKNOWN 不会自增。

这是一个mapper的测试,所以我们先初始化一个mapper的驱动。注意四个类型参数必须与测试目标的类型参数匹配。

private MapDriver<LongWritable, BytesWritable, LongWritable, BytesWritable> mapDriver;
         @Before 	
         public void setUp() { 	        
                  MergeAndSplineMapper mapper = new MergeAndSplineMapper();
                  mapDriver = new MapDriver<LongWritable, BytesWritable, LongWritable,  BytesWritable>(); 	         
                  mapDriver.setMapper(mapper);         
        }

在定义单元测试用例方法的时候使用IOException

Mapper可能会抛出IOException。在JUnit中,开发人员可以通过catch或throw来处理测试目标代码抛出的异常。注意,这 里我们并不是专门测试异常情况,所以,我不建议让测试用例方法去捕捉(catch)测试目标代码的异常,而是让测试目标抛出(throw)它们。如果测试 目标发生了异常,测试会失败,而这恰恰是我们想要的结果。如果你并非专门测试异常情况,但是却捕捉了测试目标代码的异常,这往往会造成不必要的麻烦。你大 可以抛出这些异常并让测试用例失败。

@Test
public void testMap_INTERSECTION() throws IOException {

然后初始化测试桩。为了测试if-else块,我们要提供路面类型为RoadType.INTERSECTION的数据。

LinkSurfaceMap lsm = new LinkSurfaceMap();
RoadSurface rs = new RoadSurface(Enums.RoadType.INTERSECTION);
byte[] lsmBytes = append(lsm, rs);

我们用PowerMock来模拟调用类型PopulatorPreprocessor的静态方法。PopulatorPreprocessor是一 个拥有业务逻辑的独立的类型。在类级别上,我们用 @RunWith来初始化PowerMock。通过 @PrepareForTest,我们告诉PowerMock去模拟哪个有静态方法的类型。PowerMock支持EasyMock和Mockito。这 里我们使用Mockito,所以我们使用了相关类型PowerMockito。我们通过调用PowerMockito.mockStatic来模拟调用静 态方法。

@RunWith(PowerMockRunner.class)
@PrepareForTest(PopulatorPreprocessor.class)

PowerMockito.mockStatic(PopulatorPreprocessor.class);

输入之前创建的测试桩并且运行mapper。

mapDriver.withInput(new LongWritable(1234567), new BytesWritable(lsmBytes));
	          mapDriver.runTest();

最后,查看结果。SurfaceCounters.NONLINEARS 类型的计数器自增了一次,而SurfaceCounters.ROADS 类型的计数器和SurfaceCounters.UNKNOWN类 型的计数器没有自增。我们可以用JUnit的assetEquals方法来检测结果。这个方法的第一个参数是一个String类型的可选参数,用来表示断 言的错误提示。第二个参数是断言的预期结果,第三个参数是断言的实际结果。assetEquals方法可以输出非常友好的错误提示,它的格式是 “expected: <x> but was: <y>.”。比如说,下面第二个断言没有通过的话,我们就可以得到一个错误语句“java.lang.AssertionError: NONLINEARS count incorrect. expected:<1> but was:<0>. “。

Assert.assertEquals("ROADS count incorrect.", 0,
	mapDriver.getCounters().findCounter(SurfaceCounters.ROADS).getValue());
Assert.assertEquals("NONLINEARS count incorrect.", 1,
	mapDriver.getCounters().findCounter(SurfaceCounters.NONLINEARS).getValue());
Assert.assertEquals("UNKNOWN count incorrect.", 0,
	mapDriver.getCounters().findCounter(SurfaceCounters.UNKNOWN).getValue());

用下面的语句可以检测PopulatorPreprocessor.processLinearSurface(surface)方法没有被调用过。

PowerMockito.verifyStatic(Mockito.never());
PopulatorPreprocessor.processLinearSurface(rs);

testMap_ROAD

这个测试用例的预期结果应该是

  1. SurfaceCounters. ROADS 类型计数器应该自增。
  2. PopulatorPreprocessor.processLinearSurface(surface)方法被调用了。
  3. 另外两种计数器SurfaceCounters. NONLINEARS 和SurfaceCounters.UNKNOWN 不会自增。

测试驱动模块的初始化与第一个用例相似,但有几点不同。

  1. 初始化一个路面类型的测试桩。
    RoadSurface rs = new RoadSurface(Enums.RoadType.ROAD);
  2. 设置log4j的debug级别。在测试目标代码中,只有log4j设置成了debug级别,我们才会打印路面数据。为了测试这个功能点,我们先记录当前的logging级别,然后我们把根logger对象的logging级别设置成debug。
    Level originalLevel = Logger.getRootLogger().getLevel();
                       Logger.getRootLogger().setLevel(Level.DEBUG)

最后,我们把logging级别重新设置成原来的级别,这样就不会影响其他测试了。

Logger.getRootLogger().setLevel(originalLevel);

我们看一下测试的结果。SurfaceCounters. ROADS 类型的计数器是自增的。另外两个类型的计数器SurfaceCounters. NONLINEARS和SurfaceCounters.UNKNOWN都不会自增。

 Assert.assertEquals("ROADS count incorrect.", 1,
         mapDriver.getCounters().findCounter(SurfaceCounters.ROADS).getValue());
 Assert.assertEquals("NONLINEARS count incorrect.", 0,
	 mapDriver.getCounters().findCounter(SurfaceCounters.NONLINEARS).getValue());
 Assert.assertEquals("UNKNOWN count incorrect.", 0,
	 mapDriver.getCounters().findCounter(SurfaceCounters.UNKNOWN).getValue());

使用下面的代码,可以检测出PopulatorPreprocessor.processLinearSurface(surface)被调用了一次。

PowerMockito.verifyStatic(Mockito.times(1));
PopulatorPreprocessor.processLinearSurface(rs);

测试Reducer

测试reducer和测试mapper的原理是相似的。区别在于我们需要创建一个ReducerDriver,然后把需要测试的reducer赋值给这个ReducerDriver。

private ReduceDriver<LongWritable, BytesWritable, LongWritable, BytesWritable>  reduceDriver;
     @Before
     public void setUp() {
	      MyReducer reducer = new MyReducer ();
	      reduceDriver = new ReduceDriver <LongWritable, BytesWritable,  LongWritable, BytesWritable>();
	      reduceDriver.setReducer(reducer);
     }

配置MAVEN POM

如果使用JUnit 4,那么还要在Maven的POM.xml配置文件中添加下面的配置项。可以在PowerMock的官方网站上找到Mockito相关的版本信息。

<dependency>
	                  <groupId>org.apache.mrunit</groupId>
	                  <artifactId>mrunit</artifactId>
	                  <version>0.8.0-incubating</version>
	                  <scope>test</scope>
	            </dependency>
	            <dependency>
	                  <groupId>org.mockito</groupId>
	                  <artifactId>mockito-all</artifactId>
	                  <version>1.9.0-rc1</version>
	                  <scope>test</scope>
	            </dependency>
	            <dependency>
	                  <groupId>org.powermock</groupId>
	                  <artifactId>powermock-module-junit4</artifactId>
	                  <version>1.4.12</version>
	                  <scope>test</scope>
	            </dependency>
	            <dependency>
	                  <groupId>org.powermock</groupId>
	                  <artifactId>powermock-api-mockito</artifactId>
	                  <version>1.4.12</version>
	                  <scope>test</scope>
                    </dependency>

在Eclipse中运行

这个单元测试可以像其他JUnit测试一样运行。下面是在Eclipse中运行测试的示例。

结论

MRUnit是一种轻量但非常强大的测试驱动开发的工具。它可以帮助开发人员提高代码测试覆盖率。

感谢

我要感谢Boris Lublinsky帮助我完成了项目。还要感谢Miao Li为项目添加了许多MRUnit测试用例。

查看英文原文:Unit Testing Hadoop MapReduce Jobs With MRUnit, Mockito, & PowerMock


感谢杨赛对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。

This entry was posted in DB, UT. Bookmark the permalink.

发表评论

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / 更改 )

Twitter picture

You are commenting using your Twitter account. Log Out / 更改 )

Facebook photo

You are commenting using your Facebook account. Log Out / 更改 )

Google+ photo

You are commenting using your Google+ account. Log Out / 更改 )

Connecting to %s