概要
Spring Boot と Jersey を連携させたアプリケーションにおいて、API テストを実施する方法を調べました。
Spring Boot と連携していない Jersey 単体での API テスト方法については、そこそこ情報が出てきます。
- JerseyTest で Servlet のリソースを扱う JAX-RS 部品のユニットテストを行う方法
- Exploring the Jersey Test Framework
- Jersey Test + MockitoでJAX-RSリソースクラスの単体テスト
- JAX-RSのユニットテストまわり
- Test Jersey Rest Service with JUnit
ところがSpring Boot と Jersey を構成した場合の情報は極端に少なく、クセがとても強いと感じました。
API テスト実現までとても時間がかかってしまったので、備忘録として残しておきます。
テストするアプリケーションの構成
テスト対象のアプリケーションは、リソースクラスがインターフェースと実装クラスで分離しています。
実装クラスには、Spring のサービスクラスと HttpServletRequest のコンテキスト情報が Injection されます。
@Path("/users")
public interface UserResource {
@Path("/add/")
@POST
@Consumes(MediaType.APPLICATION_JSON)
void register(User user);
}
実装クラスです。1
@Component
public class UserResourceImpl implements UserResource {
@Context
private HttpServletRequest request; // this is context of http.
@Inject
private UserService userService; // this is application service
/**
* this method requires json format user information
*/
@Override
public Response register(User user) {
userService.register(user);
return Response.noContent();
}
}
テストクラス作成
テストクラス作成のためにはいくつかのステップが必要です。
依存性の追記
SPRING INITIALIZR で Web と Jersey を選択して、maven プロジェクトの雛形をダウンロードします。
しかし、これだけでは API テストは正常に動作しません。Jersey のテストフレームワークとしてデファクトスタンダードであると思われる Grizzly の依存関係をつけます。
<properties>
<jersey.version>2.27</jersey.version>
</properties>
<dependencies>
<!-- this is test class dependency -->
<dependency>
<groupId>org.glassfish.jersey.test-framework</groupId>
<artifactId>jersey-test-framework-core</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.test-framework.providers</groupId>
<artifactId>jersey-test-framework-provider-grizzly2</artifactId>
<version>${jersey.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
JerseyTest の利用
Jersey にて API テストを実施するためには、上記のリンク先にあるように、JerseyTest を利用します。このクラスの利用方法が API テスト実施において、実装が最も重要になります。
ポイントごとに番号を降って解説します。
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.val;
import com.kasakaid.MyFilter;
import org.glassfish.jersey.client.JerseyClientBuilder;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.test.DeploymentContext;
import org.glassfish.jersey.test.JerseyTest;
import org.glassfish.jersey.test.ServletDeploymentContext;
import org.glassfish.jersey.test.grizzly.GrizzlyWebTestContainerFactory;
import org.glassfish.jersey.test.spi.TestContainerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import javax.ws.rs.client.Client;
import javax.ws.rs.client.WebTarget;
// ①
@Component
public class JerseyTestResource {
private boolean initialized = false;
@Getter
private JerseyTest jerseyTest;
ApplicationContext applicationContext;
public JerseyTestResource(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
this.jerseyTest = new JerseyTest() {
@Override
protected ResourceConfig configure() {
val resource = new ResourceConfig();
return resource
// ②
.register(UserResourceImpl.class)
// ③
.property("contextConfig", applicationContext);
}
// ④
@Override
protected ServletDeploymentContext configureDeployment() {
return ServletDeploymentContext
.forServlet(new ServletContainer(configure()))
.addFilter(MyFilter.class, MyFilter.class.getSimpleName())
.build();
}
// ⑤
@Override
protected TestContainerFactory getTestContainerFactory() throws TestContainerException {
return new GrizzlyWebTestContainerFactory();
}
// ⑥
// @Override
// protected Client getClient() {
// return JerseyClientBuilder.createClient();
// }
};
}
// ⑦
@SneakyThrows
public void setUp() {
if (!initialized) {
jerseyTest.setUp();
initialized = true;
}
}
public WebTarget target(String path) {
return this.jerseyTest.target(path);
}
}
① JerseyTest をラップするクラスを用意する
JerseyTest を利用する際、configure メソッドをオーバーライドします。このメソッドで、「どのクラスがリソースクラスであるか」を登録します。
上記の実装では、JerseyTest を継承するクラスを用意せず、無名クラスで configure メソッドをオーバーライドしています。なぜこのような実装をしているかというと、configure メソッド内で Spring の DI コンテナを格納している ApplicationContext にアクセスする必要があるためです。
後述のとおり、ApplicationContext は JerseyTest において必要なのですが、JerseyTest を継承して applicationContext にアクセスしようとすると、configure メソッドが呼ばれる際、applicationContext は null になっています。
@Component
public class CustomJerseyTest extends JerseyTest {
ApplicationContext applicationContext;
public CustomJerseyTest(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
protected ResourceConfig configure() {
ResourceConfig resource = new ResourceConfig();
return resource
.register(UserResourceImpl.class)
.property("contextConfig", applicationContext);
}
}
というのも、上記実装ではコンストラクタで applicationContext が inject されると、明示的にコールしていないにも関わらず、真っ先に、JerseyTest のコンストラクタ (親クラスのコンストラクタ) が起動するためです。このコンストラクタで configure メソッドが呼ばれますが、このタイミングではメンバー変数の applicationContext には参照が渡っていません。
これを回避するため、AnnotationConfigApplicationContext を新規生成する方法がありますが、このインスタンスを生成するとアプリケーションのクラスにスキャンが走るためインスタンス生成までに余分な時間がかかってしまいます。
これらの回避策として、JerseyTest をラップするクラスを用意して、このクラスで ApplicationContext が Inject された後に、configure メソッドがコールされるようにします。
② リソースの実装クラスを登録する
リソースクラスのインターフェースと実装が分離している場合、直感的には、インターフェースクラスを登録すればよいと考えます。ところが、インターフェースクラスの登録では、@Inject で指定しているアプリケーションサービスクラスは Inject されるものの、@Context で指定している HttpServletRequest は Inject されません。この動作の意味はよくわかりませんが、Context は Spring ではなく Grizzly テストコンテナが管理するのでこのようなことになるのかもしれません。
③ ApplicationContext をプロパティに渡す
ResourceConfig のプロパティ、contextConfig に applicationContext のインスタンスを渡します。このインスタンスが渡って来ないと、Spring の DI が実現できません。どうやら、JerseyTest はこのキー名で DI コンテナの情報を取得しているようです。
④ configureDeployment をオーバーライドする (オプション)
このページ などで、サーブレットコンテナを利用するためにカスタマイズが必要とあります。
色々と試行錯誤しながら JerseyTest の動作の理解を深めたところ、上述の記述で動作しました。javax.servlet.Filter インターフェースの実装は、サーブレットコンテナにて動作します。JerseyTest ではサーブレットコンテナの機能が含まれていないため、アプリケーションのフィルターは動作しないようです。アプリケーションでフィルターを使用しており、テストに必要な場合該当のフィルターを登録できます。
⑤ GrizzlyWebTestContainerFactory を使用する (オプション)
GrizzlyWebTestContainerFactory を使用すると複雑なサーブレットベースのテストが実施できると [公式ページ] (https://jersey.github.io/documentation/latest/test-framework.html) にて記述されています。
Jersey provides 2 different test container factories based on Grizzly. The GrizzlyTestContainerFactory creates a container that can run as a light-weight, plain HTTP container. Almost all Jersey tests are using Grizzly HTTP test container factory. Second factory is GrizzlyWebTestContainerFactory that is Servlet-based and supports Servlet deployment context for tested applications. This factory can be useful when testing more complex Servlet-based application deployments.
④のオーバーライドなしで、このコンテナを使うと、DeploymentContext がないというような例外が発生します。
④にて ServletDeploymentContext を生成すると正常に動作するようになりました。フィルター機能を使用する場合は、このコンテナを使用します。
2020/03/27 追記
注意
Spring Boot 2.x の Web を Jersey にした上で、様々な依存関係がある状態のアプリケーションの JUnit 上の Web コンテナーを GrizzlyWebTestContainerFactory にしたところ、下記のエラーが出ました。
Caused by: org.glassfish.hk2.api.UnsatisfiedDependencyException: There was no object available for injection at SystemInjecteeImpl(requiredType=MyService,parent=MyFilter,qualifiers={},position=-1,optional=false,self=false,unqualified=null,1015706015)
at org.jvnet.hk2.internal.ThreeThirtyResolver.resolve(ThreeThirtyResolver.java:75)
JUnit 上の Jersey のリソース に MyFilter を Jersey のリソースとして登録しているのですが、エラーメッセージから、MyService の依存関係が解決できないことがわかります。
TestContainerFactory では Spring Boot が適切に解決してくれるので、MyService が正常に依存関係が注入されます。やり方がほかにあるのかもしれませんが、それを探すコスト、実現可能性、得られるメリットを考慮して、コストが高くつきすぎると判断しました。いずれにしても、GrizzlyWebTestContainerFactory 途端使えなくなるのは、使い勝手が悪すぎます。Spring Boot を利用しているのであれば、GrizzlyWebTestContainerFactory は使わない方が良いと感じています。
⑥ getClient メソッドのオーバーライド
このメソッドをオーバーライドせずに、Entity を指定してリクエストすると Json にパースできずに、下記のエラーが発生しました。
javax.ws.rs.ProcessingException: RESTEASY004655: Unable to invoke request
Caused by: javax.ws.rs.ProcessingException: RESTEASY003215: could not find writer for content-type application/json type: User
at org.jboss.resteasy.core.interception.ClientWriterInterceptorContext.throwWriterNotFoundException(ClientWriterInterceptorContext.java:38)
....
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit4.SpringRunner;
import org.junit.Test;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
@SpringBootTest(classes = {BasePackageClass.class, TestConfig.class})
@RunWith(SpringRunner.class)
@ActiveProfiles("test")
public UserResourceTest {
@Autowired
private JerseyTestResource testJersey;
@Test
public void testUserResource() {
User user = new User(some, user, information, params);
WebTarget webTarget = testJersey.target("/resources/user/add");
webTarget.request().accept(MediaType.APPLICATION_JSON).post(Entity.entity(user, MediaType.APPLICATION_JSON_TYPE));
//---> RESTEASY003215 parse error
}
}
RESTEasyのバージョンが古い とか パーサーの依存性が足りていない だとかの情報が出てきましたが、この依存性を追加するだけでは解決しませんでした。そこで、JerseyClient を戻すよう getClient をオーバーライドしたところ、パーサーのエラーが解消しました。
2019/3/16追記
しかしながら、パーサーのエラーについては、そもそもとして、 __RESTEasy への依存関係が存在してしている__ことが根本的な問題だったようです。私の環境では、JAX-RS の実装を RESTEasy から、Spring Boot がサポートしている JAX-RS の実装である Jersey へと変更しました。ただし、アプリケーションの実装が一部 RESTEasy のクラスに依存してしまっているため、RESTEasy の依存をすべて残していたのです。RESTEasy の依存がある状態でも、実際に動作する JAX-RS の実装は Jersey になりましたが、JerseyTest の getClient が実行された際に取得される Client (リクエストを実行するためのインターフェース) の実態が org.jboss.resteasy.client.jaxrs.ResteasyClient となっておりました。
JerseyTest#getClient
本来 JerseyClient が戻されるべきであるにも関わらず、ResteasyClient が戻されているため、パーサーのエラーが発生しているのではないかと思います。
また、RESTEasy の依存がある場合、ClientBuilder.newBuilder にて返されるインスタンスも ResteasyClient になりました。両者の内部動作は異なりますが、結果が同じであることを鑑みると、JAX-RS の実装への依存が 2 つ以上あると予期せぬ動作になる可能性があると考えられます。
RESTEasy の依存関係をなくすと、取得される Client は、org.glassfish.jersey.client.JerseyClient に変わりました。
想定どおりの状態となったので、この状態であれば、JerseyClient を戻すよう getClient をオーバーライドせずとも正常にテストを実施できます。
2019/6/16 追記
上記のとおり、Client を JerseyClient に変更することで正常にテストを完了させることができるようになりましたが、ここのページ で記述した通り、Jsr310 Date and Time API のメンバーが含まれたレスポンスがある場合、規定の状態ではうまくテストを正常終了させることができませんでした。LocalDateTime などのメンバーがあるレスポンスを返す場合、テスト用にカスタマイズする必要があるようです。
⑦ Jersey のインメモリサーバーを起動
JerseyTest からのリクエストを処理するためのインメモリサーバーを JerseyTest#setUp メソッドにて起動します。テストクラスの @Before などで実行しておけばテスト時にサーバーが起動した状態になっています。
念の為、サーバーが起動していない時だけ setUp メソッドがコールされるようにしておきます。
サーバーはポート 9998 を解放して起動しますが、テストの実装者はこれを意識する必要はありません。
21:56:36.257 [main] INFO o.g.j.t.g.GrizzlyTestContainerFactory$GrizzlyTestContainer - Creating GrizzlyTestContainer configured at the base URI http://localhost:9998/
また、テストの実装者は Spring Boot にてコンテキストパスを application.properties の server.servlet.context-path にて指定している場合においても、特に意識する必要はありません。WebTarget に指定するパスは、Resource のパスだけになります。
所感
上記の通り、非常に複雑なテストのための設定を要求されて非常に無駄な実装が必要になりました。Spring MVC を利用すれば、MockMVC を利用 でき、こんなトラブルにはまることもありません。
不便な点がいくつかあったので列挙しておきます。
明示的なインスタンスの登録
テストでもそうなのですが、通常の Jersey の利用でも、Spring MVC を利用するよりもはるかに煩雑なことを要求されると思います。例えば、リソースクラスの登録は、明示的に行わなければなりません。
import org.glassfish.jersey.server.ResourceConfig;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import javax.ws.rs.ApplicationPath;
@Component
@Configuration
@ApplicationPath("/resources")
public class JerseyConfig extends ResourceConfig {
public JerseyConfig() {
register(UserResource.class);
}
}
Resource が少なければ問題は少ないのですが、複数ある場合、この register の実装が複数必要になります。
そこで、 packages("hoge.fuga") とパッケージ名で登録する方法がありますが、この方法を使うと、なんと、IDE で動かす時は問題がないのですが、war ファイルを java コマンドで起動するとWEB-INF 配下に classes が存在しない、というエラーが発生してアプリケーション起動に失敗してしまいます。
java -Dspring.profiles.active=development -jar myApp.war
Caused by: java.io.FileNotFoundException: /Users/kasakaid/dev/java/app/target/myApp.war!/WEB-INF/classes (No such file or directory)
at java.io.FileInputStream.open0(Native Method) ~[na:1.8.0_131]
at java.io.FileInputStream.open(FileInputStream.java:195) ~[na:1.8.0_131]
at java.io.FileInputStream.<init>(FileInputStream.java:138) ~[na:1.8.0_131]
at java.io.FileInputStream.<init>(FileInputStream.java:93) ~[na:1.8.0_131]
at sun.net.www.protocol.file.FileURLConnection.connect(FileURLConnection.java:90) ~[na:1.8.0_131]
at sun.net.www.protocol.file.FileURLConnection.getInputStream(FileURLConnection.java:188) ~[na:1.8.0_131]
at java.net.URL.openStream(URL.java:1045) ~[na:1.8.0_131]
at org.glassfish.jersey.server.internal.scanning.JarZipSchemeResourceFinderFactory.getInputStream(JarZipSchemeResourceFinderFactory.java:178) ~[jersey-server-2.27.jar!/:na]
at org.glassfish.jersey.server.internal.scanning.JarZipSchemeResourceFinderFactory.create(JarZipSchemeResourceFinderFactory.java:88) ~[jersey-server-2.27.jar!/:na]
... 84 common frames omitted
そのため、Reflections などを使用して、動的にパッケージ名からリソースを登録する仕組みを自前で用意する必要があります。
もちろん、クラスが増えるごとに register をする方法もありですが、リソース追加ごとに ResourceConfig を register するのは面倒ですので、、、
public class ClassAnnotationPair {
public String packageName;
public String className;
public Class<? extends Annotation> annotation;
private ClassAnnotationPair(Class<?> clz) {
this.packageName = clz.getPackage().getName();
this.className = clz.getName();
}
public ClassAnnotationPair(Class<?> clz, Class<? extends Annotation> annotation) {
this(clz);
this.annotation = annotation;
}
}
import lombok.SneakyThrows;
import org.glassfish.jersey.server.ResourceConfig;
import org.reflections.Reflections;
public abstract class JerseyConfigRegister {
@SneakyThrows
public ResourceConfig register(ResourceConfig jerseyConfig, ClassAnnotationPair annotationPair) {
Reflections reflections = new Reflections(annotationPair.packageName);
reflections.getTypesAnnotatedWith(annotationPair.annotation)
.forEach((clazz) -> {
if (isTargetClass(new ClassAnnotationPair(clazz, annotationPair.annotation))) {
jerseyConfig.register(clazz);
}
}
);
return jerseyConfig;
}
/**
* 登録対象のクラスとするか判断します。
* @param
* @return
*/
protected abstract boolean isTargetClass(ClassAnnotationPair annotationPair);
}
Spring MVC だともちろんこんなこと考えなくて良いですね。@RestController を設定すれば終わりです。
このような苦労を強いられ、かつナレッジも多くないので、特別な理由がなければ、Jax-RS ではなく、Spring MVC を選択すべきだと思います。
Spring MVC を選択する以上、Java の標準仕様である CDI を DI コンテナとして使用することはデメリットしかないように思われます。
Java の標準仕様より Spring Boot の方がはるかにかゆいところに手が届くことを実感しました。
GrizzlyTestContainer と main でトランザクションが別れる
通常、単体テストのスレッドは、[main] とい名前でログに記録されます。
一方、JerseyTest の post メソッドなどから起動したプロセスは、[grizzly-http-server-0] というプロセス名でログが記録されます。
15:24:24.161 [main] INFO jdbc.resultset - 8. ResultSet.wasNull() returned false
15:24:38.409 [grizzly-http-server-0] INFO j.c.s.c.c.u.Interceptor - [Started ]
この 2 つのプロセスは、なんとトランザクションが別物になります。
そのため、[main] で登録したテストデータを [grizzly-http-server-0] で利用するためには、@Transaction でついたメソッドで一つの処理をまとめてはいけません。
@Autowired
UserRepository userRepository;
*/
@Test
@Transactional
public void NG_一メソッドでデータ投入を行う() {
User user = new User(some, user, info);
userRepository.save(testData);
Response result = testJersey.target("/resources/users/100").request().accept(MediaType.APPLICATION_JSON).post(Entity.entity(user, MediaType.APPLICATION_JSON_TYPE));
assertThat(response.getStatus(), is(HttpStatus.SC_OK);
User user = result.readEntity(User.class);
assertNotNull(user);
}
上記の場合だと、@Transactional のスコープが、NG_テスト一メソッドでデータ投入を行う全体にかかるので、jerseyTest の post 時にはテストデータがコミットされていません。userRepository の save メソッドは、[NG_テスト一メソッドでデータ投入を行う] テストが終了してからコミットされるので、JerseyTest の post のスレッド ([grizzly-http-server-0]) からはデータが見えません。JerseyTest の結果は、404 Not Found になります。そのため、テストデータを投入する際は、テストデータ投入メソッドは、別途メソッドに切り出すなどして、コミットする必要があります。
@Autowired
UserRepository userRepository;
*/
@Transactional
private void テストデータ投入() {
boolean result = false;
User user = new User(some, user, info);
userRepository.save(testData);
}
@Test
public void OK_データ投入メソッドは分割してテストする() {
テストデータ投入();
Response result = testJersey.target("/resources/users/100").request().accept(MediaType.APPLICATION_JSON).post(Entity.entity(user, MediaType.APPLICATION_JSON_TYPE));
assertThat(response.getStatus(), is(HttpStatus.SC_OK);
User user = result.readEntity(User.class);
assertNotNull(user);
}
ポイントは、テストメソッドにトランザクションが存在していないことです。これで [テストデータ投入] が終了したらトランザクションがコミットされ、JerseyTest の post 時にデータが取得できるようになります。
本来的にはこれがトランザクションに対して正しいスタンスなのかもしれません。
しかしながら、ここでやりたいことはあくまでテストデータを投入してそれが取得できるか、ということですので、このようにトランザクションが分離されても煩雑になってしまうだけです。
Spring MVC での API テストではこんな面倒なことを意識したことがないので、この辺についても Spring MVC に分があるなと思った次第です。
インターフェースのバインドを手動で行う
コンテナを使う時に、メンバー変数をインターフェースとして集約して、実装を切り替えることができるようにする場合があります。
@Service
public class MyResourceImpl implements MyResource {
private IAmInterface iamInterface;
}
public interface IAmInterface {
String doSomething();
}
Spring MVC にてリクエストを投げると、MyResource の IAmInterface には、@Componennt がついたクラスがバインドされます。ところが、Jersey ではデフォルトではバインドしてくれません。
JerseyTest の configure メソッドに「このクラス」 は 「このインターフェース」 に 「シングルトン」 で バインドします、という定義が必要なのです。
this.jerseyTest = new JerseyTest() {
@Override
protected ResourceConfig configure() {
val resource = new ResourceConfig(MyResourceImpl.class);
resource.register(new AbstractBinder() {
/**
* Jersey の API テストだと Interface を定義するとうまく inject してくれない。
*/
@Override
protected void configure() {
bind(IAmImplemented.class).to(IAmInterface.class).in(Singleton.class);
}
});
return resource
.property("contextConfig", applicationContext);
}
};
}
まだあるかもしれませんが、この動作についても不便です。
-
実装が複数あるわけでもなく、テストがしやすくなるわけでもないので、インターフェースと実装を分ける意味は皆無ですが、既存のアプリケーションがこの構成であったのでやむをえず追従します。 ↩