Apache MRUnit 1.1 MapReduce 单元测试实战详解

基于 Ubuntu 22.04 实测的 MRUnit 1.1 实战详解,以 WordCount 为例覆盖三层驱动器测试、Combiner 幂等性验证、计数器断言与排障实录,输出逐字实录。

Apache MRUnitMRUnitApache大数据单元测试2026/10/15

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 或真实集群的少量冒烟用例。分层的原则是:业务逻辑的错误在毫秒级单测里消灭,框架交互的错误留给少量高价值集成用例。

十二、参考链接