KarateによるAPIのシナリオテスト自動化 #07 Test-Double


はじめに

前回(#06 Assertions-Contains)は、Karate における Assert に関して、Containsの使い方についてて確認しました。

今回は、テストダブルといって、これまでのAPIを呼び出す側(ここでは「テストクライアント」と呼びます)ではなく、テスト対象から呼び出される側(ここでは「サーバーモック」と呼びます。「テストダブル」と同様の意味として利用します)を、Karateで実現する方法を確認します。

RESTでのテストクライアントのツールは、色々とありますが、Karateでは、このテストダブルの機能があると分かって、私としては、応用の範囲がかなり広がりました。
これができると、以下のように、テスト対象が別のサービスに依存している場合のテストが容易になります。

マイクロサービス化されたシステムなど、テスト対象が、別のサービスを呼び出すようなことは、非常に多くなってきました。
これまでは、独自のモックを作成していたりしたのですが、Karateを使えば、独立性の高いサービス単体でのテストが可能になります。

サーバーモック(テストダブル)としての Karate シナリオ

サーバーモックとしてKarateを動作させる場合は、これまでと同様に Feature ファイルを作成しますが、シナリオの書き方が変わります。

Scenario: pathMatches('/cats') && methodIs('post')
    * def cat = request
    * def id = uuid()
    * set cat.id = id
    * eval cats[id] = cat
    * def response = cat

Scenario: pathMatches('/cats')
    * def response = $cats.*

上記のように、Scenarioの部分に、呼び出されるURIの定義を指定します。
request/response などは予約語になっており、それぞれ受信データの内容を取得したり、応答データの内容を指定したりすることが可能になっています。

詳細は、以下のあたりを参照。

テストシナリオ

ここでは、以前の「#03 Writing Scenarios」で作成したサーバーモックを、Karateで実現してみます。

「#03 Writing Scenarios」の際は、WireMock を利用して実現していました。
ただ、一点課題があって、WireMockの場合、固定値のレスポンスを返すようなケースは簡単なのですが、同じAPIでも状態によって戻り値が変わるようなケースは、カスタマイズの実装が必要であり、ちょっと大変でした。
「#03 Writing Scenarios」では、「空き車両一覧取得」のAPIが、これに該当するもので、APIとしては同じリクエストになるのですが、戻り値は状態によって変わるのを実現することが必要でした。

今回は、これをKarateで実現してみます。

モックシナリオ作成

呼び出されるAPIに合わせて、モックシナリオを以下のように作成しました。

rentacycles-service-mock.feature
Feature: レンタサイクルAPIのサービスモック

Background:
    * configure cors = true
    * def rentacycles =
    """
    [
      {id: 'A001', rent: false},
      {id: 'A002', rent: false},
      {id: 'A003', rent: false},
      {id: 'A004', rent: true},
      {id: 'A005', rent: true}
    ]
    """

# -----------------------------------------------
# 空き車両一覧取得
# GET:/rentacycles?available=true
# -----------------------------------------------
Scenario: methodIs('get') && pathMatches('/rentacycles') && paramValue('available') == 'true'
    * def availables = karate.jsonPath(rentacycles, "$[?(@.rent==false)]")
    * def response = availables

# -----------------------------------------------
# 車両一覧取得
# GET:/rentacycles
# -----------------------------------------------
Scenario: methodIs('get') && pathMatches('/rentacycles')
    * def response = rentacycles

# -----------------------------------------------
# レンタル処理
# POST:/rentacycles/rent
# -----------------------------------------------
Scenario: methodIs('post') && pathMatches('/rentacycles/rent')
     * def requestBody = request
     * def id = requestBody.id
     * def target = karate.jsonPath(rentacycles, "$[?(@.id=='" + id + "')]")[0]
     * print 'rental target : ', target
     * eval if (target == null) karate.abort()

     # 既にレンタル済みの場合は、409をリターンして終了
     * eval if (target.rent == true) karate.set('responseStatus', 409)
     * eval if (responseStatus == 409) karate.abort()

     # レンタル可能な場合は、状態を変更して200をリターン
     * set target.rent = true
     * def response = {result : true}

# -----------------------------------------------
# 返却処理
# POST:/rentacycles/return
# -----------------------------------------------
Scenario: methodIs('post') && pathMatches('/rentacycles/return')
     * def requestBody = request
     * def id = requestBody.id
     * def target = karate.jsonPath(rentacycles, "$[?(@.id=='" + id + "')]")[0]
     * print 'rental target : ', target
     * eval if (target == null) karate.abort()

     # 状態を変更して200をリターン
     * set target.rent = false
     * def response = {result : true}

# -----------------------------------------------
# API が一致しない場合
# -----------------------------------------------
Scenario:
    * def responseStatus = 404

サーバーモックの起動

サーバーモックを起動する場合は、Standalone JAR を利用すると便利です。
これまでは、Karate のシナリオを Maven か Gradle で実行してきていますが、実は、Karateのjarを利用することで、単体として実行することも可能です。

まず、以下から Standalone JAR を取得します。

このjarを使って、以下のように Feature ファイルを実行します。

$ java -jar karate.jar -p 8089 -m rentacycles-service-mock.feature
オプション 説明
-p サーバーモックを起動するポート番号を指定します。
-m 指定したシナリオを、サーバーモックとして起動します。

指定した Feature ファイルを正常に読み込めたら、以下のようなかたちで起動状態になります。
Feature ファイルの内容に誤りがあると、エラーとなって起動されません。

10:03:31.986 [main] INFO  com.intuit.karate.netty.Main - Karate version: 0.9.2
10:03:32.742 [main] INFO  com.intuit.karate - backend initialized
10:03:32.993 [main] INFO  c.intuit.karate.netty.FeatureServer - server started - http://127.0.0.1:8089

テストの実行

モックサーバーが起動できたので、その状態で、これまで通り、テストシナリオを実行します。

$ mvn clean test -Dtest=RentaCyclesRunner

実行結果は、以下の通り、
WireMockで実現した内容を、Karateで置き換えてみましたが、元の通り、正常に実行が完了しました!

---------------------------------------------------------
feature: classpath:examples/rentacycles/rentacycles.feature
scenarios:  2 | passed:  2 | failed:  0 | time: 1.0908
---------------------------------------------------------
HTML report: (paste into browser to view) | Karate version: 0.9.2
file:/MyWork/example-karate/target/surefire-reports/examples.rentacycles.rentacycles.html
---------------------------------------------------------

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

Results :

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

この内容は、以下のような構成(Karate vs Karate)でテストしていることになります。

モックシナリオの解説

今回の内容は、以下に登録しています。

その上で、いくつか、モックシナリオのポイントを解説します。

Background

Background は、モックシナリオ全体における初期化処理です。
ここで変数などを指定しておくと、各APIが呼び出された際に、API横断でその変数を利用できます。

また、以下のように configure cors を指定しておくと、CORS対応がされます。

Background:
    * configure cors = true

この場合、レスポンスヘッダに、自動的に以下の内容が指定されます。

Allow: GET, HEAD, POST, PUT, DELETE, PATCH
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, PATCH

Scenario

GET:/rentacycles?available=true のようなAPIに対するモックを作成する場合、以下のように記述します。

Scenario: methodIs('get') && pathMatches('/rentacycles') && paramValue('available') == 'true'
関数 説明
methodIs HTTPメソッドの種類を指定します。
pathMatches モック化するAPIのURIパターンを指定します。
paramValue クエリストリングのパラメータがある場合に、そのパラメータの内容を指定します。

request に対する処理

requestrequestParams を利用して、リクエストのデータを取得することができます。

Scenario: pathMatches('/cats') && methodIs('post')
    * def cat = request

response に対する処理

response に指定したデータがレスポンスのデータとなります。
また、 responseStatus に指定した値がレスポンスのHTTPステータスとなります。

Scenario: pathMatches('/v1/cats/{id}') && methodIs('get')
    * def response = cats[pathParams.id]
    * def responseStatus = response ? 200 : 404

注意点

実際にモックシナリオを作成してみて、以下のような点に注意する必要があると感じました。

  • APIの一致判定は、シナリオで定義された上から順番に行われる。

    • 今回の場合、以下の2つは、パラメータの有無の違いになりますが、1)の方を先に定義されると、2)よりも前に1)の方に一致するとして動作してしまいます。
      • 1) Scenario: methodIs('get') && pathMatches('/rentacycles')
      • 2) Scenario: methodIs('get') && pathMatches('/rentacycles') && paramValue('available') == 'true'
  • 値の設定などは、処理の内容によって工夫が必要。

    • 例えば、setを利用したい場合でも、コマンドの呼び出し時と、if指定時の関数呼び出し時では、記述の仕方が異なります。この辺りは、Karateの関数などを把握しておく必要があります。
      • コマンドの呼び出し: * set param = 100
      • 関数の呼び出し: * eval if (data == true) karate.set('param', 100)
  • 関数の利用のしどころを考える。

まとめ

サーバーモックを作成するのに、WireMockでは約350ライン程度の実装が必要だったモノが、今回、Karateで同等の内容を約60ライン程度で実現ができました。
ステートフルなサーバーモックを、DSLで実現できるのは、とても便利だと思います。
最近では、この機能を利用して、フロントエンドを開発するときのバックエンドのAPIをモック化して開発する、といったことなどもしています。