Groovy
api
spock
RESTful
HttpClient

APIのテストを書くならspock + RESTClientが便利かも(Groovy)

More than 3 years have passed since last update.

たとえばこんなAPI

GET /bookmarks
(example) http://localhost/bookmarks
[
    {
        "id": "1",
        "name": "オキニ1",
        "url": "http:/hogehoge.com/okini1"
    },
    {
        "id": "2",
        "name": "オキニ2",
        "url": "http:/hogehoge.com/okini2"
    },
    {
        "id": "3",
        "name": "オキニ3",
        "url": "http:/hogehoge.com/okini3"
    }
]
GET /bookmarks/{id}
(example) http://localhost/bookmarks/2
{
    "id": "2",
    "name": "オキニ2",
    "url": "http:/hogehoge.com/okini2"
}

いきなりソースコード

@Grab('org.spockframework:spock-core:0.7-groovy-2.0')
@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.7.1')

import groovyx.net.http.RESTClient
import static groovyx.net.http.ContentType.*
import org.apache.http.client.HttpResponseException
import spock.lang.Specification
import spock.lang.Unroll

class Test extends Specification {

    def ROOT = "http://localhost/"
    def http = new RESTClient(ROOT)

    def "URL NotFound"() {
        when:
        http.get(path: "hoge")

        then:
        def e = thrown(HttpResponseException)
        e.statusCode == 404
        e.message == "Not Found"
    }

    @Unroll
    def "test bookmarks"() {
        when:
        def res = http.get(path: "bookmarks", contentType: JSON)
        def resData = res.responseData

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData[i].id == t_id
        resData[i].name == t_name
        resData[i].url == t_url

        where:
        i || t_id | t_name    | t_url
        0 || "1"  | "オキニ1" | "http:/hogehoge.com/okini1"
        1 || "2"  | "オキニ2" | "http:/hogehoge.com/okini2"
        2 || "3"  | "オキニ3" | "http:/hogehoge.com/okini3"
    }

    @Unroll
    def "test bookmarks/#t_id"() {
        when:
        def res = http.get(path: "bookmarks/${t_id}", contentType: JSON)
        def resData = res.responseData

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData.id == t_id
        resData.name == t_name
        resData.url == t_url

        where:
        t_id | t_name    | t_url
        "1"  | "オキニ1" | "http:/hogehoge.com/okini1"
        "2"  | "オキニ2" | "http:/hogehoge.com/okini2"
        "3"  | "オキニ3" | "http:/hogehoge.com/okini3"
    }
}

説明

準備

@Grab('org.spockframework:spock-core:0.7-groovy-2.0')
@Grab('org.codehaus.groovy.modules.http-builder:http-builder:0.7.1')

Grapeをつかってspockとhttp-builder(RESTClient)を取得して依存ライブラリに追加しています。

class Test extends Specification {

テストクラスを定義しますが、spockのSpecificationクラスを継承することで
spockのテストコードとしてテスト実行されます。

    def ROOT = "http://localhost/"
    def http = new RESTClient(ROOT)

RESTClientをnewしてコンストラクタ引数にルートURLを渡してあげれば
HTTPクライアントの出来上がり。

ID指定なしのテスト

    @Unroll
    def "test bookmarks"() {
        when:
        def res = http.get(path: "bookmarks", contentType: JSON)
        def resData = res.responseData

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData[i].id == t_id
        resData[i].name == t_name
        resData[i].url == t_url

        where:
        i || t_id | t_name    | t_url
        0 || "1"  | "オキニ1" | "http:/hogehoge.com/okini1"
        1 || "2"  | "オキニ2" | "http:/hogehoge.com/okini2"
        2 || "3"  | "オキニ3" | "http:/hogehoge.com/okini3"
    }

これがテストケースのかたまりです。def "テスト名"() {} で囲みます。
spockのテストの基本として、

  • when → この操作をしたとき、
  • then → こういう結果になる。

となっています。ですので、

        when:
        def res = http.get(path: "bookmarks", contentType: JSON)
        def resData = res.responseData

の操作がされたとき、

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData[i].id == id
        resData[i].name == name
        resData[i].url == url

という結果になるのが正しい、ということです。

when

        when:
        def res = http.get(path: "bookmarks", contentType: JSON)
        def resData = res.responseData

httpはRESTClientですが、getメソッドでGETで接続します。
pathにはURLのパスを指定します。→http://localhost/bookmarksにGET接続!
返ってきたデータからresponseDataを取り出し、JSONレスポンスを取得します。

then

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData[i].id == id
        resData[i].name == name
        resData[i].url == url

whenの操作がされた後、thenで評価します。
RESTClientは接続結果がエラーだった場合HttpResponseExceptionが投げられますが
notThrownでHttpResponseExceptionが投げられない(接続成功)ことを評価します。
レスポンスJSONはList形式でresDataに格納されていますので、
resData.size()で何件のレコードが返ってきたかを評価できます。

where

ここでwhereという便利な記述があります。

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData[i].id == t_id
        resData[i].name == t_name
        resData[i].url == t_url

        where:
        i || t_id | t_name    | t_url
        0 || "1"  | "オキニ1" | "http:/hogehoge.com/okini1"
        1 || "2"  | "オキニ2" | "http:/hogehoge.com/okini2"
        2 || "3"  | "オキニ3" | "http:/hogehoge.com/okini3"

thenのi、t_id、t_name、t_urlは実は変数で、whereでデータを記述します。

  • i=0 のとき、t_id="1"、t_name="オキニ1"、t_url="http:/hogehoge.com/okini1" だ!
  • i=1 のとき、t_id="2"、t_name="オキニ2"、t_url="http:/hogehoge.com/okini2" だ!
  • i=1 のとき、t_id="3"、t_name="オキニ3"、t_url="http:/hogehoge.com/okini3" だ!

というのをwhereの3行で表現しています。
なのでwhereの記述でテストケース3つ分ということになります。(先頭に@Unrollをつけてた場合)

・・・といった感じで評価できます。

ID指定ありのテスト

    @Unroll
    def "test bookmarks/#t_id"() {
        when:
        def res = http.get(path: "bookmarks/${t_id}", contentType: JSON)
        def resData = res.responseData

        then:
        notThrown(HttpResponseException)
        resData.size() == 3
        resData.id == t_id
        resData.name == t_name
        resData.url == t_url

        where:
        t_id | t_name    | t_url
        "1"  | "オキニ1" | "http:/hogehoge.com/okini1"
        "2"  | "オキニ2" | "http:/hogehoge.com/okini2"
        "3"  | "オキニ3" | "http:/hogehoge.com/okini3"
    }

ID指定なしとほとんど変わらないですが
whenのpath指定で"bookmarks/${t_id}"の記述がありますが
whenの中の値もwhereで指定できるよ!って感じです。

あとは最初のdef "test bookmarks/#t_id"() {ですが
テスト名に#変数名ってするとテスト名を動的に変更できるよ!って感じです。

NotFoundのテスト

    def "URL NotFound"() {
        when:
        http.get(path: "hoge")

        then:
        def e = thrown(HttpResponseException)
        e.statusCode == 404
        e.message == "Not Found"
    }

pathで存在しないパスをしたときは404になりHttpResponseExceptionが投げられるので
HttpResponseExceptionがスローされ、ステータスコードは404、メッセージは"Not Found"だ!というのを評価してます。

テスト結果

テスト結果がすべてOKのとき

JUnit 4 Runner, Tests: 7, Failures: 0, Time: 530           # 7件テストでエラーなし。

7件テストの内訳はID指定なし:3件、ID指定あり:3件、NotFound:1件。

テスト結果がNGだったとき

JUnit 4 Runner, Tests: 7, Failures: 1, Time: 609           # 7件テストでエラー1件。
Test Failure: test bookmarks/2(Test)                       # test bookmarks/2 のテストで失敗。
Condition not satisfied:

resData.url == t_url                                       # resData.url == t_url の行で失敗。
|       |   |  |                                           # 具体的にどう違うかを表示してくれる。
|       |   |  http:/hogehoge.com/okini3
|       |   false
|       |   1 difference (96% similarity)
|       |   http:/hogehoge.com/okini(2)
|       |   http:/hogehoge.com/okini(3)
|       http:/hogehoge.com/okini2
[id:2, name:オキニ2, url:http:/hogehoge.com/okini2]

    at Test.test bookmarks/#t_id(test.groovy:56)       # ソースのどの行で失敗したか。

至れり尽くせり!
どこがどう違うか、何パーセント合致しているかまで表示してくれます。
もうテスト失敗させたいくらい!

まとめ

おもにspockの概要説明でしたがAPIテストだけでなくUnitテストももちろんできるのでかなーり便利になったりします。
Gradleに組み込んだりしてもテストレポートがJUnitと同じ形式で出力されるので見やすかったです。

サンプル

サンプルをGitHubにおきました。
APIサーバは適当にSinatraで書いてます。

https://github.com/hiromikimura/spock-api-test