Java EE 单元测试

作者:Adam Bien

觉得测试 Java EE 应用程序太困难、不方便或者太复杂?通过阅读本文,您将了解现实情况并非如此,同时还将了解如何高效进行单元测试。

下载:
下载Java EE

2011 年 8 月发布

测试是 Java Platform, Enterprise Edition (Java EE) 仍存的神秘领域之一。人们常常错误地认为 Java EE 应用程序的测试比较困难、不方便或者太复杂。从五年多前 Java EE 5 发布以来,实际情况并非人们所认为的那样。在本文中,我将探究单元测试。在后续文章中,我将探讨集成测试和嵌入式容器测试。

:您可以在这里找到用于本文的 Maven 3 项目 TestingEJBAndCDI,已使用 NetBeans 7 和 GlassFish v3.x 对其进行过测试。

向 Oracle 询问 Java 的未来

我将使用一个“oracle”应用程序,借助后台的一些顾问,它能够预测 Java 的未来。需要澄清一下,“oracle”在此处另有他义:

“在古代,oracle 是在的启示下,能够提出明智的忠告或具有先知先觉能力,可以预言预知未来的人或机构。因此,这是一种占卜。”[http://en.wikipedia.org/wiki/Oracle]。

在现代,即便一个 oracle 也需要依靠表示状态传输 (REST) 来公开他的预言(参见清单 1)。OracleResource 是一个 Enterprise JavaBeans (EJB) 3.1 bean,通过用于 RESTful Web 服务的 Java API (JAX-RS) 作为上下文和依赖注入 (CDI) 托管 bean 的 REST 资源和网关来提供。OracleResource 维护一个注入 consultant 池 (Instance<Consultant> company),并请求第一个 consultant 预测 Java 的未来。

@Path("javafuture")

@Stateless

public class OracleResource {

 @Inject

 Instance<Consultant> company;

 @Inject

 Event<Result> eventListener;

 @GET

 @Produces(MediaType.TEXT_PLAIN)

 public String predictFutureOfJava(){

 checkConsultantAvailability();

 Consultant consultant = getConsultant();

 Result prediction = consultant.predictFutureOfJava();

 eventListener.fire(prediction);

 if(JAVA_IS_DEAD.equals(prediction)){

 throw new IllegalStateException("Please perform a sanity / reality check");

 }

 return prediction.name();

 }

 void checkConsultantAvailability(){

 if(company.isUnsatisfied()){

 throw new IllegalStateException("No consultant to ask!");

 }

 }

 Consultant getConsultant(){

 for (Consultant consultant : company) {

 return consultant;

 }

 return null;

 }

}

清单 1:作为 CDI 托管 Bean 的 REST 资源和网关的 EJB 3.1 Bean

Consultant 是一个由 Blogger、ReasonableConsultant 和 SmartConsultant 实现的 Java 接口。OracleResource 只是选择第一个 Consultant,向其询问 Java 的未来。除了 JAVA_IS_DEAD(会引起 IllegalStateException)外,所有答案均可接受。通常,您会使用 javax.inject.Qualifier 明确您的选择,但 javax.enterprise.inject.Instance 的测试比较困难,因此我使用 javax.enterprise.inject.Instance 来使测试更加“有趣”。

public class Blogger implements Consultant{

 @Override

 public Result predictFutureOfJava() {

 return Result.JAVA_IS_DEAD;

 }

}

清单 2:Blogger Consultant 实现

所有预言都作为事务事件进行分配。每个预言均在 EJB 容器启动的独立事务中执行。这是一种约定;无需为此进行额外配置。

PredictionAudit EJB 3.1 bean 接收事件,它使用 Java Persistence API (JPA) 2 保存所有成功的和失败的预言,因为已知有些 consultant 会回滚他们的结论(参见清单 3)。

@Stateless

public class PredictionAudit { 

 @PersistenceContext

 EntityManager em; 

 public void onSuccessfulPrediction(@Observes(during= TransactionPhase.AFTER_SUCCESS) Result result){

 persistDecision(result,true);

 }

 public void onFailedPrediction(@Observes(during= TransactionPhase.AFTER_FAILURE) Result result){

 persistDecision(result,false);

 }

 void persistDecision(Result result,boolean success) {

 Prediction prediction = new Prediction(result,success);

 em.persist(prediction);

 } 

 public List<Prediction> allDecisions(){

 return this.em.createNamedQuery(Prediction.findAll).getResultList();

 }

}

清单 3:事件驱动的 PredictionAudit

CDI 事件巧妙地将 OracleResource 与 PredictionAudit 分离,但同时也使测试变得更加困难。无论事务是提交还是回滚,对每个预言均保存 JPA 2 实体 Prediction(参见清单 4)。

@Entity

@XmlRootElement

@XmlAccessorType(XmlAccessType.FIELD)

@NamedQuery(name=Prediction.findAll,query="Select d from Prediction d")

public class Prediction {

 public final static String PREFIX = "com.abien.testing.oracle.entity.Prediction.";

 public final static String findAll = PREFIX + "findAll";

 @Id

 @GeneratedValue

 @XmlTransient

 private long id; 

 @Column(name="prediction_result")

 @Enumerated(EnumType.STRING)

 private Result result; 

 @Temporal(TemporalType.TIME)

 private Date predictionDate;

 private boolean success;

 public Prediction() {

 this.predictionDate = new Date();

 } 

 public Prediction(Result result, boolean success) {

 this();

 this.result = result;

 this.success = success;

 }

//bookkeeping methods omitted

}

清单 4:JPA 2 实体结论 

Maven 3 与单元测试 — 开始之前

Java EE 6 应用程序的单元测试没什么特别之处。只需将 JUnit 库添加到 pom.xml 文件中(参见清单 5),将您的类放到 src/test/java 目录中。在标准 Maven 生命周期 (mvn clean install) 期间,将自动执行所有 JUnit 测试。

 <dependency>

 <groupId>junit</groupId>

 <artifactId>junit</artifactId>

 <version>4.8.2</version>

 <scope>test</scope>

 </dependency>

清单 5:在 pom.xml 中包括 JUnit 库

加载标准 Maven 原型包含的 Java EE 6 API 类时将收到一条奇怪的错误(参见清单 6)。

 <dependency>

 <groupId>javax</groupId>

 <artifactId>javaee-web-api</artifactId>

 <version>6.0</version>

 <scope>provided</scope>

 </dependency>

清单 6:对 API 的引用不可用

Maven 信息库中的标准 Java EE 6 API 经过一个工具的处理,该工具从字节码中删除方法的主体实现,从而使 javaee-web-api 依赖对单元测试不可用。从 Java EE 6 API 加载类的任何尝试都将导致类似下面的错误:

Absent Code attribute in method that is not native or abstract in class file javax/enterprise/util/TypeLiteral

java.lang.ClassFormatError:Absent Code attribute in method that is not native or abstract in class file javax/enterprise/util/TypeLiteral

 at java.lang.ClassLoader.defineClass1(Native Method)

 at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)

清单 7:从 Java EE 6 API 加载类时导致的错误

您应该用应用程序供应商的实现替换 Java EE 6 API 类。对 GlassFish v3.1,最方便的方法是在嵌入式容器上使用单个依赖:

 <dependency>

 <groupId>org.glassfish.extras</groupId>

 <artifactId>glassfish-embedded-all</artifactId>

 <version>3.1</version>

 <scope>provided</scope>

 </dependency>

清单 8:Java EE 6 API 替换

当然,您可以为 JUnit 测试或编译挑选 CDI、JPA、EJB 等库,或者使用 JBoss 或 Geronimo Maven 镜像中的有效替代品。

为何选择 Mockito?

Mockito 是一个易用的开源模拟库。Mockito 能够从类或接口创建“智能代理”(也称为模拟)。虽然这些代理不附带任何行为,但它们仍然十分有用。您可以调用方法,但只会返回默认值,或空值。创建这些模拟后,将使用 when(mock.getAnswer()).then(42) 语法记录它们的行为。

Mockito 非常适合“模拟”各种难以实现的类、资源或服务。您只要了解一个 org.mockito.Mockito 类就可以开始学习使用 Mockito 了。when-then“领域专用语言”由来自 Mockito 类的静态方法组成。对 org.mockito.Mockito 类的文档注释很好。事实上,由 org.mockito.Mockito 类中的 JavaDoc 标记生成了完整的文档集。

使用 Mockito 对 Java EE 6 进行单元测试

PredictionAudit 将根据事务结果设置“success”标记,并使用 EntityManager 保存 Prediction 实体。要精确测试 PredictionAudit 类,必须“模拟”EntityManager。我们将只测试 EntityManager#persist 方法是否接收正确参数,而不测试是否真正保存 Prediction 实体。(在后续文章中,我们将探讨对 PredictionAudit 与实际 JPA EntityManager 功能间交互的集成测试。)

使用改进的静态 mock(EntityManager.class) 方法创建模拟出的 EntityManager 实例。

import static org.mockito.Mockito.*;

public class PredictionAuditTest {

 private PredictionAudit cut; 

 @Before

 public void initializeDependencies(){

 cut = new PredictionAudit();

 cut.em = mock(EntityManager.class);

 } 

 @Test

 public void savingSuccessfulPrediction(){

 final Result expectedResult = Result.BRIGHT;

 Prediction expected = new Prediction(expectedResult, true);

 this.cut.onSuccessfulPrediction(expectedResult);

 verify(cut.em,times(1)).persist(expected);

 }

 @Test

 public void savingRolledBackPrediction(){

 final Result expectedResult = Result.BRIGHT;

 Prediction expected = new Prediction(expectedResult, false);

 this.cut.onFailedPrediction(expectedResult);

 verify(cut.em,times(1)).persist(expected);

 }

}

清单 9:PredictionAudit 的单元测试与模拟

模拟出的 EntityManager 实例直接注入到默认的可见域 PredictionAudit#em 中。在 savingSuccessfulPrediction 测试方法中(参见清单 9),创建了一个 Result 实例并将它传递给 onSuccessfulPrediction 方法,该方法创建 Prediction 实例并调用 EntityManager#persist(参见清单 3)。静态方法 verify(cut.em,times(1)).persist(expected) 验证是否使用所期望的参数仅调用过一次 EntityManager#persist 方法。

一个更复杂的模拟案例

EJB 3.1 OracleResource bean 的 javax.enterprise.event.Event 和 javax.enterprise.inject.Instance 这两个实例的注入对于测试来说更为有趣。将使用 Mockito#mock 方法再次执行 CDI 依赖项的初始化和模拟(参见清单 10)。

public class OracleResourceTest {

 private OracleResource cut;

 @Before

 public void initializeDependencies(){

 this.cut = new OracleResource();

 this.cut.company = mock(Instance.class);

 this.cut.eventListener = mock(Event.class);

 }

清单 10:CDI 依赖项的初始化和模拟

我们从 helper 方法 checkConsultantAvailability 的测试开始,该方法验证顾问的可用性。如果未找到 Consultant 实现,我们希望生成 IllegalStateException。静态方法 Mockito#when 记录 Mockito#mock 方法所返回实例期待的行为。我们只需为 Instance#isUnsatisfied 返回 true,并且期待一个 IllegalStateException:

 @Test(expected=IllegalStateException.class)

 public void checkConsultantAvailabilityWithoutConsultant(){

 when(this.cut.company.isUnsatisfied()).thenReturn(true);

 this.cut.checkConsultantAvailability();

 }

清单 11:预先记录模拟行为

如果 Consultant 给出一个很荒唐的预言,EJB bean OracleResource 将抛出一个 IllegalStateException。Consultant 接口的 Blogger 实现始终返回 JAVA_IS_DEAD,这将引发 IllegalStateException。为测试这一行为,我们必须模拟出 javax.enterprise.inject.Instance 返回的 Iterator:

 Iterator mockIterator(Consultant consultant) {

 Iterator iterator = mock(Iterator.class);

 when(iterator.next()).thenReturn(consultant);

 when(iterator.hasNext()).thenReturn(true);

 return iterator;

 }

清单 12:模拟出 Iterator

我们改变 Instance 的行为使其返回模拟的 Iterator 实例:

 @Test(expected=IllegalStateException.class)

 public void unreasonablePrediction(){

 Consultant consultant = new Blogger();

 Iterator iterator = mockIterator(consultant);

 when(this.cut.company.iterator()).thenReturn(iterator);

 this.cut.predictFutureOfJava();

 }

清单 13:返回模拟出的 Iterator

模拟出实例将使您可以控制实际使用哪个 Consultant 实现。在我们的案例中,我们可以仅使用 Blogger 实现来检查是否正确抛出了 IllegalStateException。您也可以创建一个 Consultant 模拟,返回这个模拟,而不是返回实际的 Blogger 实例。这种做法尤其适用于 Java EE 平台上具有很强依赖性的 Consultant 实现。

还模拟出了 Event 实例,这使您可以验证已执行的方法调用:

 @Test

 public void unreasonablePredictionFiresEvent(){

 Consultant consultant = new Blogger();

 Result expectedResultToFire = consultant.predictFutureOfJava();

 Iterator iterator = mockIterator(consultant);

 when(this.cut.company.iterator()).thenReturn(iterator);

 try{

 this.cut.predictFutureOfJava();

 }catch(IllegalStateException e){}

 verify(this.cut.eventListener,times(1)).fire(expectedResultToFire);

 }

清单 14:测试 Event#fire 调用

对失败案例的测试稍微麻烦一点。我们先接受 IllegalStateException,然后检查实际上是否调用了 Event#fire 方法。

与 Java SE 类似

Java EE 6 应用程序的单元测试与测试 Java Platform, Standard Edition (Java SE) 没有区别。Java EE 6 组件只是一些带批注的类。您无需以特殊的方式对待它们;而是应该重点关注业务逻辑的验证。

在容器内部测试所有内容是一种常见的错误做法。即使是 Java EE 6 容器也要花数秒钟的时间来启动和部署应用程序。在容器中测试业务逻辑不仅浪费时间,还会带来不必要的复杂性。在 Java EE 6 中,您应该始终将单元测试与集成测试分开。甚至应该分别执行单元测试和集成测试。使用模拟出的环境进行真正的单元测试快如闪电。PredictionAuditTest 和 OracleResourceTest 的执行甚至用不了半秒钟:

-------------------------------------------------------

 T E S T S

-------------------------------------------------------

Running com.abien.testing.oracle.boundary.OracleResourceTest

Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.254 sec

Running com.abien.testing.oracle.control.PredictionAuditTest

Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.025 sec

Results :

Tests run: 8, Failures: 0, Errors: 0, Skipped: 0

清单 15:测试结果

最常见错误的观点:您需接口来进行模拟

接口的引入往往是由模拟造成的。然而,现代框架根本不需要接口就可实现模拟。可以直接由传统 Java 对象 (POJO) 类方便地创建模拟。PredictionArchiveResource 使用直接注入的 PredictionAudit 类(参见清单 16)。

@Path("predictions")

@Stateless

public class PredictionArchiveResource {

 @EJB

 PredictionAudit audit;

 @GET

 @Produces(MediaType.APPLICATION_JSON)

 public List<Prediction> allPredictions(@DefaultValue("5") @QueryParam("max") int max) {

 List<Prediction> allPredictions = audit.allPredictions();

 if (allPredictions.size() <= max) {

 return allPredictions;

 } else {

 return allPredictions.subList(0, max);

 }

 }

}

清单 16:POJO 类注入

尽管 PredictionAudit 是一个类而不是一个接口,但也可以轻松地模拟它。为了测试返回列表的大小限制,对 PredictionAudit 类进行了全面模拟(参见清单 17)。

public class PredictionArchiveResourceTest { 

 PredictionArchiveResource cut; 

 @Before

 public void initialize(){

 this.cut = new PredictionArchiveResource();

 this.cut.audit = mock(PredictionAudit.class);

 }

 @Test

 public void allDecisionsWithMaxLesserReturn() throws Exception {

 int expectedSize = 2;

 List<Prediction> prediction = createDecisions(expectedSize);

 when(this.cut.audit.allPredictions()).thenReturn(prediction); 

 List<Prediction> allDecisions = this.cut.allPredictions(3);

 assertThat(allDecisions.size(), is(expectedSize));

 }

 @Test

 public void allDecisionsWithMaxGreaterReturn() throws Exception {

 int max = 5;

 int expected = 3;

 List<Prediction> prediction = createDecisions(max);

 when(this.cut.audit.allPredictions()).thenReturn(prediction); 

 List<Prediction> allDecisions = this.cut.allPredictions(expected);

 assertThat(allDecisions.size(), is(expected));

 }

 @Test

 public void allDecisionsWithMaxEqualReturn() throws Exception {

 //obvious code omitted

 }

 List<Prediction> createDecisions(final int nr) {

 return new ArrayList<Prediction>(){{

 for (int i = 0; i < nr; i++) {

 add(new Prediction(Result.BRIGHT, true));

 }

 }};

 }

}

清单 17:Java 类模拟

PredictionArchiveResource 类中唯一值得关注的业务逻辑就是列表大小的限制(参见清单 16)。QueryParameter 的最大值用于计算子列表的大小。PredictionArchiveResource 模拟实例允许返回任意大小的 List<Prediction>。无需访问 JPA 层(甚至数据库)就能控制列表的内容和大小将会极大简化测试。模拟还显著加快了测试执行速度。数据库访问需要几秒钟,而几毫秒就可完成对模拟的访问。

Java EE 6 使接口成为可选。您可以注入类(CDI 托管 bean 和 EJB bean),而不是接口,这丝毫不会牺牲任何“企业”特性。也完全不必为了模拟而使用接口。使用普通类(而非接口)作为一般的构建块,不仅可以使生产代码的实现更加精简,而且还能简化测试。您不必决定是模拟类还是接口。

总结

至此,我们仅测试了对象和方法的内部功能。为使测试尽可能简单,完全模拟出了周围的基础架构。我们的测试工作完全符合单元测试的定义:“在计算机编程中,单元测试是一种方法,使用这种方法对源代码的每个单元进行测试,以确定它们是否适用。单元是指应用程序最小的可测试部分。在过程编程中,单元可能是一个单独的函数或过程。在面向对象的编程中,单元通常是一个方法……”(http://en.wikipedia.org/wiki/Unit_testing)

尽管我们所有单元测试的结果均“成功”,但我们仍然不清楚应用程序是否可部署。JPA 2 映射错误、不一致的 persistence.xml 或 JPA 查询拼写错都根本无法使用也不应使用经典单元测试来测试。诸如 CDI 事件传递、复杂依赖注入或 JPA 查询之类与基础架构相关的逻辑只能使用集成测试在类似于生产的环境中进行测试。在后续文章中,我将探讨集成测试的方方面面。

另请参见

 


顾问兼作家 Adam Bien 是 Java EE 6 和 7、EJB 3.X、JAX-RS、CDI 和 JPA 2.X JSR 专家组成员。他从 JDK 1.0 就开始使用 Java 技术,并在几个大型项目中使用了 servlet/EJB 1.0,目前是 Java SE 和 Java EE 项目的架构师和开发人员。他编辑了多本关于 JavaFX、J2EE 和 Java EE 的图书,并且是《Real World Java EE Patterns—Rethinking Best Practices》《Real World Java EE Night Hacks—Dissecting the Business Tier》两本书的作者。Adam 还是 Java Champion 和 JavaOne 2009 Rock Star。