Ubuntu 22.04 集成 Apache MRUnit 1.1.0 指南

Ubuntu 22.04 上集成 Apache MRUnit 1.1.0 的指南,涵盖 OpenJDK 8 安装、Maven 阿里云镜像、工程创建、hadoop2 依赖引入、测试验证与移除方法。

Apache MRUnitMRUnitApacheJavaUbuntu 22.04安装部署2026/10/15

Ubuntu 22.04 集成 Apache MRUnit 1.1.0 指南

一、集成目标与总体路线

MRUnit 是一个纯 Java 测试库,"集成"对它而言意味着三件事:把构件放进 Maven 本地仓库、让工程编译通过、让单元测试真实跑绿。它没有守护进程、没有配置文件、没有端口,全部能力都以 jar 的形式通过 classpath 提供,因此整个集成过程就是一条标准的"JDK → Maven → 工程 → 依赖 → 测试验证"链路。本文在 Ubuntu 22.04 服务器上完整走通这条链路,最终以经典 WordCount 的五个单元测试全部通过作为验收标志。

与集成一个服务型软件不同,测试库的集成验收不是"起了进程、通了端口",而是"测试代码真的执行了、断言真的生效了"。这里有一个隐蔽的失败模式值得提前预警:surefire 一个测试都没跑时,构建依然显示 BUILD SUCCESS——空跑不会失败。所以本文的验收标准除了"绿色"之外还要求确认 Running com.example.WordCountTest 这样的执行记录确实出现在输出里,确保"测了"而不是"空过"。这个习惯在把 MRUnit 引入持续集成的流水线时尤其重要:防止因为测试类命名不符合 *Test 约定或放错源码目录而让整个测试环节静默失效。

总体路线归纳为六步:装 OpenJDK 8 → 装 Maven 并配置阿里云镜像 → 建工程骨架 → 写 pom 引入 mrunit 与 Hadoop 客户端库 → 编写被测代码与测试 → 运行 mvn test 验证并记录移除方式。有两个选型决定需要提前说明,它们贯穿全文:其一,坚持用 OpenJDK 8 而不是更高版本,因为 MRUnit 1.1.0 发布于 2014 年(2016 年项目退役),官方从未在 JDK 8 之后的版本上验证过它,遗留库集成的第一原则是把不可控变量排除在排查视野之外;其二,mrunit 构件必须带 hadoop2 分类器,同时工程里要有 Hadoop 2.x 的两个客户端构件,这是 1.1.0 能正常工作的硬前提,第五节会展开。

二、集成前的选型分析

2.1 运行时版本:为什么是 JDK 8

MRUnit 1.1.0 的字节码目标是 Java 6/7 时代,其传递依赖(powermock 1.5.1、mockito 1.9.5、guava 11.0.2 等,实测依赖树可证)都是那个年代的产物。这些库在 JDK 8 上运行没有任何问题,但其中不少在 JDK 11+ 上会踩到模块化封装、已移除的内部 API 等问题。与其逐个排障,不如直接匹配它原生的运行时代际。Ubuntu 22.04 的官方源里就有 openjdk-8-jdk(universe 仓库),无需第三方源,这让"贴近原生环境"的成本降到最低。实测安装的版本是 8u504-ga-1ubuntu1~22.04.3,这是 jammy-updates 维护线上的当前版本。

2.2 坐标与 hadoop2 分类器

Maven Central 上的坐标是 org.apache.mrunit:mrunit:1.1.0,但 1.1.0 的主坐标下没有默认 jar——它按 Hadoop 大版本分别构建了 hadoop1 与 hadoop2 两个分类器构件,对应两代 MapReduce API 的包结构与适配差异。声明依赖时必须显式写 <classifier>hadoop2</classifier>,否则坐标解析直接失败。这个设计在 2014 年是合理的(Hadoop 1.x 与 2.x 并存期),但今天容易踩坑:网上大量旧示例不带 classifier,照抄必败。本文实测使用 hadoop2,配 Hadoop 2.10.2 客户端库。

2.3 Hadoop 客户端库的版本与范围

MRUnit 本身不携带 Hadoop API,Mapper、Reducer、Text、IntWritable 这些类型全部来自 Hadoop 客户端库,因此工程必须自己引入。最小集合是两个构件:hadoop-mapreduce-client-core(提供 mapreduce 新 API 的 Mapper/Reducer 基类与 Context)与 hadoop-common(提供 Text、IntWritable、LongWritable、Configuration 等 io 与公共类型)。版本选择 2.10.2——Hadoop 2.x 线的最后一个维护版本,与 1.1.0 的适配目标(Hadoop 2)同代。3.x 理论上兼容新 API,但依赖树差异大、未经 MRUnit 官方验证,不建议在首次集成时引入额外变量。范围上这两个构件用默认 compile 即可(被测代码的主代码也要 import 它们);mrunit 与 junit 都是 test 范围,不进入交付物。

两个构件而不是一个大构件,是 Hadoop 2.x 依赖体系的特点:hadoop-mapreduce-client-core 只带 mapreduce 框架类型,会传递引入 yarn-client、yarn-common、avro、protobuf 等约三十个构件;hadoop-common 负责 io 与公共设施,传递引入 httpclient、zookeeper、curator 等约六十个构件。两者合计不到一百个传递依赖、缓存约 62MB(本文实测),对现代开发机毫无压力。反过来说,如果你只引入 hadoop-common 而漏掉 client-core,Mapper 基类找不到,编译会立刻失败——错误信息明确指向缺失的包,容易定位。

2.4 测试框架:JUnit 4

MRUnit 1.1.0 的驱动器 API 是 JUnit 3/4 风格(runTest 直接抛断言错误,依赖 org.junit.Assert),没有适配 JUnit 5。引入 junit:junit:4.13.2(4.x 最终版)即可。不需要手工引入 hamcrest,junit 4.13.2 会传递依赖 hamcrest-core 1.3。mockito 与 powermock 虽然出现在 mrunit 的传递依赖里(test 范围),但基本用法完全用不到,无需关心。

三、系统准备与 OpenJDK 8 安装

3.1 确认系统与现有 Java 环境

先确认系统版本与已装的 Java:

grep PRETTY /etc/os-release
java -version 2>&1 || echo "java-not-found"

实测环境输出:

PRETTY_NAME="Ubuntu 22.04.5 LTS"

执行 apt-get update 刷新索引后,用 apt-cache 确认源里的 JDK 8:

apt-cache policy openjdk-8-jdk | head -8

输出(jammy 官方源就有,无需第三方仓库):

openjdk-8-jdk:
  Installed: 8u504-ga-1ubuntu1~22.04.3
  Candidate: 8u504-ga-1ubuntu1~22.04.3
  Version table:
 *** 8u504-ga-1ubuntu1~22.04.3 500
        500 http://archive.ubuntu.com/ubuntu jammy-updates/universe amd64 Packages
        500 http://security.ubuntu.com/ubuntu jammy-security/universe amd64 Packages
        100 /var/lib/dpkg/status

注意 Version table 里 500 优先级的两条分别来自 jammy-updates/universe 与 jammy-security/universe——Ubuntu 22.04 官方源自 8u372 起重新收录了 OpenJDK 8,如今的集成不需要再添加 PPA,直接从官方源安装即可,这比老教程里"必须加 openjdk-r PPA"的步骤更简单也更安全。

3.2 安装 OpenJDK 8 与 Maven

一条命令把 JDK 与 Maven 一起装上(本文实测环境为逐个安装后复核):

apt-get install -y openjdk-8-jdk
apt-get install -y maven

已安装时重跑会给出幂等回显:

openjdk-8-jdk is already the newest version (8u504-ga-1ubuntu1~22.04.3).
0 upgraded, 0 newly installed, 0 to remove and 18 not upgraded.
maven is already the newest version (3.6.3-5).
0 upgraded, 0 newly installed, 0 to remove and 18 not upgraded.

Maven 3.6.3-5 同样来自 jammy/universe 官方源(apt-cache policy maven 可证),对 MRUnit 这种传统 pom 工程完全够用。

3.3 排障实录:默认 Java 指向了 11 而不是 8

Ubuntu 22.04 上若同时存在多个 JDK(default-jdk 元包默认拉 JDK 11),update-alternatives 机制会把 /usr/bin/java 指向优先级最高的版本,通常是 11。装完 openjdk-8-jdk 后执行 java -version 如果看到的还是 11,用 update-alternatives 查看现状:

update-alternatives --list java
update-alternatives --display java | grep -E "link best|currently"

实测输出(两套 JDK 并存时的典型状态):

/usr/lib/jvm/java-11-openjdk-amd64/bin/java
/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java
  link best version is /usr/lib/jvm/java-11-openjdk-amd64/bin/java
  link currently points to /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java

把 java 手工切到 8(注意 OpenJDK 8 的 java 在 jre/bin/ 子路径下,与 11 的 bin/java 不同):

update-alternatives --set java /usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java

验证生效:

java -version 2>&1
javac -version
openjdk version "1.8.0_504"
OpenJDK Runtime Environment (build 1.8.0_504-8u504-ga-1ubuntu1~22.04.3-b01)
OpenJDK 64-Bit Server VM (build 25.504-b01, mixed mode)
javac 1.8.0_504

update-alternatives --set 在目标已是当前指向时静默无输出,成功与否以 java -version 的实际回显为准。javac 的候选通常只有 8 这一个(11 的 javac 若未注册进 alternatives 不受影响),无需额外处理。

3.4 确认 Maven 与 JDK 的绑定

mvn -version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 1.8.0_504, vendor: Ubuntu, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: en_US, platform encoding: ANSI_X3.4-1968

关键看 Java version 行:runtime 指向 java-8 即绑定正确,Maven 用这套 JDK 编译与跑测试。platform encoding 显示 ANSI_X3.4-1968(即 ASCII)是 Ubuntu 最小安装下无 UTF-8 locale 的正常现象,对本文的集成没有影响(原因见第九节的实测结论),但建议顺手执行 apt-get install -y locales && locale-gen zh_CN.UTF-8 en_US.UTF-8 消除隐患。

为什么编码问题可以放到第九节才处理,这里先把原理讲透:整个集成链路上有三个编码现场——javac 读源文件的编码(由 pom 的 project.build.sourceEncoding 决定,与平台无关)、JVM 内部 String 的编码(永远是 UTF-16,不受环境影响)、以及 System.out 打印到终端的编码(由 JVM 启动参数 file.encoding 决定)。三处只有最后一处受平台 locale 影响,而单元测试的断言比较发生在第二层,所以只要 sourceEncoding 配置正确,断言层面天然免疫平台编码;只有"测试代码里往控制台打印中文"这一个动作需要额外配置。把这三个现场分清楚,就不会再用重启、改环境变量这类无关手段瞎试。

四、配置 Maven 阿里云镜像

国内网络直连 Maven Central 下载依赖经常缓慢甚至超时,配置阿里云公共仓库是最常用的加速手段。创建或编辑 /root/.m2/settings.xml:

mkdir -p /root/.m2
cat > /root/.m2/settings.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
  <mirrors>
    <mirror>
      <id>aliyunmaven</id>
      <mirrorOf>central</mirrorOf>
      <name>Aliyun Maven Central Mirror</name>
      <url>https://maven.aliyun.com/repository/public</url>
    </mirror>
  </mirrors>
</settings>
EOF

mirrorOf 写 central 只镜像中央仓库,不拦截其他自定义仓库——比 *(全部拦截)更安全,公司私服或特殊仓库不会被吞掉。settings.xml 是用户级配置,对当前用户所有 Maven 工程生效,不需要在每个 pom 里重复。若网络走代理,还需在 settings.xml 的 <proxies> 段配置或在环境中设置 http_proxy/https_proxy。判断镜像是否生效的办法是看 mvn 下载日志里的来源域名:生效时会显示 Downloaded from aliyunmaven: https://maven.aliyun.com/...(本文第十节的重拉验证输出里可以看到实录)。

本地仓库默认在 /root/.m2/repository,所有依赖只下载一次、多工程共享。本文实测的完整依赖缓存约 62MB(du -sh /root/.m2/repository),其中大头是 hadoop-common 的传递依赖。

五、创建 Maven 工程并引入 MRUnit

5.1 建立工程骨架

用手工目录方式建工程(比 archetype 生成后删样板更干净):

mkdir -p /opt/mrunit-demo/src/main/java/com/example
mkdir -p /opt/mrunit-demo/src/test/java/com/example
find /opt/mrunit-demo -type d | sort

实测输出:

/opt/mrunit-demo
/opt/mrunit-demo/src
/opt/mrunit-demo/src/main
/opt/mrunit-demo/src/main/java
/opt/mrunit-demo/src/main/java/com
/opt/mrunit-demo/src/main/java/com/example
/opt/mrunit-demo/src/test
/opt/mrunit-demo/src/test/java
/opt/mrunit-demo/src/test/java/com
/opt/mrunit-demo/src/test/java/com/example

主代码放 src/main/java/com/example(Mapper 与 Reducer),测试代码放 src/test/java/com/example(JUnit 测试类),这是 Maven 约定俗成的两棵源码树。

5.2 编写 pom.xml

在 /opt/mrunit-demo/pom.xml 写入完整内容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>mrunit-demo</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <properties>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <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>
  </dependencies>
</project>

四个依赖逐项说明:mrunit 带 hadoop2 classifier、test 范围——驱动器只在测试代码里用;hadoop-mapreduce-client-core 与 hadoop-common 用默认 compile 范围——被测代码的主代码也要 import Hadoop 类型;junit 4.13.2 test 范围。properties 里的 project.build.sourceEncoding 必须写 UTF-8:不写的话老版本编译插件会用平台默认编码读源文件,一旦测试数据里有中文就可能编译出错误的字节码。

5.3 关于版本组合的可复现性

本文实测的组合是 mrunit 1.1.0(hadoop2) + hadoop-client-core/common 2.10.2 + junit 4.13.2 + Maven 3.6.3 + JDK 8u504,全部来自官方源与 Maven Central,任何一台 Ubuntu 22.04 机器按同样步骤都能复现相同结果。更换 Hadoop 客户端到其他 2.x 版本原则上可行(新 API 自 2.0 起稳定),但不在本文验证范围内;升级到 3.x 前请先阅读介绍篇里关于依赖树差异的说明。

六、编写被测代码

6.1 WordCountMapper

创建 src/main/java/com/example/WordCountMapper.java:

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);
        }
    }
}

三个值得注意的写法细节都是 Hadoop 生产代码的惯用法:ONE 是静态常量而不是每次 new——避免每词一个对象;word 是复用的成员对象,set 新值后 write——Text.write 把当前字节写入流,框架侧立即取值,复用是安全的;泛型参数 Mapper<LongWritable, Text, Text, IntWritable> 对应"输入键=行偏移量、输入值=行文本、输出键=词、输出值=计数 1"。

6.2 WordCountReducer

创建 src/main/java/com/example/WordCountReducer.java:

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);
    }
}

reduce 的输入值参数是 Iterable<IntWritable>——框架把同一 key 的所有值归成一组传入,这就是 shuffle 的分组语义;求和后输出 (词, 总数)。这段代码同样是生产级的:无状态、不依赖调用顺序。

七、编写单元测试

7.1 测试类全文

创建 src/test/java/com/example/WordCountTest.java,覆盖三层驱动器共五个用例:

package com.example;

import java.util.ArrayList;
import java.util.List;

import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mrunit.mapreduce.MapDriver;
import org.apache.hadoop.mrunit.mapreduce.MapReduceDriver;
import org.apache.hadoop.mrunit.mapreduce.ReduceDriver;
import org.junit.Before;
import org.junit.Test;

public class WordCountTest {
    private MapDriver<LongWritable, Text, Text, IntWritable> mapDriver;
    private ReduceDriver<Text, IntWritable, Text, IntWritable> reduceDriver;
    private MapReduceDriver<LongWritable, Text, Text, IntWritable, Text, IntWritable> driver;

    @Before
    public void setUp() {
        WordCountMapper mapper = new WordCountMapper();
        WordCountReducer reducer = new WordCountReducer();
        mapDriver = MapDriver.newMapDriver(mapper);
        reduceDriver = ReduceDriver.newReduceDriver(reducer);
        driver = MapReduceDriver.newMapReduceDriver(mapper, reducer);
    }

    @Test
    public void testMapper() throws Exception {
        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();
    }

    @Test
    public void testMapperEmptyLine() throws Exception {
        mapDriver.withInput(new LongWritable(1), new Text(""));
        mapDriver.runTest();
    }

    @Test
    public void testReducer() throws Exception {
        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();
    }

    @Test
    public void testMapReduce() throws Exception {
        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();
    }

    @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);
    }
}

7.2 五个用例各验证什么

testMapper 验证单条映射且期望输出按 write 顺序登记(hello、world、hello、mrunit);testMapperEmptyLine 验证空行产生零输出——不抛异常也不写任何记录;testReducer 验证三个 1 归并为 3;testMapReduce 验证端到端语义(三行输入、fox 出现两次、the 出现三次),期望输出按 shuffle 后的键序登记;testMapReduceOrderInsensitive 用 run() 取回实际列表自行断言,演示顺序无关的比较方式。两个类型细节:Pair 只有二元构造函数 Pair(first, second),没有工厂方法;driver.run() 返回的列表就是按键排序后的实际输出。

测试类里三个驱动器在 @Before 中共用同一对 mapper 与 reducer 实例,这是有意为之:MRUnit 的驱动器每次 runTest 都重新调用被测对象,不会在对象间残留调用状态,共享实例既省对象创建也没有副作用。但期望输出必须每个用例独立登记(本文每个测试方法里各自 withInput/withOutput),跨用例共享登记列表会互相污染——驱动器对象可以复用,登记内容不能复用,这条边界务必守住。

八、构建与运行验证

8.1 运行 mvn clean test

cd /opt/mrunit-demo && mvn clean test

实测输出(节选关键段):

[INFO] --- maven-compiler-plugin:3.1:compile (default-compile) @ mrunit-demo ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 2 source files to /opt/mrunit-demo/target/classes
[INFO]
[INFO] --- maven-compiler-plugin:3.1:testCompile (default-testCompile) @ mrunit-demo ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source file to /opt/mrunit-demo/target/test-classes
[INFO]
[INFO] --- maven-surefire-plugin:2.12.4:test (default-test) @ mrunit-demo ---
[INFO] Surefire report directory: /opt/mrunit-demo/target/surefire-reports

-------------------------------------------------------
 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] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  7.750 s

五个测试全部通过,整个构建含编译只用了 7.750 秒——这就是"毫秒级单测"的集成验收:没有任何集群、没有任何远程服务参与。验收达标线建议定为"五个用例全绿 + Time elapsed 小于 5 秒",如果耗时明显超标,通常是被传递依赖拖进了什么初始化逻辑,需要回头检查依赖树。

重复执行 mvn test(不带 clean)时会看到另一种输出:编译器报告 Nothing to compile、surefire 仍然完整重跑五个测试。这是 Maven 生命周期的正常行为——增量编译按文件时间戳判定,而测试默认全量执行。想让测试也跳过可以用 -DskipTests(只编译不测试)或 -Dmaven.test.skip=true(连测试类编译都跳过),但在集成验证阶段请保持全跑,不要用跳过参数制造虚假的绿色。

失败时的排查入口也在此节说清:断言失败(Failures 大于 0)与异常错误(Errors 大于 0)要分开看。前者是输出与期望不一致,重点检查期望登记的顺序与键值(参考第九节的顺序陷阱);后者是被测代码或测试代码抛了异常,需要看 surefire 报告里的完整堆栈——报告落盘在 target/surefire-reports/ 目录,纯文本格式,比控制台截断的输出完整得多。

8.2 用依赖树核查集成结果

mvn dependency:tree

实测输出(顶层两级节选):

[INFO] com.example:mrunit-demo:jar:1.0.0
[INFO] +- org.apache.mrunit:mrunit:jar:hadoop2:1.1.0:test
[INFO] |  +- commons-logging:commons-logging:jar:1.1.1:compile
[INFO] |  +- org.mockito:mockito-core:jar:1.9.5:test
[INFO] |  +- com.google.guava:guava:jar:11.0.2:compile
[INFO] |  \- org.powermock:powermock-module-junit4:jar:1.5.1:test
[INFO] +- org.apache.hadoop:hadoop-mapreduce-client-core:jar:2.10.2:compile
[INFO] |  +- org.apache.hadoop:hadoop-yarn-client:jar:2.10.2:compile
[INFO] |  +- com.google.protobuf:protobuf-java:jar:2.5.0:compile
[INFO] |  +- org.apache.avro:avro:jar:1.7.7:compile
[INFO] |  \- org.slf4j:slf4j-api:jar:1.7.36:compile
[INFO] +- org.apache.hadoop:hadoop-common:jar:2.10.2:compile
[INFO] |  +- commons-cli:commons-cli:jar:1.2:compile
[INFO] |  +- org.apache.httpcomponents:httpclient:jar:4.5.13:compile
[INFO] |  +- com.google.code.gson:gson:jar:2.2.4:compile
[INFO] |  \- org.apache.zookeeper:zookeeper:jar:3.4.14:compile
[INFO] \- junit:junit:jar:4.13.2:test
[INFO]    \- org.hamcrest:hamcrest-core:jar:1.3:test

核查三要点:mrunit 行必须带 hadoop2 分类器且是 test 范围;hadoop 两个构件都是 2.10.2、没有被子模块拖成别的版本;guava 11.0.2 由 mrunit 传递引入——如果工程主代码同时声明了新版 guava,注意 1.1.0 是 2012 年代码,混版本可能踩老 API 兼容问题,必要时用 <exclusions> 排除后自选版本并实测。

8.3 日志警告的解读

首次运行时 surefire 段前会打几行 SLF4J 与 log4j 警告:

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).

这些警告不影响测试结果:多个 SLF4J 绑定来自 hadoop-mapreduce-client-core 传递的 slf4j-reload4j 与 zookeeper 传递的 slf4j-log4j12,任选其一即可,测试代码不依赖日志行为;No appenders 说明 Hadoop 内部日志没有配置输出目标,静默即可。想让输出干净,可在测试 classpath 放一个 log4j.properties 把日志级别调到 ERROR,属于可选项。

九、中文测试数据的实测结论

集成是否真正可靠,一个常见疑虑是"测试数据里能用中文吗"。本文做了三组探针实验,结论明确。

9.1 断言层面的中文数据直接可用

构造中文输入 "你好 世界" 跑 MapDriver 并断言输出 (你好,1)、(世界,1),在默认编码环境下(mvn -version 显示 platform encoding: ANSI_X3.4-1968)测试直接通过:Tests run: 1, Failures: 0, Errors: 0。原理是 Text 在内存里做 UTF-8 字节串与 Java String 的双向转换,断言比较发生在 JVM 内存中,与控制台编码无关。前提只有一个:源文件必须按 UTF-8 编译,即 5.2 节 pom 里的 project.build.sourceEncoding=UTF-8 必须保留。

9.2 控制台打印中文会变问号,argLine 是正解

同一测试里加 System.out.println 输出中文词,默认环境打印出来是 TOKEN=??(ASCII 替换符),测试本身仍通过。试图用 MAVEN_OPTS=-Dfile.encoding=UTF-8 修复无效——surefire 会 fork 独立 JVM 跑测试,MAVEN_OPTS 只作用于 Maven 自己的 JVM,传不进去。有效的方式是给 surefire 传 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 一个与编码无关但极易踩的坑:runTest 之后不能再 run

探针过程中还实测出一个高频错误写法:先调 mapDriver.runTest() 再调 mapDriver.run(),会抛出:

java.lang.IllegalStateException: No input was provided
        at org.apache.hadoop.mrunit.MapDriverBase.preRunChecks(MapDriverBase.java:286)

原因是 runTest 执行时会消费掉已登记的输入,之后再调 run() 时输入列表已空。正确做法是二选一:要么 runTest(断言式),要么 run(取数式),一次登记只执行一轮;确实要两轮时重新登记输入。这个坑在第一次集成时几乎必踩,特此记录。

十、移除与清理方法

完整的集成评估应当包括"怎么干净地退出去"。MRUnit 的移除成本极低,这也正是它适合做技术评估的原因之一:三层撤除粒度,按需选择。

10.1 从工程中移除依赖

删除 pom.xml 里对应的 <dependency> 段即可,Maven 不需要任何注销动作。移除后执行 mvn clean compile 验证主代码不再引用相关类(若有残留 import 会编译失败,这本身就是一次反向核查)。通常的场景是评估期结束只撤 mrunit 与 junit(这两个本来就是 test 范围),Hadoop 客户端库因为生产代码还在用而保留——此时依赖树会自动瘦掉 powermock、mockito 等仅为 mrunit 服务的传递分支,Maven 的传递依赖管理替你完成了绝大部分清理工作。

10.2 清理本地仓库缓存

du -sh /root/.m2/repository
rm -rf /root/.m2/repository/org/apache/mrunit
ls /root/.m2/repository/org/apache/ | grep mrunit || echo "mrunit-artifact-removed"

实测输出:

62M     /root/.m2/repository
mrunit-artifact-removed

删除后想再验证依赖可随时重拉(也证明镜像配置工作正常):

mvn -B dependency:get -Dartifact=org.apache.mrunit:mrunit:1.1.0:jar:hadoop2

实测输出(节选):

[INFO] Downloaded from aliyunmaven: https://maven.aliyun.com/repository/public/org/codehaus/jackson/jackson-core-asl/1.0.1/jackson-core-asl-1.0.1.jar (136 kB at 316 kB/s)
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] Total time:  11.516 s

复核构件文件确实回来了:

ls /root/.m2/repository/org/apache/mrunit/mrunit/1.1.0/
_remote.repositories
mrunit-1.1.0-hadoop2.jar
mrunit-1.1.0-hadoop2.jar.sha1
mrunit-1.1.0.pom
mrunit-1.1.0.pom.sha1

10.3 删除工程与卸载工具链

删除整个工程目录:

rm -rf /opt/mrunit-demo

卸载 JDK 与 Maven 前可先用模拟模式确认影响面(-s 只演算不执行):

apt-get -s remove openjdk-8-jdk | tail -4

实测输出:

The following packages will be REMOVED:
  openjdk-8-jdk
0 upgraded, 0 newly installed, 1 to remove and 18 not upgraded.
Remv openjdk-8-jdk [8u504-ga-1ubuntu1~22.04.3]

确认无误后去掉 -s 真正执行,并 apt-get autoremove 清理孤儿依赖。卸载后 java -version 应提示找不到命令。settings.xml 与 /root/.m2/repository 属用户数据,rm -rf /root/.m2 可一并清掉。

三层撤除的顺序建议自上而下:先撤依赖(验证工程是否还有隐性引用),再清构件缓存(必要时重拉验证可恢复性),最后卸载工具链(影响面最大,放最后)。这个顺序保证了任何一步发现"还需要用"时都能低代价回头——撤依赖加回来只要一行 pom,清缓存恢复只要一次重拉,而卸载 JDK 再重装是分钟级的动作。评估类集成的移除演练不是洁癖:它验证的是你对这套东西的依赖边界是否真的清楚。

十一、常见问题(FAQ)

1. 不带 hadoop2 classifier 会怎样? 依赖解析直接失败,报找不到 org.apache.mrunit:mrunit:jar:1.1.0——1.1.0 的主坐标下只有 pom 没有默认 jar,必须用 classifier 显式选择 hadoop1 或 hadoop2。这是集成 MRUnit 1.1.0 的第一个门槛,绝大多数"照抄旧教程失败"都源于此。

2. 可以用 JDK 11 或 17 跑吗? 不建议作为首选。1.1.0 与其传递依赖(powermock 1.5.1、mockito 1.9.5)诞生于 JDK 8 之前,官方从未在更高 JDK 上验证;本文全部实测在 8u504 完成。若确需高版本 JDK,先在隔离工程里完整跑通本文的五个测试再决定。

3. Hadoop 客户端用 3.x 版本行不行? 理论可行但未经 MRUnit 官方验证。Hadoop 3 保持了 mapreduce 新 API 兼容,但依赖树变化大,与 mrunit 1.1.0 的老传递依赖混树风险高。首次集成坚持 2.10.2(Hadoop 2.x 末版),跑通后再评估。

4. mvn test 报 Could not find artifact org.apache.mrunit:mrunit:jar:1.1.0? 两种可能:pom 里漏了 <classifier>hadoop2</classifier>;或本地仓库有损坏的半成品下载。前者补 classifier,后者 rm -rf /root/.m2/repository/org/apache/mrunit 后重跑(本文第十节有完整演示)。

5. 测试报 IllegalStateException: No input was provided? 在 runTest() 之后又调用了 run()(或反过来重复执行)——驱动器执行一轮就消费掉登记的输入。一轮登记只执行一次,需要再次执行请重新 withInput。

6. 期望输出明明一样却断言失败? 看错误里的 position 提示:驱动器按顺序逐项比对输出,(world, 1) 登记在第 0 位而实际第 0 位是 (hello, 1) 就会报 Missing expected output (world, 1) at position 0, got (hello, 1)。修正方法:按 mapper 实际 write 顺序登记期望,或改用 run() 自行做顺序无关比较。

7. 控制台中文变问号怎么办? 分两层:断言比较不受影响(内存中 UTF-8 往返);打印受 surefire fork JVM 的编码影响,MAVEN_OPTS 传不进去,要用 -DargLine="-Dfile.encoding=UTF-8" 或把 argLine 写进 pom(本文第九节有三组对照实测)。

8. SLF4J multiple bindings 警告要处理吗? 不影响测试正确性,是 hadoop-mapreduce-client-core 与 zookeeper 各自带了一个绑定导致的。强迫症可以在 pom 里 exclusions 掉其中一个,或在测试 resources 放 log4j.properties 统一级别。

9. mvn 下载依赖慢或超时? 配置阿里云镜像(本文第四节完整 settings.xml),并用下载日志里的域名确认生效。公司网络有代理时再补 <proxies> 段或环境变量。判断是网络问题还是坐标写错的办法:前者报连接超时/无法检索,后者报 Could not find artifact 且不重试——先看错误类型再对症。

10. 测试文件放在 src/main/java 里会怎样? 编译能过但 surefire 只扫 src/test/java,测试会被静默忽略、Tests run 计数为 0——这是"以为测了其实没测"的典型事故。保持两棵源码树的约定,用 mvn test 输出里的 Running com.example.WordCountTest 行确认测试真的被执行。

11. 集成后工程打包(mvn package)会把 MRUnit 打进 jar 吗? 不会。mrunit 与 junit 都是 test 范围,只存在于测试 classpath;Hadoop 客户端库是 compile 范围会进 WEB-INF/lib 或 lib 目录,这符合"生产代码依赖 Hadoop 类型"的事实。若希望交付物更瘦,可把 Hadoop 两构件也改为 provided 并在部署环境提供。

12. 项目退役多年,这套集成还能撑多久? Maven Central 的构件与校验文件永久保留,本文实测的重拉验证也证明了这一点;风险不在"下载不到"而在"发现缺陷无人修"。存量 Hadoop 工程继续使用合理,新工程建议评估测试工具选型时参考介绍篇的对比章节。

十二、参考链接