JMockit+JaCoCo トラブルあれこれ

JMockitはテストのために一部のクラスの定義を書き換えるライブラリ、JaCoCoはカバレッジ取得用のライブラリ、どちらも昨今のJava開発のユニットテストで(多分)よく使うものです。

Antで両方を使ったサンプルプロジェクトも用意してみました。 (ライブラリダウンロードのためにはじめてIvyも使ってみた)

https://github.com/knjname/jmockit-jacoco-javaagent-arg-order

JMockitもJaCoCo、どちらもinstrumentationという仕組みを用いてその機能を実現しています。

http://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html

instrumentationというのは、超簡単にいえば、Javaでロードするクラスの中身をロード前に書き換える仕組みです。

instrumentationを使用するには、下記のようにJVM起動時にinstrumentation用のクラスとマニフェストを内包したJARを指定してあげればOK。

java -javaagent:instrumentationに使うJARのパス ほかの引数...

上記のような指定で、-javaagent:~~ に指定されたJARのマニフェストにあるPre-Mainという項に書かれたクラスのpremainメソッドが起動し、そのメソッドの中でクラスの定義を書き換えるクラスがJVMに登録されます。

ま、ここらへんの詳しい仕組みはJMockitなど使う上ではどうでもいいです。

-javaagent複数指定できるので、たとえばJMockit、JaCoCo双方を使う場合は、jmockitのJAR、JaCoCoのJAR、どちらも指定してあげれば、どちらも有効になります。

java -javaagent jmockit.jar -javaagent jacocoagent.jar ほかの引数...

JMockit+JaCoCoがEclipse(EclEmma Java Code Coverage)で動かないよう!

上記解説でJVM起動時にjmockit.jarとjacocoagent.jarを両方指定しましたが、これには正しい指定順序(JMockit→JaCoCo)があり、これが逆(JaCoCo→JMockit)だと下記のようなエラーが出てしまいます。

java.lang.LinkageError: loader (instance of  sun/misc/Launcher$AppClassLoader): attempted  duplicate class definition for name: "knjname/AddRandomTest$1"
     at java.lang.ClassLoader.defineClass1(Native Method)
     at java.lang.ClassLoader.defineClass(ClassLoader.java:760)
     at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
     at java.net.URLClassLoader.defineClass(URLClassLoader.java:455)
     at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
     at java.net.URLClassLoader$1.run(URLClassLoader.java:367)
     at java.net.URLClassLoader$1.run(URLClassLoader.java:361)
     at java.security.AccessController.doPrivileged(Native Method)
     at java.net.URLClassLoader.findClass(URLClassLoader.java:360)
     at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
     at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
     at knjname.AddRandomTest.testAddRandom(AddRandomTest.java:16)
     at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
     at java.lang.reflect.Method.invoke(Method.java:483)
     at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.run(JUnit4TestReference.java:50)
     at org.eclipse.jdt.internal.junit.runner.TestExecution.run(TestExecution.java:38)
     at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:459)
     at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:675)
     at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:382)
     at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:192)

JaCoCoが先行してクラスロード部分に邪念を送ったことにより、JMockitのモック化のためのゴニョゴニョを邪魔してるんですね。

まあ、普通にAntで起動する場合は下記のようにjacocoのAntタスク使い、その場合はこの現象にあうことはないのですが

<jacoco:coverage destfile= "target/coverage.exec">
  <junit fork= "true">
    <jvmarg line= " -javaagent:lib/jmockit-1.11.jar " />
<!-- 以下略 -->

EclipseでEclEmmaとかいうJaCoCoでカバレッジを取得するプラグイン(http://www.eclemma.org/)を使って普通にJMockitを使用したテストを起動するとプラグインの動作で内部的にJaCoCoのJARが先行してしまい、上記のエラーが出てしまいます。

このエラーはEclipse上のカバレッジ取得アクション(Coverage As)起動時にプラグイン内部でたぶん -javaagent:jacocoのJAR を先頭に追記したせいで発生しているようで、JUnit起動構成のVM引数設定を編集して、-javaagentを追加したりなどしても無駄みたいです。

これを解決するには、JaCoCoの指定よりJMockitの指定が先行しさえすればいいので、

  • Eclipseの設定 -> Java -> Installed JREs から
  • JUnit用のJREを新規構成して
  • その構成のDefault VM argumentsに-javaagent:jmockitのJARのパス を追記し、(-javaagent:${workspace_loc}/プロジェクト名/lib/jmockit.jar のようにワークスペースまでの変数とかいれると便利)
  • その構成のJREJUnitコードのプロジェクトのJREライブラリに指定してあげれば

無事に動くと思います。

ちなみに私はこれに半日ハマっていました。酷い。

よその環境で何故かJMockitが動かないよう!

上記のEclipseとは別に、なんでかローカルで動いていたはずのJMockitさんが別Java環境で動かないことがあります。

java.lang.IllegalStateException : Native library for Attach API not available in this JRE
      at mockit.internal.startup.AgentLoader.getVirtualMachineImplementationFromEmbeddedOnes(AgentLoader.java:96)
      at mockit.internal.startup.AgentLoader.loadAgent(AgentLoader.java:50)
      at mockit.internal.startup.AgentInitialization.loadAgentFromLocalJarFile(AgentInitialization.java:29)
      at mockit.internal.startup.Startup.initializeIfPossible(Startup.java:212)
      at org.junit.runner.Runner.<clinit>( Runner.java:22)
      at org.junit.internal.builders.JUnit4Builder.runnerForClass(JUnit4Builder.java:10)
      at org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:59)
      at org.junit.internal.builders.AllDefaultPossibilitiesBuilder.runnerForClass(AllDefaultPossibilitiesBuilder.java:26)
      at org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:59)
      at org.junit.internal.requests.ClassRequest.getRunner(ClassRequest.java:26)
      at org.eclipse.jdt.internal.junit4.runner.JUnit4TestReference.<init>(JUnit4TestReference.java:33)
      at org.eclipse.jdt.internal.junit4.runner.JUnit4TestClassReference.<init>(JUnit4TestClassReference.java:25)
      at org.eclipse.jdt.internal.junit4.runner.JUnit4TestLoader.createTest(JUnit4TestLoader.java:48)
      at org.eclipse.jdt.internal.junit4.runner.JUnit4TestLoader.loadTests(JUnit4TestLoader.java:38)
      at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:444)
      at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.runTests(RemoteTestRunner.java:675)
      at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.run(RemoteTestRunner.java:382)
      at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:192)
Caused by: java.lang.UnsatisfiedLinkError: no attach in java.library.path
      at java.lang.ClassLoader.loadLibrary(Unknown Source)
      at java.lang.Runtime.loadLibrary0(Unknown Source)
      at java.lang.System.loadLibrary(Unknown Source)
      at sun.tools.attach.WindowsVirtualMachine.<clinit>(WindowsVirtualMachine.java:185)
      at sun.reflect.NativeConstructorAccessorImpl.newInstance0( Native Method)
      at sun.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
      at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
      at java.lang.reflect.Constructor.newInstance(Unknown Source)
      at mockit.internal.util.ConstructorReflection.invoke(ConstructorReflection.java:68)
      at mockit.internal.util.ConstructorReflection.newInstance(ConstructorReflection.java:37)
      at mockit.internal.startup.AgentLoader.getVirtualMachineImplementationFromEmbeddedOnes(AgentLoader.java:92)
      ... 17 more

これは、前の例で示していたような -javaagent:jmockitのJAR を指定しないと発生することがあります。

-javaagent:の指定がないし、今ここでそれ相当のを動的追加しよっか、ってことでAttach APIというのを探して使おうとして、なかったのでエラーということみたいです。

でもこれ確か最近のJavaじゃ指定しなくていいってマニュアルに書いてたはずじゃ?

Depending on your development environment, you may have to do one other thing:

  • If you are developing on JDK 1.5, then make sure that -javaagent:jmockit.jar (with the proper absolute or relative path tojmockit.jar) is passed as an initialization parameter to the JVM when running tests. This standard JVM initialization parameter causes it to load on start-up the "Java agent" that JMockit uses internally for bytecode instrumentation; this is required to work in all standard JVMs since version 1.5, in all OSs. You may have to use this parameter even on a newer JDK 1.6+, if its Attach API implementation is not supported by JMockit: such is the case with the IBM J9 JDK 1.6, the Mac OS X JDKs, and with JDKs for the Solaris OS.

  • If you use TestNG in a JDK 1.6+ environment, JMockit can be initialized in one of three possible ways (apart from use of "-javaagent" indicated above, which can also be used). See this page for details.

When using a HotSpot JDK 1.6+ or a JRockit JDK 1.6+ on Windows or Linux, neither of the extra steps above is necessary.

http://jmockit.googlecode.com/svn-history/r1166/trunk/www/installation.html

はい。最後。

When using a HotSpot JDK 1.6+ or a JRockit JDK 1.6+ on Windows or Linux, neither of the extra steps above is necessary.

(超訳:JDK1.6以上は上記の追加手順はどちらも不要。)

と、こういうことなのですが、これが適用可能なのは厳密にいうと、Attach API(http://docs.oracle.com/javase/8/docs/technotes/guides/attach/index.html)というものが使える環境限定の話らしく、JREJavaでは使えませんし、JDKを指定したつもりでもなんか使えない場合もありました。(Jenkins上とか。なんでなんだろう?)

Attach APIが利用可能であるには、具体的にはjavaが attach.dll (Windows) / attach.so (Linux) をライブラリとして参照可能である必要があるみたいです。

Attach APIに頼らずに無難にどこの環境でもJMockitを使えるようにするには -javaagent:JMockitのJAR は付けておいて損はないと思います。ビルドスクリプトとか。

これも私はハマってました。

っていうか、Attach APIって何?

-javaagent: 相当のAgentClassというやつをVM起動後に動的に追加できることもできるAPIと覚えればOK。出自はJava6から。

http://docs.oracle.com/javase/8/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html

VirtualMachineクラスのloadAgentメソッドにAgent-Classの指定があるJARのパスを渡せば、動的に -javaagent: 相当のクラス定義変換をVMに組み込むことができます。

JMockitみたいなのを作るには、こういうAPIを使って、あらゆるクラスについて好きなタイミングで、挙動を変えられるアスペクト的なバイトコードを全クラスに仕込むようにしておけば作れそうですね。

ほかJMockitの罠

  • クラスパス上、JMockitのJARがJUnitのJARより先行しなければならない。
  • EclipseJUnitプラグインJUnitだと動かないかも(JUnitは明示的に持ってくる派なので遭遇したことはないが
  • junitはfork="true"じゃないとJavaAgent指定する系は動かない。

まあ、一度動けばJMockitもJaCoCoも便利ですよ。

2014-09-01 追記

最近のマニュアル(←まずそっち読めよ)だと

JMockit - Tutorial - Running tests

To run tests that use any of the JMockit APIs, use your Java IDE, Ant/Maven script, etc. the way you normally would. In principle, any JDK of version 1.6 or newer, on Windows, Mac OS X, or Linux, can be used. JMockit supports (and requires) the use of JUnit or TestNG; details specific to each of these test frameworks are as follows:

For JUnit 4.5+ test suites, make sure that jmockit.jar appears before the JUnit jar in the classpath. Alternatively, annotate test classes with @RunWith(JMockit.class).

(Note for Eclipse users: when specifying the order of jars in the classpath, make sure to use the "Order and Export" tab of the "Java Build Path" window. Also, make sure the Eclipse project uses the JRE from a JDK installation instead of a "plain" JRE, since the latter lacks the "attach" native library.)

For TestNG 6.2+ test suites, simply add jmockit.jar to the classpath (at any position).

と書いてあるみたいで、Java6以降使ってね、ってのとjarの指定がアレならRunWithでも動くし、JREを使った場合はAttach APIがないから動かないよと書いてますね。

僕としては何かトラブルがあったら、-javaagent を指定することを薦めたい。