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

# はじめに
[前回（#06 Assertions-Contains）](https://qiita.com/takanorig/items/9709291e0f2583c8b24b)は、[Karate](https://github.com/intuit/karate) における Assert に関して、Containsの使い方についてて確認しました。

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22893/72ad1967-66f4-b01e-7bf0-3100824e9976.png)

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


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

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

```cucumber
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 などは予約語になっており、それぞれ受信データの内容を取得したり、応答データの内容を指定したりすることが可能になっています。

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

- https://github.com/intuit/karate/tree/master/karate-netty#server-life-cycle


# テストシナリオ
ここでは、以前の「[#03 Writing Scenarios](https://qiita.com/takanorig/items/2f4963d87af860a60d17)」で作成したサーバーモックを、Karateで実現してみます。

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

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


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

```cucumber: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 を取得します。

- https://dl.bintray.com/ptrthomas/karate/

この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）でテストしていることになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22893/db5c9d68-c251-271c-9bbb-3fa0233f7e59.png)



## モックシナリオの解説
今回の内容は、以下に登録しています。

- https://github.com/takanorig/example-karate
    - /src/mock/java/examples/rentacycles/rentacycles-service-mock.feature
    - /src/test/java/examples/rentacycles/rentacycles.feature

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

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

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

```cucumber
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に対するモックを作成する場合、以下のように記述します。

```cucumber
Scenario: methodIs('get') && pathMatches('/rentacycles') && paramValue('available') == 'true'
```

| 関数 | 説明 |
|:--|:--|
|methodIs|HTTPメソッドの種類を指定します。|
|pathMatches|モック化するAPIのURIパターンを指定します。|
|paramValue|クエリストリングのパラメータがある場合に、そのパラメータの内容を指定します。|


### request に対する処理

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

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


### response に対する処理

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

```cucumber
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)```

- **関数の利用のしどころを考える。**
    - 今回は、関数を定義せず、すべてKarateのDSL定義で実現しましたが、もう少し複雑な処理が必要になる場合は、関数を定義して利用する方が簡単だと思います。
    - モックシナリオの場合でも、Karateの関数は利用できるので、それらの活用も考えると良いでしょう。
        - https://github.com/intuit/karate#javascript-functions
        - https://github.com/intuit/karate#java-interop


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