Spring BootアプリのテストをSpockで書く
Spring BootアプリケーションのテストをSpockで書く方法を説明します。最近のバージョンを対象にしています。
- Spring Boot 1.4
- Spock 1.1-rc-3
- Groovy 2.4
本稿では以下のテストレベルを対象とします。
まずは、build.gradleに依存関係を追加しておきます。
dependencies { testCompile 'org.springframework.boot:spring-boot-starter-test' testCompile 'org.spockframework:spock-core:1.1-groovy-2.4-rc-3' testCompile 'org.spockframework:spock-spring:1.1-groovy-2.4-rc-3' testRuntime 'cglib:cglib-nodep:3.2.4' }
コンポーネントテスト
このテストレベルでは、テスト対象の依存コンポーネントをモックに差し替えた状態でテストを行います。
SpockのSpecificationでDIコンテナを利用するには、以下のように@SpringBootTest
アノテーションを付加します。
@SpringBootTest(webEnvironment = NONE) class HelloServiceSpec extends Specification { }
これにより@Autowired
でSpringがコンポーネントを注入してくれるようになります。Web Environmentは不要なのでNONE
を指定しています。
モックを利用するには@TestConfiguration
を定義します。以下のようにDetachedMockFactory#Mock()
メソッドでモックオブジェクトを注入することを宣言します。SpockのMock()
記法は使えないので注意が必要です。
// Specificationのインナークラス @TestConfiguration static class MockConfig { final detachedMockFactory = new DetachedMockFactory() @Bean ExternalApiClient externalApiClient() { detachedMockFactory.Mock(ExternalApiClient) } }
あとは、Spockのお作法に従ってgivenブロックの中でモックのインタラクションを宣言します。
given: 1 * client.getDefault() >> new Hello('world')
E2Eテスト
このテストレベルでは、テスト対象のAPIに対してHTTPリクエストを投げてレスポンスを検証します。
@SpringBootTest
アノテーションにwebEnvironment = RANDOM_PORT
を指定することで、実際にアプリケーションサーバが起動した状態でテストを実行できます。
@SpringBootTest(webEnvironment = RANDOM_PORT)
テスト対象にRESTリクエストを送るにはTestRestTemplate
クラスを利用します。
@Autowired TestRestTemplate restTemplate
RestTemplateのお作法に従ってリクエストを送り、レスポンスを検証します。
when: def entity = restTemplate.getForEntity('/hello', Hello) then: entity.statusCode == HttpStatus.OK entity.body.name == 'world'
この場合も`DetachedMockFactory#Mock()
で依存コンポーネントをモックに差し替えることが可能です。
まとめ
@SpringBootTest
アノテーションでDIコンテナを使ってテストを実行する方法を説明しました。また、DetachedMockFactory#Mock()
メソッドで依存コンポーネントをモックに差し替える方法を説明しました。TestRestTemplate
クラスを使うとテスト対象APIにRESTリクエストを送信できます。
GitHubにサンプルプロジェクトを置いているので参考にどうぞ。
FeignでOAuth 2.0クライアントを使う
RESTクライアントのSpring Cloud Netflix FeignでOAuth 2.0を使う方法を説明します。
@FeignClient
のconfiguration
でOAuth2FeignRequestInterceptor
を設定する。OAuth2FeignRequestInterceptor
にクライアントIDやクライアントクレデンシャルを渡す。
具体的な設定
@EnableOAuth2Client
アノテーションを付けます。
@EnableOAuth2Client @EnableFeignClients @SpringBootApplication class App { static void main(String[] args) { SpringApplication.run(App, args) } }
@FeignClient
アノテーションにConfigurationクラスを指定します。
@FeignClient(name = 'hello', url = '${hello.service.url}', configuration = HelloClientConfiguration) interface HelloClient { }
ConfigurationクラスでOAuth2FeignRequestInterceptor
を設定します。OAuth2FeignRequestInterceptor
クラスはspring-cloud-securityに含まれるクラスで、OAuth 2.0アクセストークンの取得や利用をまるっとお任せできます。
@Configuration class HelloClientConfiguration { @Value('${security.oauth2.hello.access-token-uri}') private String accessTokenUri @Value('${security.oauth2.hello.client-id}') private String clientId @Value('${security.oauth2.hello.client-secret}') private String clientSecret @Value('${security.oauth2.hello.scope}') private String scope @Bean RequestInterceptor oauth2FeignRequestInterceptor(OAuth2ClientContext oAuth2ClientContext) { new OAuth2FeignRequestInterceptor(oAuth2ClientContext, resource()) } OAuth2ProtectedResourceDetails resource() { def details = new ClientCredentialsResourceDetails() details.accessTokenUri = accessTokenUri details.clientId = clientId details.clientSecret = clientSecret details.scope = [scope] details } @Bean Logger.Level feignLoggerLevel() { Logger.Level.BASIC } }
application.ymlを設定します。
logging.level: # アクセストークンリクエストのログを有効にします org.springframework.web.client: DEBUG # Feignのリクエスト/レスポンスログを有効にします example.client.HelloClient: DEBUG hello.service: url: http://localhost:8081 # Hystrixを無効化またはSEMAPHOREに変更する必要がある(後述) feign.hystrix.enabled: false security: oauth2: hello: access-token-uri: http://localhost:8081/oauth/token client-id: theId client-secret: theSecret scope: theScope # (参考) Twitter APIの場合はこんな感じ twitter: access-token-uri: https://api.twitter.com/oauth2/token
既知の問題
アクセストークンはセッション単位で管理する必要があるため、OAuth2ClientContextはセッションスコープで管理されます(OAuth2ClientContextConfiguration)。これにより、以下の問題があります。
まず、複数のFeignClientを利用すると同一のアクセストークンを利用してしまう問題があります。例えば、TwitterClientとFacebookClientがあって先にTwitterClientを実行した場合、OAuth2ClientContextにTwitter APIのアクセストークンが保存されてしまうため、Facebook APIはエラーになります。自分でセッションスコープのBeanを定義すれば解決しそうですが、まだ試していません。解決策を知っていたら教えてください。
また、FeignとHystrixを組み合わせて使えない問題があります。これはHystrixが別スレッドでHTTPリクエストを実行するため、セッションスコープのOAuth2ClientContextを参照できないエラーが発生するためです。Hystrixを無効化するか、SEMAPHOREモードに切り替える必要があります。(参考: Spring @FeignClient with OAuth2FeignRequestInterceptor not working - Stack Overflow)
実行時にOAuth2ProtectedResourceDetailsを切り替える
上記の実装ではDIコンテナの起動時にOAuth2ProtectedResourceDetailsが生成されるため、複数のOAuth2ProtectedResourceDetailsを動的に切り替えるには工夫が必要です。例えば、ログイン状態に応じてClient Credentials GrantとResource Owner Password Grantを使い分けたいといった場合があります。
実行時にOAuth2ProtectedResourceDetailsを切り替えたい場合、FeignのRequestInterceptorを自分で実装します。
@Slf4j class MultiGrantOAuth2FeignRequestInterceptor implements RequestInterceptor { //@Valueは省略 @Inject private OAuth2ClientContext oAuth2ClientContext @Override void apply(RequestTemplate template) { new OAuth2FeignRequestInterceptor(oAuth2ClientContext, resource()).apply(template) } OAuth2ProtectedResourceDetails resource() { def authentication = SecurityContextHolder.context.authentication if (!authentication || authentication instanceof AnonymousAuthenticationToken) { // 未ログインの場合はClient Credentials Grantを使う def details = new ClientCredentialsResourceDetails() details.accessTokenUri = accessTokenUri details.clientId = clientId details.clientSecret = clientSecret details.scope = [scope] log.debug("ClientCredentialsResourceDetails") details } else { // ログイン済みの場合はResource Owner Password Grantを使う def details = new ResourceOwnerPasswordResourceDetails() details.accessTokenUri = accessTokenUri details.clientId = clientId details.clientSecret = clientSecret details.scope = [scope] // ユーザ情報からユーザ名を取得 details.username = (authentication.principal as UserDetails).username details.password = 'theResourceOwnerPassword' // TODO: 固定値ではなく動的に設定 log.debug("ResourceOwnerPasswordResourceDetails: username=${details.username}") details } } }
とりあえず、簡単に試すには以下のようなWebSecurityConfigを用意するとよいでしょう。
@Configuration @EnableWebSecurity class AppSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) { // ログイン後にセッションを再作成する http.sessionManagement().sessionFixation().newSession() // 未ログインでもアクセスできるようにする http.authorizeRequests().anyRequest().permitAll() // フォームログインを有効にする http.formLogin() } }
注意点ですが、未ログイン時に取得したアクセストークンはClient Credentials Grantのものなので、ログイン後にResource Owner Password Grantのものを再取得する必要があります。そのため、sessionFixation().newSession()
でログイン後にセッションを再作成するように設定しています。
この実装では以下のような挙動になります。
- http://localhost:8082/hello にアクセスすると、Client CredentialsのクライアントIDが表示される
- http://localhost:8082/login にアクセスしてフォームでログインする
- http://localhost:8082/hello にアクセスすると、リソースオーナーのユーザ名が表示される
下記にサンプルがあるので参考までにどうそ。
Feignでレスポンスボディによる例外処理を行う
REST APIクライアントのSpring Cloud Feignを使う場合に異常系のステータスコードをハンドリングする方法を調べたのでメモです。
デフォルトの振る舞い
APIが400番台や500番台のステータスコードを返した場合、FeignはFeignException
をスローします。これは下記の記事に書いた通りです。
FeignException
にはステータスコードが含まれるので、例外オブジェクトのstatus
プロパティで例外処理ができます。しかし、レスポンスボディで例外処理するのはこの仕組みではできません。
https://github.com/OpenFeign/feign/blob/master/core/src/main/java/feign/FeignException.java
public class FeignException extends RuntimeException { private static final long serialVersionUID = 0; private int status; //(中略) public static FeignException errorStatus(String methodKey, Response response) { String message = format("status %s reading %s", response.status(), methodKey); try { if (response.body() != null) { String body = Util.toString(response.body().asReader()); message += "; content:\n" + body; } } catch (IOException ignored) { // NOPMD } return new FeignException(response.status(), message); } }
レスポンスボディによる例外処理
レスポンスボディの内容を元に例外処理を行いたい場合を想定します。例えば、バリデーションエラーの時にステータスコード400でエラー理由がレスポンスボディに入るといった場合です。
このような場合、既存のFeignException
ではレスポンスボディを読み取れないので、ErrorDecoder
を使う必要があります。ErrorDecoder
を使うと、APIが400番台や500番台を返した場合にどのような例外を発生させるか設定できます。詳しくは Custom error handling · OpenFeign/feign Wiki · GitHub に説明があります。
ここでは、ErrorDecoder
でレスポンスボディをPOJOに変換してみましょう。Spring Cloud Feignに含まれるSpringDecoder
を使うと、Springで用意されているConvertでレスポンスボディをオブジェクトに変換できます。
import feign.Response import feign.codec.Decoder import feign.codec.ErrorDecoder class ErrorResponseDecoder implements ErrorDecoder { // Spring Cloud FeignのSpringDecoderが注入される @Inject Decoder decoder @Override Exception decode(String methodKey, Response response) { def errorResponse = decoder.decode(response, ErrorResponse) as ErrorResponse new ErrorResponseException(response.status(), errorResponse) } } // レスポンスボディの型 @Immutable class ErrorResponse { int code String message } // レスポンスボディを含む例外クラス class ErrorResponseException extends FeignException { final ErrorResponse errorResponse def ErrorResponseException(int status, ErrorResponse errorResponse1) { super(status, errorResponse1.toString()) errorResponse = errorResponse1 } }
ErrorDecoder
を有効にするにはFeignのConfigurationを使います。
@Configuration class HelloClientConfiguration { @Bean ErrorDecoder errorDecoder() { new ErrorResponseDecoder() } } @FeignClient(name = 'hello', url = '${hello.service.url}', configuration = HelloClientConfiguration) interface HelloClient { //... }
上記の仕組みを使うと、ErrorResponseException
をキャッチすることでレスポンスボディによる例外処理ができます。
レスポンスボディのパススルー
外部APIのエラーレスポンスをそのままクライアントに返したい場合は、@ControllerAdvice
で共通処理を入れるとよいでしょう。
@Slf4j @ControllerAdvice class ResponseStatusPropagation { @ExceptionHandler(ErrorResponseException) ResponseEntity handleErrorResponseException(ErrorResponseException e) { log.error("Handled error response from API", e) def cause = e.cause as ErrorResponseException ResponseEntity .status(cause.status()) .contentType(MediaType.APPLICATION_JSON) .body(cause.errorResponse) } }
Feignのエラーハンドリングに関する記事がとても少ないので書いてみました。ご参考まで。