Apache MRUnit 1.1 MapReduce 单元测试实战详解
基于 Ubuntu 22.04 实测的 MRUnit 1.1 实战详解,以 WordCount 为例覆盖三层驱动器测试、Combiner 幂等性验证、计数器断言与排障实录,输出逐字实录。
Apache MRUnit 1.1 MapReduce 单元测试实战详解
一、实战目标与工程准备
本文在 Ubuntu 22.04 服务器上,以 OpenJDK 8、Maven 3.6.3、mrunit 1.1.0(hadoop2 分类器)、Hadoop 2.10.2 客户端库为固定组合,把 MapReduce 单元测试的三层驱动器全部真实跑一遍:MapDriver 测映射、ReduceDriver 测归并、MapReduceDriver 测端到端,外加两个进阶场景——Combiner 幂等性验证与计数器断言。所有代码与输出都来自同一个 Maven 工程(com.example:mrunit-demo:1.0.0)的真实执行,文中的每一段测试输出都是逐字记录,包括五个踩坑实录(期望顺序、Pair 构造、计数器与输出断言的关系、runTest 消费语义、surefire 编码传参)。
七个用例的分工先亮出来,读者可以按图索骥:testMapper(单行映射、顺序敏感断言)、testMapperEmptyLine(空行零输出)、testReducer(值列表归并)、testMapReduce(三行端到端、键序断言)、testMapReduceOrderInsensitive(run() 取数、顺序无关比较)、testCombinerSameResult(combiner 幂等对照)、testCounterCheck(Enum 计数器断言)。前五个在 WordCountTest 中,后两个在 CombinerCounterTest 中,全部测试的最终状态是全绿。
工程依赖与集成步骤见本专栏的集成指南篇,这里只复述 pom 的关键片段(完整文件五分钟可复现):
<dependency>
<groupId>org.apache.mrunit</groupId>
<artifactId>mrunit</artifactId>
<version>1.1.0</version>
<classifier>hadoop2</classifier>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-mapreduce-client-core</artifactId>
<version>2.10.2</version>
</dependency>
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-common</artifactId>
<version>2.10.2</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
本文实战的全部测试最终汇成一次构建报告,先给出终态作为"预告":
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.example.WordCountTest
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.413 sec
Results :
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 7.750 s
下面逐层展开每个用例的写法、背后的语义与踩过的坑。
二、被测对象:完整 WordCount 实现
2.1 Mapper 全文
package com.example;
import java.io.IOException;
import java.util.StringTokenizer;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Mapper;
public class WordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
private final static IntWritable ONE = new IntWritable(1);
private Text word = new Text();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String line = value.toString();
StringTokenizer tokenizer = new StringTokenizer(line);
while (tokenizer.hasMoreTokens()) {
word.set(tokenizer.nextToken());
context.write(word, ONE);
}
}
}
2.2 Reducer 全文
package com.example;
import java.io.IOException;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Reducer;
public class WordCountReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
private IntWritable result = new IntWritable();
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Context context)
throws IOException, InterruptedException {
int sum = 0;
for (IntWritable value : values) {
sum += value.get();
}
result.set(sum);
context.write(key, result);
}
}
这两段代码刻意保持生产级写法:对象复用(ONE 常量、word 复用 set)、无状态(不存跨调用字段)、标准的 Writable 类型体系。它们也是最容易在测试中暴露问题的写法——对象复用正是手工 mock 测试最容易测错的地方,第三节的顺序失败与此直接相关。
选择 WordCount 作为全篇标本不是偷懒,而是它恰好覆盖了 MapReduce 测试的三类典型断言:映射类断言(一行拆多对,输出有序)、归并类断言(多值并一对,与值序无关)、管道类断言(中间键与分组粒度)。三类断言分别在 MapDriver、ReduceDriver、MapReduceDriver 上有唯一的最佳覆盖位置,换任何一个"更真实"的业务例子反而会让测试语义被业务细节淹没。读者把这里的词换成自己的字段解析、把求和换成自己的聚合,就是一套现成的测试骨架——这也是本文按"层"而不是按"业务场景"组织章节的原因。
三、第一层:MapDriver 单测
3.1 基本用例与期望输出的登记顺序
@Test
public void testMapper() throws Exception {
mapDriver = MapDriver.newMapDriver(new WordCountMapper());
mapDriver.withInput(new LongWritable(1), new Text("hello world hello mrunit"));
mapDriver.withOutput(new Text("hello"), new IntWritable(1));
mapDriver.withOutput(new Text("world"), new IntWritable(1));
mapDriver.withOutput(new Text("hello"), new IntWritable(1));
mapDriver.withOutput(new Text("mrunit"), new IntWritable(1));
mapDriver.runTest();
}
一行输入 "hello world hello mrunit" 经 map 拆成四条输出,顺序是 write 的发生顺序:hello、world、hello、mrunit。withOutput 按这个顺序登记,runTest 逐位比对。这里最容易犯的错是"按去重后的逻辑结果登记"(只登记 hello、world、mrunit 三条),或者登记顺序与 write 顺序不一致——两者都会失败,下一小节是真实的失败输出。
这个用例还藏着一个值得点破的类型细节:期望登记里的 new Text("hello") 与 mapper 内部复用的 word 对象是不同的实例,断言能通过靠的是 Text 正确实现了 equals 与 hashCode(内容相等即相等),而不是对象同一性。这正是 Writable 体系设计成熟的地方——如果你自定义了键类型,务必同样实现好 equals/hashCode/compareTo 三件套,否则集群端分组会错、单测端断言也会错,而且两边错得一样"合理",很难有人怀疑到类型头上。用 MRUnit 测自定义键类型时,第一组用例就应该是"相同内容、不同实例"的键能被正确归组。
3.2 排障实录一:期望顺序错误
把期望登记成 world 在前、hello 在后(模拟"以为框架会排序"的常见误解):
mapDriver.withInput(new LongWritable(1), new Text("hello world"));
mapDriver.withOutput(new Text("world"), new IntWritable(1));
mapDriver.withOutput(new Text("hello"), new IntWritable(1));
mapDriver.runTest();
真实失败输出(surefire 报告原文):
Tests run: 1, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.943 sec <<< FAILURE!
testWrongOrder(com.example.FailureDemoTest) Time elapsed: 0.839 sec <<< FAILURE!
java.lang.AssertionError: 2 Error(s): (Missing expected output (world, 1) at position 0, got (hello, 1)., Missing expected output (hello, 1) at position 1, got (world, 1).)
at org.junit.Assert.fail(Assert.java:89)
错误信息把每一位的差异都列出来了:第 0 位期望 world 实际 hello,第 1 位期望 hello 实际 world。这证实 MapDriver 的输出断言是顺序敏感的——它模拟的是框架对单条记录的调用,mapper write 什么顺序输出就是什么顺序,MapDriver 不排序。想要顺序无关断言请用 run() 自行比较(第五节)。同理,"MapDriver 会按 key 排序"是错的,那是 MapReduceDriver 模拟 shuffle 之后的行为。
3.3 空行边界用例
@Test
public void testMapperEmptyLine() throws Exception {
mapDriver.withInput(new LongWritable(1), new Text(""));
mapDriver.runTest();
}
空行不登记任何期望输出,runTest 断言"零输出"。这个用例验证两件事:StringTokenizer 对空串不产出 token(不会误写记录),map 方法对空输入不抛异常。真实数据里空行极常见,这个三行用例是性价比最高的回归。顺带一提,空行用例在 MapReduceDriver 层也值得再放一份:端到端喂入"一行正常、一行空行",断言最终输出只含正常行的词——它验证的不再是 map 的健壮性,而是"空行不会污染分组统计"这一管道级事实。
四、第二层:ReduceDriver 单测
@Test
public void testReducer() throws Exception {
reduceDriver = ReduceDriver.newReduceDriver(new WordCountReducer());
List<IntWritable> values = new ArrayList<IntWritable>();
values.add(new IntWritable(1));
values.add(new IntWritable(1));
values.add(new IntWritable(1));
reduceDriver.withInput(new Text("hello"), values);
reduceDriver.withOutput(new Text("hello"), new IntWritable(3));
reduceDriver.runTest();
}
ReduceDriver 的 withInput 一次喂入"键 + 值列表",对应真实框架里同一 key 在 shuffle 后形成的组。三个 1 归并成 3,断言一次通过。设计用例时值得多想一步的是值的顺序与 reduce 结果的关系:WordCount 的求和是交换的,所以列表顺序不影响结果;如果你的 reducer 里有"取第一个值""按序合并"这类顺序敏感逻辑,就必须在这里显式构造不同顺序的值列表分别断言——这是 ReduceDriver 层独有的测试维度,MapDriver 与 MapReduceDriver 都覆盖不到。
ReduceDriver 层还有两个值得覆盖的边界。其一是空值组:理论上框架不会给 reducer 传空列表,但防御式实现应该能容忍,喂入空 ArrayList 断言不抛异常即可把这份健壮性固化下来。其二是大数溢出:IntWritable 承载 32 位整数,高频词的计数可能触及边界,构造接近 Integer.MAX_VALUE 的期望值(如两个 1073741824 相加)能验证 reduce 里的累加变量类型是否留足了余量——如果实现用了 int 累加溢出为负,这一层立刻现形,比在集群上发现"某个超高频词的计数是负数"便宜得多。这些用例的代码量都只有几行,但它们守住的边界是集群排障最难够到的区域。
五、第三层:MapReduceDriver 端到端
5.1 shuffle 分组语义实测
@Test
public void testMapReduce() throws Exception {
driver = MapReduceDriver.newMapReduceDriver(new WordCountMapper(), new WordCountReducer());
driver.withInput(new LongWritable(1), new Text("the quick brown fox"))
.withInput(new LongWritable(2), new Text("the lazy dog"))
.withInput(new LongWritable(3), new Text("the fox runs"));
driver.withOutput(new Text("brown"), new IntWritable(1));
driver.withOutput(new Text("dog"), new IntWritable(1));
driver.withOutput(new Text("fox"), new IntWritable(2));
driver.withOutput(new Text("lazy"), new IntWritable(1));
driver.withOutput(new Text("quick"), new IntWritable(1));
driver.withOutput(new Text("runs"), new IntWritable(1));
driver.withOutput(new Text("the"), new IntWritable(3));
driver.runTest();
}
三行输入十二个词,MapReduceDriver 内部走完"逐条 map → 按键分组 → 逐组 reduce"的全链路:fox 出现两次归并成 2,the 出现三次归并成 3,其余各 1。期望输出按键的字典序登记——brown、dog、fox、lazy、quick、runs、the——因为 MapReduceDriver 的输出是 shuffle 排序后的键序(Text 的字典序),与 3.2 节 MapDriver 的"不排序"形成鲜明对照。这两个行为差是 MRUnit 测试里最容易被搞混的语义,实测一次就牢了。
MapReduceDriver 层真正的测试价值在于"中间键设计"的验证。WordCount 的中间键是词本身,简单到没有设计感;换成实际业务就会遇到选择:日志按"用户 ID"归组还是按"用户 ID + 日期"复合归组?归组键选错了,reduce 拿到的组就不是它假设的粒度,结果错得无声无息。MapReduceDriver 是唯一能把"map 产出的中间键"与"reduce 假设的组粒度"对上账的测试层——两层各自单测都通过、端到端仍失败时,问题几乎必然出在中间键。测试即规格:读这个用例的输入与期望,就能准确理解作业的数据流契约。
5.2 顺序无关断言:run() 加自定义比较
@Test
public void testMapReduceOrderInsensitive() throws Exception {
driver.withInput(new LongWritable(1), new Text("a b a"))
.withInput(new LongWritable(2), new Text("b c"));
List<org.apache.hadoop.mrunit.types.Pair<Text, IntWritable>> expected =
new ArrayList<org.apache.hadoop.mrunit.types.Pair<Text, IntWritable>>();
expected.add(new org.apache.hadoop.mrunit.types.Pair<Text, IntWritable>(new Text("a"), new IntWritable(2)));
expected.add(new org.apache.hadoop.mrunit.types.Pair<Text, IntWritable>(new Text("b"), new IntWritable(2)));
expected.add(new org.apache.hadoop.mrunit.types.Pair<Text, IntWritable>(new Text("c"), new IntWritable(1)));
List<org.apache.hadoop.mrunit.types.Pair<Text, IntWritable>> actual = driver.run();
org.junit.Assert.assertEquals(expected, actual);
}
driver.run() 返回 List<Pair<K2, V2>> 的实际输出,测试代码自己决定怎么比——排序后比、转 Set 比、只比键集都比期望输出登记灵活。Pair 实现了 equals 与 compareTo(实测 javap 输出可证),直接进集合比较没有障碍。注意 run() 一旦执行过,登记的输入就被消费掉了(第八节展开),所以这个用例没有先 runTest 再 run。
5.3 排障实录二:Pair 没有工厂方法
写这个用例时想当然地写了 Pair.newPair(...),编译直接失败。用 javap 反查 Pair 的真实 API:
javap -cp /root/.m2/repository/org/apache/mrunit/mrunit/1.1.0/mrunit-1.1.0-hadoop2.jar \
org.apache.hadoop.mrunit.types.Pair
public class org.apache.hadoop.mrunit.types.Pair<S, T> implements java.lang.Comparable<...> {
public org.apache.hadoop.mrunit.types.Pair(S, T);
public S getFirst();
public T getSecond();
public boolean equals(java.lang.Object);
public int hashCode();
public int compareTo(org.apache.hadoop.mrunit.types.Pair<S, T>);
public java.lang.String toString();
}
结论:1.1.0 的 Pair 只有二元构造函数 Pair(first, second),getFirst/getSecond 取值,旧博客里的 newPair 工厂要么是记忆偏差要么属于更老的版本。遇到"教程 API 编译不过"时,javap 直查构件里的真实签名是最快的排障路径,比反复试错省时间。这也是处理退役项目文档缺失时的通用技巧:javadoc 站点可能下线,但二进制构件里的类签名永远可查,编译器与 javap 就是最终版本的 API 文档。
六、Combiner 幂等性实测
Combiner 是框架在 map 端可选调用的"本地归并",调用次数不受控制,因此必须满足"调与不调、多调少调都不改变最终结果"。MRUnit 的 MapReduceDriver 支持把一个 Reducer 插成 combiner(withCombiner 或三参数工厂的第三个参数),让幂等性直接变成可断言的事实:
@Test
public void testCombinerSameResult() throws Exception {
MapReduceDriver<LongWritable, Text, Text, IntWritable, Text, IntWritable> driver =
MapReduceDriver.newMapReduceDriver(new WordCountMapper(),
new WordCountReducer(), new WordCountReducer());
driver.withInput(new LongWritable(1), new Text("a b a b a"))
.withInput(new LongWritable(2), new Text("b c"));
driver.withOutput(new Text("a"), new IntWritable(3));
driver.withOutput(new Text("b"), new IntWritable(3));
driver.withOutput(new Text("c"), new IntWritable(1));
driver.runTest();
}
三参数工厂 newMapReduceDriver(mapper, reducer, combiner) 把 WordCountReducer 同时用作 reducer 与 combiner。输入 "a b a b a" 与 "b c" 的最终期望是 a=3、b=3、c=1——如果 combiner 改变了结果(比如用"取第一个值"当 combiner,b 组会被砍成 1),这个用例立刻失败。实测该用例通过(Tests run: 2 中的第一个,与计数器用例同批执行)。把带 combiner 与不带 combiner 的两组用例放进同一个测试类,就是 WordCount 类聚合逻辑幂等性的最小回归集。
展开讲讲为什么 Combiner 的幂等性值得单独一组用例。Combiner 的调用时机由框架决定:内存缓冲区溢写前可能触发、每个分片结束时可能触发、也可能因为数据量太小一次都不触发。这意味着同一个 mapper 的中间结果在集群上可能经历 0 次、1 次到 N 次本地归并,每次都改变传给 shuffle 的数据形态。只有幂等的 combiner 才能保证"不同触发次数殊途同归"。MRUnit 的驱动器提供的是确定性执行——combiner 恰好被调用一次——所以单靠一个带 combiner 的用例并不能证明幂等,证明来自"带与不带两组用例断言同一结果"的对照结构。这是 MRUnit 测试设计里少数需要刻意构造对照的地方,也是最能体现测试功力的地方:测的不是"combiner 对不对",而是"结果对不对得不管 combiner 怎么调"。
七、计数器验证实测
MapReduce 作业常用计数器做业务统计(跳过多少脏行、命中多少规则),MRUnit 让计数器也进入断言范围。先写一个统计空行与非空行的 mapper:
public static class CountingMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
public enum LINE { EMPTY, NONEMPTY }
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
if (value.toString().isEmpty()) {
context.getCounter(LINE.EMPTY).increment(1);
} else {
StringTokenizer st = new StringTokenizer(value.toString());
while (st.hasMoreTokens()) {
context.write(new Text(st.nextToken()), new IntWritable(1));
}
context.getCounter(LINE.NONEMPTY).increment(1);
}
}
}
测试用例喂入一行空行、一行 "hello world",用 withCounter(Enum, long) 登记期望计数:
@Test
public void testCounterCheck() throws Exception {
MapDriver<LongWritable, Text, Text, IntWritable> mapDriver =
MapDriver.newMapDriver(new CountingMapper());
mapDriver.withInput(new LongWritable(0), new Text(""));
mapDriver.withInput(new LongWritable(1), new Text("hello world"));
mapDriver.withOutput(new Text("hello"), new IntWritable(1));
mapDriver.withOutput(new Text("world"), new IntWritable(1));
mapDriver.withCounter(CountingMapper.LINE.EMPTY, 1);
mapDriver.withCounter(CountingMapper.LINE.NONEMPTY, 1);
mapDriver.runTest();
}
runTest 会同时校验输出与计数器,实测通过(Tests run: 2 中的第二个)。这个能力让"统计类"逻辑第一次有了廉价的回归手段——在过去,计数器逻辑只能在集群跑完后从作业日志里人工核对。
7.1 排障实录三:Expected no output; got 2 output(s)
第一次写计数器用例时只登记了 withCounter、没有登记 withOutput,runTest 失败:
java.lang.AssertionError: 1 Error(s): (Expected no output; got 2 output(s).)
at org.junit.Assert.fail(Assert.java:89)
at org.apache.hadoop.mrunit.internal.util.Errors.assertNone(Errors.java:73)
at org.apache.hadoop.mrunit.TestDriver.validate(TestDriver.java:702)
at org.apache.hadoop.mrunit.TestDriver.runTest(TestDriver.java:641)
语义解读:驱动器把"没有登记期望输出"严格理解为"期望零输出",mapper 实际写出了两条记录就被判违规。计数器断言不能替代输出断言,两者是并行的校验维度。补上两条 withOutput 后测试转绿——这条例子说明 MRUnit 的校验是"全量精确匹配"而非"子集匹配",理解这一点能避免一批"为什么明明对还报错"的困惑。
八、runTest 与 run 的消费语义:一次登记只跑一轮
探针过程中实测出的另一个高频坑:同一个驱动器先 runTest() 再 run(),第二调直接抛异常:
java.lang.IllegalStateException: No input was provided
at org.apache.hadoop.mrunit.MapDriverBase.preRunChecks(MapDriverBase.java:286)
at org.apache.hadoop.mrunit.mapreduce.MapDriver.run(MapDriver.java:142)
原因:runTest 内部执行 run 并比对,执行过程消费掉登记的输入与期望;再次调用时输入列表已空,preRunChecks 检查不通过直接抛 IllegalStateException。正确用法是二选一:runTest(断言式,框架帮你比)或 run(取数式,自己比),一次登记只执行一轮。想对同一份数据既做顺序敏感断言又做取数分析,要么构造两个驱动器各自登记,要么在同一驱动器上重新 withInput 后再执行。这个坑与 5.2 节的写法互相印证:testMapReduceOrderInsensitive 之所以只用 run(),正是因为没有先跑 runTest。
从设计视角看,"消费式登记"是一个有意的取舍而不是缺陷:它强制每个测试用例显式声明自己的输入与期望,杜绝了"上一个用例的残留数据悄悄影响下一个用例"这类隐式耦合。JUnit 世界里每个测试方法理论上相互独立,驱动器的消费语义与这个原则天然一致。代价是新手会踩本文实录的两次异常——但异常信息(No input was provided)直白地指向了原因,踩过一次就再也不会忘。相比之下,那些允许重复执行的测试工具反而更常出现"用例间暗依赖"的顽疾。
九、中文测试数据的实测结论
单测里能不能用中文数据,实测分三个层面,结论都来自真实运行。
9.1 断言层面:默认环境直接可用
输入 "你好 世界"、断言输出 (你好,1) 与 (世界,1),在 mvn -version 报 platform encoding: ANSI_X3.4-1968 的默认环境下测试通过(Tests run: 1, Failures: 0)。原理:Text 在内存中做 UTF-8 字节串与 Java String 的双向转换,断言比较发生在 JVM 内部,与终端编码无关。前提是 pom 保留 project.build.sourceEncoding=UTF-8,让 javac 正确读取中文源码。
9.2 打印层面:默认变问号,MAVEN_OPTS 传不进去
同一测试里 System.out.println 输出词项,默认环境打印 TOKEN=??(两个问号),测试本身仍通过。尝试 MAVEN_OPTS=-Dfile.encoding=UTF-8 无效——surefire fork 的独立 JVM 不继承该参数。正确姿势是 argLine:
mvn test -Dtest=EncodingProbeTest -DargLine="-Dfile.encoding=UTF-8"
实测输出:
TOKEN=你好
TOKEN=世界
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
长期生效可把 <argLine>-Dfile.encoding=UTF-8</argLine> 写进 pom 的 surefire 插件配置。
9.3 测试策略建议
断言不受编码影响意味着"中文键值对"可以放心写进用例;打印仅影响人读输出,建议测试代码保持"少打印、多断言"。若确需调试输出,用 argLine 方案一次性解决,不要依赖环境变量——它对 fork 出来的测试 JVM 无效这件事,很多老文档没有讲清。
再把三组探针的完整证据链摆在一起:其一,默认环境断言中文键值,Tests run: 1, Failures: 0(通过);其二,默认环境打印词项,输出 TOKEN=?? 与 TOKEN=??(乱码但测试通过);其三,argLine 注入 UTF-8 后打印 TOKEN=你好 与 TOKEN=世界(正确)。三组对照覆盖了"数据层、断言层、展示层"的全部组合,任何关于"MRUnit 能不能测中文"的疑问都可以在这套对照里找到对应答案。更进一步的实践建议:中文数据用例的价值不止于编码验证——中文分词场景(IK、jieba 等分词器包在 mapper 里)的边界用例(单字词、混合中英、全角标点)同样跑在这套驱动器上,编码结论保证了这些用例的可写性。
十、完整测试报告解读
10.1 构建输出与报告文件
七个测试(基础五个 + combiner 与计数器两个)分属两个测试类,mvn test 的 surefire 段会逐类列出 Running 与 Tests run 行;汇总行 Results 是全工程的总账。完整的失败详情不依赖控制台——target/surefire-reports/ 下每个测试类一个纯文本报告(com.example.WordCountTest.txt 等),含完整堆栈与时间,本文引用的失败实录正是从这里逐字摘录的。把 surefire-reports 纳入持续集成的产物归档,失败排查就不需要重跑构建。
10.2 首次运行的日志警告
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/root/.m2/repository/org/slf4j/slf4j-reload4j/1.7.36/slf4j-reload4j-1.7.36.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/root/.m2/repository/org/slf4j/slf4j-log4j12/1.7.25/slf4j-log4j12-1.7.25.jar!/org/slf4j/impl/StaticLoggerBinder.class]
log4j:WARN No appenders could be found for logger (org.apache.hadoop.util.Shell).
两个绑定分别来自 hadoop-mapreduce-client-core 传递的 slf4j-reload4j 与 zookeeper 传递的 slf4j-log4j12,任选其一保留即可,不影响测试语义;No appenders 是 Hadoop 内部日志无输出目标,静默处理。这些警告是"库时代码"的正常生态噪音,与其逐个 exclusions,不如统一放行——它们不会出现在你的生产交付物里。
10.3 耗时基线
本文工程七个测试的实测耗时:WordCountTest 五用例 1.413 秒、CombinerCounterTest 两用例约 0.5 秒,全构建(含编译)7.750 秒。任何一次改动后回归的代价不超过十秒,这就是"集群验证转 JVM 验证"的收益量化:同样的逻辑修正,集群上一轮要分钟级到十分钟级。给测试耗时设基线(如单类不超过 5 秒)还能顺带监控依赖树劣化——某个传递依赖引入重量级静态初始化时会立刻显形。
10.4 接入持续集成的三个检查点
把这套测试接进持续集成流水线时,有三个基于本文实测经验的检查点。第一,必须断言 Tests run 数量:流水线脚本里解析 surefire 输出或读取报告 XML,确认测试数不低于预期值,堵住"空跑假绿"。第二,固定工具链版本:流水线的构建节点要把 JDK 与 Maven 版本钉死在本文验证过的组合(OpenJDK 8、Maven 3.6.x),高版本节点上 1.1.0 的传递依赖未经官方验证,偶发问题会以"时好时坏"的面目出现,极难排查。第三,失败产物归档:把 target/surefire-reports 目录随构建产物一起归档,失败时不需要重跑就能拿到完整堆栈。三点都不复杂,但任何一点缺位都会在几个月后变成凌晨的告警电话。
十一、常见问题(FAQ)
1. 测试全绿但 Tests run 是 0,算通过吗? 不算。surefire 没扫到任何测试时构建照样 SUCCESS,这是最危险的假绿。确认输出里有 Running com.example.WordCountTest 行、且 Tests run 数量与用例数一致。常见原因:测试类不在 src/test/java、类名不符合 *Test 约定、方法缺少 @Test 注解。
2. MapDriver 与 MapReduceDriver 的输出排序规则为什么不一样? MapDriver 只模拟"调用一次 map",输出按 write 发生顺序;MapReduceDriver 模拟完整管道,输出是 shuffle 按键排序后的键序。期望登记顺序必须分别匹配这两种语义,本文 3.2 与 5.1 节各有实测佐证。
3. 期望输出报 Missing expected output ... at position N 怎么修? 按实际 write 顺序重排登记顺序;或者改用 run() 取回实际列表、排序后与期望比较(顺序无关)。不建议反向操作——为了迎合登记顺序去改 mapper 的 write 顺序是本末倒置。
4. Pair 怎么构造?有 newPair 吗? 1.1.0 的 org.apache.hadoop.mrunit.types.Pair 只有构造函数 new Pair<>(first, second),getFirst/getSecond 取值,没有工厂方法(javap 实测可证)。旧资料里的 newPair 写法编译不过。
5. 能断言计数器吗? 能。withCounter(Enum, long) 或字符串组名对登记期望计数,runTest 一并校验。注意计数器断言不豁免输出断言——不登记期望输出会被视为期望零输出(本文 7.1 节实录)。
6. Combiner 怎么测? 把同一个 Reducer 传给三参数工厂 newMapReduceDriver(mapper, reducer, combiner) 或 withCombiner(),断言最终输出与不带 combiner 时一致,即为幂等性回归。求和、求最大值这类可交换可结合的聚合天然满足;不满足的 combiner(如取第一个值)会被立刻抓出。
7. runTest 之后还能再 run 吗? 不能。执行一轮即消费登记内容,再调用抛 IllegalStateException: No input was provided(MapDriverBase.preRunChecks 实测堆栈可证)。需要多轮就重新登记。
8. 中文测试数据需要注意什么? 断言直接可用(Text 内存中 UTF-8 往返,平台编码无关),pom 保留 sourceEncoding=UTF-8 即可;控制台打印中文默认变问号,MAVEN_OPTS 传不进 surefire fork 的 JVM,要用 -DargLine="-Dfile.encoding=UTF-8"(本文第九节三组对照实测)。
9. 传进 reducer 的值列表顺序可控吗? 可控。ReduceDriver 的 withInput(key, values) 用普通 List 直传,顺序就是你构造的顺序。这正好用来测"reduce 结果是否依赖值顺序"——依赖顺序的 reducer 在集群上是有隐患的,因为真实 shuffle 不保证组内值序与 map 产出序一致。
10. 一个测试类里可以复用同一个驱动器对象吗? 对象可以(@Before 统一构造),登记内容不行——每个测试方法独立 withInput/withOutput,否则互相污染。混用登记列表的症状是"单独跑绿、一起跑红",用 mvn test -Dtest=单个方法可以快速定位这类问题。
11. 怎么只跑一个测试方法? mvn test -Dtest=WordCountTest#testMapper(JUnit 4 支持)。定位失败用例时比整类重跑更快;修好后记得去掉 -Dtest 全量回归,防止方法间的隐藏耦合被跳过。
12. MRUnit 测过的逻辑还需要集成测试吗? 需要,但量可以很小。MRUnit 不覆盖分区器、InputFormat 分片、压缩、分布式缓存等框架行为,这些保留给 LocalJobRunner 或真实集群的少量冒烟用例。分层的原则是:业务逻辑的错误在毫秒级单测里消灭,框架交互的错误留给少量高价值集成用例。
十二、参考链接
- Apache Attic 项目页(MRUnit):https://attic.apache.org/projects/mrunit.html
- MRUnit 归档官网:https://mrunit.apache.org/
- Maven Central 上的 mrunit 1.1.0:https://central.maven.org/artifact/org.apache.mrunit/mrunit/1.1.0
- Hadoop 2.10.2 发布页:https://hadoop.apache.org/release/2.10.2.html
- JUnit 4 官方文档:https://junit.org/junit4/
- Maven Surefire 插件文档:https://maven.apache.org/surefire/maven-surefire-plugin/
- Hadoop MapReduce 教程:https://hadoop.apache.org/docs/stable/hadoop-mapreduce-client/hadoop-mapreduce-client-core/MapReduceTutorial.html