golang - Kotlinのmicroservice構成のConsumer-Driven Contract testingをpactをつかって作ってみた

今回はConsumer-Driven Contract testingのサンプルを作ってみました。以前のSelenideを使ったE2Eの記事の流れからConsumer-Driven Contract testingも試してみようというモチベーションです。

Consumer-Driven Contract testingとは

Consumer-Driven Contract(以下、CDC)で検索すると定義についての記事がたくさん見つかりますので詳細な説明は他の記事にお任せします。

CDCテストはmicroservice architectureをベースに複数のmicroserviceでサービス全体を構築しサービスが成長する過程で直面する問題に向き合うためのテスト手法の1つ、というのが私の理解です。

どういった問題に直面するか

  • 複雑化するmicroservice間の依存関係
  • 依存関係が正常に保たれているかを検証するためのコスト
  • 不安要素の蓄積がmicroserviceの拡張難易度を上げる

これらの問題要素がデスマーチのように回り始めるとmicroserviceの拡張が止まり更にサービス全体の成長が止まります。

問題を解消するには

多くのmicroservice間の連携はAPIの提供と利用で成り立っています。
2つのmicroserviceの関係はAPIを提供する側(Provider)、APIを利用する側(Consumer)になります。またConsumerのmicroserviceの機能はProviderが提供するAPIを基盤として動きます。このように整理するとProviderが提供するAPIに不備があったり不明なAPI仕様があったりするとConsumerは困ってしまいます。
そのためにお互いにAPIのルール(Contract)を定義します。そしてルールの定義をConsumer側が行い(Consumer-Driven Contract)、Providerがルールを守ることでmicroservice間の連携を保ちます。
このCDCテストを複数のmicroservce間で継続的に行うことで先に挙げた問題を解消します。

Pact

Pactはmicroservce間のCDCテストを順序立て実行するためのフレームワークです。
github.com

今回はPactをインプリメントした次のプロジェクトを利用してCDCテストを作りました。
github.com

上記のプロジェクトから更にgolangjavaなど各言語に最適化されたプロジェクトが派生しています。

Pactをインプリメントした各種言語のライブラリを使う

次のプロジェクトに各種言語のプロジェクトがまとまっていますので参考にしました。
https://github.com/DiUS/pact-jvm

各ライブラリにはCDCテストをするために次のような仕組みを用意しています。

  • ConsumerがProviderのAPIをモック化する仕組み
  • ConsumerとProviderがルールを共有するためのPactファイルを生成する仕組み
  • ProviderがAPI仕様を満たしているかテストする仕組み
golangのライブラリ

github.com

kotlinのライブラリ

github.com

Pact仕様はversion1.1を使う

go-langのライブラリが1.1までの対応のため今回作ったテストもversion1.1を使っています。

Consumer-Driven Contract testをつくる

まずmicroserviceの定義ですがConsumerのmicroserviceはgolang、Providerのmicroserviceはkotlinで構築しました。
シンプルなユーザ情報を取得するAPIをProvider(kotlin)が提供しConsumer(golang)がAPIを利用するという構成でCDC testをつくっていきます。

Consumerのコード(golang

Provider APIへ接続するClient定義です。ユーザIDを受け取りAPIリクエストを送るシンプルなつくりです。

package client

import (
	"encoding/json"
	"fmt"
	"net/http"
)

type UserClient struct {
	baseURL string
}

type User struct {
	Name string
}

func (c *UserClient) GetResource(id int) (*User, error) {
	url := fmt.Sprintf("%s/user/%d", c.baseURL, id)
	req, _ := http.NewRequest("GET", url, nil)

	client := &http.Client{}
	resp, err := client.Do(req)

	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var res User
	decoder := json.NewDecoder(resp.Body)
	if err := decoder.Decode(&res); err != nil {
		return nil, err
	}

	return &res, nil
}

上記のClinetのリクエストとレスポンスをテストコードでモック化しAPI仕様のバリエーションを作成しPactファイルを生成します。
次のコードはテストコードです。

package client

import (
	pact "github.com/SEEK-Jobs/pact-go"
	"github.com/SEEK-Jobs/pact-go/provider"
	"net/http"
	"testing"
)

func buildPact() pact.Builder {
	return pact.
		NewConsumerPactBuilder(&pact.BuilderConfig{PactPath: "../../pacts"}).
		ServiceConsumer("consumer_user_client").
		HasPactWith("provider_user_client")
}

func Test_ContractUserClientProvider_StatusIsOk(t *testing.T) {

	builder := buildPact()
	ms, msUrl := builder.GetMockProviderService()

	request := provider.NewJSONRequest("GET", "/user/1192", "", nil)
	header := make(http.Header)
	header.Add("content-type", "application/json")
	response := provider.NewJSONResponse(200, header)
	response.SetBody(`{"Name": "1192-User"}`)

	if err := ms.Given("fetch user by id 1192").
		UponReceiving("get request for user with id 1192").
		With(*request).
		WillRespondWith(*response); err != nil {
		t.Error(err)
		t.FailNow()
	}

	// Test request user client
	client := &UserClient{baseURL: msUrl}
	if _, err := client.GetResource(1192); err != nil {
		t.Error(err)
		t.FailNow()
	}

	// Verify registered interaction
	if err := ms.VerifyInteractions(); err != nil {
		t.Error(err)
		t.FailNow()
	}

	// Clear interaction for this test scope, if you need to register and verify another interaction for another test scope
	ms.ClearInteractions()

	//Finally, build to produce the pact json file
	if err := builder.Build(); err != nil {
		t.Error(err)
	}
}
  • buildPact()でPactファイルの生成フォルダの指定とConsumer名称のconsumer_user_clientとProvider名称のprovider_user_clientを定義しています。
  • Test_ContractUserClientProvider_StatusIsOk(t *testing.T)ではUserClientのリクエストとレスポンスを定義しモック化しPactファイルを生成します。
  • fetch user by id 1192の文字列はprovider stateとして定義されます。このprovider stateの数がAPI仕様の数と一致するようにUserClientのリクエストとレスポンスを定義します。例えばデータが存在しない404エラーレスポンスや入力値が不正な400エラーレスポンスが必要であればprovider stateとして定義します。

テストを実行してPactファイルを生成します。

$ go test -v ./...
=== RUN   Test_ContractUserClientProvider_StatusIsOk
--- PASS: Test_ContractUserClientProvider_StatusIsOk (0.00s)
PASS
ok  	github.com/nsoushi/cdc-test/pact-go-consumer/client	0.014s
$ ls ../pacts
consumer_user_client-provider_user_client.json

生成したPactファイルは次のようになりました。

{
	"consumer": {
		"name": "consumer_user_client"
	},
	"provider": {
		"name": "provider_user_client"
	},
	"interactions": [
		{
			"provider_state": "fetch user by id 1192",
			"description": "get request for user with id 1192",
			"request": {
				"method": "GET",
				"path": "/user/1192"
			},
			"response": {
				"body": {
					"Name": "1192-User"
				},
				"headers": {
					"Content-Type": "application/json"
				},
				"status": 200
			}
		}
	],
	"metaData": {
		"pactSpecificationVersion": "1.1.0"
	}
}

このPactファイルをProvider(kotlin)が読み込みProvider側で更にテストを実行します。

Providerのコード(kotlin)

@RunWith(PactRunner::class)
@Provider("provider_user_client")
@PactFolder("pacts")
@WebAppConfiguration
open class ContractUserTest {

    lateinit var wireMockServer: WireMockServer

    companion object {

        // mock port
        private val port = 8080

        @TestTarget
        lateinit var target: Target

        @BeforeClass @JvmStatic fun setUpService() {
            target = HttpTarget(port)
        }
    }

    @Before
    fun before() {
        wireMockServer = WireMockServer(port)
        wireMockServer.start()
        WireMock.configureFor(port)
    }

    @State("fetch user by id 1192")
    open fun toDefaultState() {

        val path = "/user/1192"

        val target = UserController()
        val mvc = MockMvcBuilders.standaloneSetup(target).build()
        val mvcResult = mvc.perform(MockMvcRequestBuilders.get(path)).andReturn()

        WireMock.stubFor(WireMock.get(WireMock.urlEqualTo(path))
                .willReturn(WireMock.aResponse()
                        .withStatus(mvcResult.response.status)
                        .withHeader("Content-Type", mvcResult.response.getHeader("Content-Type"))
                        .withBody(mvcResult.response.contentAsString)))
    }
}
  • @RunWith(PactRunner::class)/テスト実行クラスにPactRunnerクラスを指定
  • @Provider("provider_user_client")/Pactファイルから参照するProvider名称を指定
  • @PactFolder("pacts")/Pactファイルの保存ディレクトリを指定
  • private val port = 8080/PactRunnerクラスはテスト実行時にポートをポーリングし実行したリクエストとレスポンスを監視します。このコードでポーリングするポートを指定します。
  • @State("fetch user by id 1192")/Pactファイルにあるinteractionsの1つのprovider_stateを指定しています。このメソッドのスコープ内でAPIのリクエストを実行しレスポンスをモック化し定義したAPIの振る舞いを再現することでAPI定義と一致しているか検証が行われます。(ポーリングの仕組みがこのリクエストとレスポンスを検証します)

Consumerが定義したPactファイルをProviderがリクエストとレスポンスをモック化してAPI定義を満たしているか検証します。今回はPactファイルにあるinteractionsが1つのみでしたが複数ある場合はProviderのテストでも複数の@Stateを作り検証します。
このようにConsumerとProviderの両者で共通の定義を不足なく検証を行うことができます。

まとめ

2つの異なる言語のmicroservice間のCDCテストをPactの仕組みを使いテストを行いました。ConsumerとProviderで最新の定義ファイルを共有することでデプロイ時などにテストを走らせることでmicroservice間の連携エラーになることを防げます。
今回は定義ファイルをファイルシステムで参照しましたがgithubにファイルをプッシュし参照する方法やbrokerと呼ばれるPactファイルの中継地点を介する方法などPactファイルを参照する様々な仕組みがあります。

ソースを公開しています

github.com

関連エントリ

naruto-io.hatenablog.com

みんGO を読んでec2インスンスリストをタグ検索するコマンドラインツールを作ってみた

プロジェクトでGO言語に触れながら学習のためにコマンドラインツールを作り拡張させながら言語理解を深めようと目標を立てた。「みんなのGO言語」を参考にしながら自作のコマンドラインツールを作ったのでまとめます。

みんなのGo言語[現場で使える実践テクニック]

みんなのGo言語[現場で使える実践テクニック]

どんなコマンドラインツールを作ったか

ec2インスタンスをタグ検索してインスタンス情報を取得できるコマンドラインツールを作りました。
生成したインスタンスリストをpecoでインクリメンタルサーチできるようにして選択したインスタンスsshできるようなzsh関数も合わせて作りました。
作ったサブコマンドとpecoを組み合わせればインスタンスへのssh接続が快適になります。

ec2インスタンスリストを生成してくれるコマンドラインツール

作りたいイメージは次のようなものです。

describe_es2 tag -tag-key Name '*myweb*'

コマンドを実行したイメージは次のようになります。

$ describe_es2 tag -tag-key Name '*myweb*'
Completed saving file ./myweb001_i-xxxxxxxxxxxxxx, that content is ec2-xx-xx-xx-xx.region.compute.amazonaws.com
Completed saving file ./myweb002_i-xxxxxxxxxxxxxx, that content is ec2-xx-xx-xx-xx.region.compute.amazonaws.com
Completed saving file ./myweb003_i-xxxxxxxxxxxxxx, that content is ec2-xx-xx-xx-xx.region.compute.amazonaws.com
$ find . -type f
./myweb001_i-xxxxxxxxxxxxxx
./myweb002_i-xxxxxxxxxxxxxx
./myweb003_i-xxxxxxxxxxxxxx
$ cat ./myweb001_i-xxxxxxxxxxxxxx
ec2-xx-xx-xx-xx.region.compute.amazonaws.com // インスタンスのパブリックDNSがファイルの中身に保存されている


tagのサブコマンドには次のオプションを指定できるようにします。

  • aws認証ファイルパス(デフォルトはLinux/OSXであれば"$HOME/.aws/credentials)
  • aws認証プロフィール(デフォルトは'default')
  • region(デフォルトは'ap-northeast-1')
  • 検索対象のタグキー(デフォルトは'Name')

aws認証プロフィールをmyprojectに指定して検索する場合は次のようなコマンドになります。

describe_es2 tag -credential-profile myproject '*myweb*'

サブコマンドにはgoogle/subcommandsを使った

今回はtag検索のみのツールですが今後の拡張でEC2 Container Serviceのクラスタ名を指定すればインスタンスリストが生成されるような拡張を考えているためサブコマンド化したかった。「みんなのGO言語」の「4.4 サブコマンドをもったCLIツール」ではmitchellh/cliの使い方を紹介いただいてますが情報が少なそうな「google/subcommands」を使ってみた。

github.com

サブコマンドをインターフェースとして定義できるので定義したサブコマンドのオプションのコード化など見通しの良いコードが書ける。
次からはgoogle/subcommandsを利用したコードの説明をしていきます。

google/subcommandsの実装例

サブコマンドのオプションを定義する

サブコマンドのオプションをstructで定義します

// オプションのaws認証情報とタグキー、regionを定義
type tagCmd struct {
	credential credential
	tagKey     string
	region     string
}

// aws認証情報は個別にstructで定義
type credential struct {
	profile  string
	filename string
}
subcommands.Commandのインターフェースを実装する
package subcommands

// A Command represents a single command.
type Command interface {
	// Name returns the name of the command.
	Name() string

	// Synopsis returns a short string (less than one line) describing the command.
	Synopsis() string

	// Usage returns a long string explaining the command and giving usage
	// information.
	Usage() string

	// SetFlags adds the flags for this command to the specified set.
	SetFlags(*flag.FlagSet)

	// Execute executes the command and returns an ExitStatus.
	Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) ExitStatus
}

tagのサブコマンドがsubcommands.Commandを実装している例

func (*tagCmd) Name() string {
	return "tag"
}

func (*tagCmd) Synopsis() string {
	return "Fetch the ec2 instance public dns name by tag search, then that stored to text file in the current directory."
}

func (*tagCmd) Usage() string {
	return `tag [-credential-profile default] [-credential-filename '~/.aws/credentials'] [-region ap-northeast-1] [-tag-key Name] '*dev*' :
  Created or updated text file
`
}

func (p *tagCmd) SetFlags(f *flag.FlagSet) {
	f.StringVar(&p.credential.filename, "credential-filename", "", "optional: aws credential file name, when filename is empty, that will use '$HOME/.aws/credentials'")
	f.StringVar(&p.credential.profile, "credential-profile", "default", "optional: aws credential profile, default value is 'default'")
	f.StringVar(&p.region, "region", "ap-northeast-1", "optional: aws region, default value is 'ap-northeast-1'")
	f.StringVar(&p.tagKey, "tag-key", "Name", "target tag key, default value is 'Name'")
}

func (p *tagCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
	// 省略
	return subcommands.ExitSuccess
}

"Execute(ctx context.Context, f *flag.FlagSet, args ...interface{}) ExitStatus"のメソッドはサブコマンドのメインの処理です。詳細は"github.com/nsoushi/describe-ec2"を参照してください。

定義したtagCmdをsubcommands.Registerで追加する
package main

func main() {
	subcommands.Register(subcommands.HelpCommand(), "")
	subcommands.Register(subcommands.FlagsCommand(), "")
	subcommands.Register(subcommands.CommandsCommand(), "")
	subcommands.Register(&tagCmd{}, "")

	flag.Parse()
	ctx := context.Background()
	os.Exit(int(subcommands.Execute(ctx)))
}
インターフェースを実装することでコマンドの使い方やヘルプがまとまる

上記のコードにある通りName()、Synopsis()、Usage()、SetFlags(*flag.FlagSet)を実装することでtagサブコマンドのヘルプが綺麗に出力されます。

subcommands.Registerで登録したサブコマンドが列挙されている

describe-ec2
Usage: describe-ec2 <flags> <subcommand> <subcommand args>

Subcommands:
	commands         list all command names
	flags            describe all known top-level flags
	help             describe subcommands and their syntax
	tag              Fetch the ec2 instance public dns name by tag search, then that stored to text file in the current directory.


Use "describe-ec2 flags" for a list of top-level flags

tagサブコマンドのヘルプ出力

./describe-ec2 help tag help tag
tag [-credential-profile default] [-credential-filename '~/.aws/credentials'] [-region ap-northeast-1] [-tag-key Name] '*dev*' :
  Created or updated text file
  -credential-filename string
    	optional: aws credential file name, when filename is empty, that will use '$HOME/.aws/credentials'
  -credential-profile string
    	optional: aws credential profile, default value is 'default' (default "default")
  -region string
    	optional: aws region, default value is 'ap-northeast-1' (default "ap-northeast-1")
  -tag-key string
    	target tag key, default value is 'Name' (default "Name")
subcommands.Commandを使ってみて
  • 導入は難しくなく簡単にサブコマンドを増やせるので拡張しやすくヘルプも綺麗にまとまり保守性も良さそうです。
「みんなのGO言語」を参考にして
  • ossで誰かに使われる意識を持ってエラーメッセージやヘルプなど詳細に記載した
  • ライブラリをメインの成果物とする場合のディレクトリ構成を参考に 'cmd/describe-ec2/'配下にmainパッケージを置いた

作ったサブコマンドで生成したインスンスリストをpecoでインクリメンタルサーチする

次のようなzsh関数を使って生成したインスタンスリストをインクリメンタルサーチして選択したインスタンスsshできます。

peco-describe-ec2() {
  local MAXDEPTH=${1:-1}
  local BASE_DIR="${2:-`pwd`}"

  local FILENAME=$(find ${BASE_DIR} -maxdepth ${MAXDEPTH} -type f -exec basename {} ';' | peco | head -n 1)

  if [ -n "$FILENAME" ] ; then
    local HOST=`cat $BASE_DIR/$FILENAME`
    echo "ssh $HOST"
    BUFFER="ssh ${HOST}"
    zle accept-line
  fi
  zle clear-screen
}

ソースを公開しています

github.com

使い方

$ go get github.com/nsoushi/describe-ec2/cmd/describe-ec2
$ source $GOPATH/src/github.com/nsoushi/describe-ec2/.zsh.describe_ec2
$ describe-ec2 tag '*AWS*'
$ peco-describe-ec2 // '*AWS*'がtag.Nameに含まれるインスタンスリストをpecoでインクリメンタルサーチして選択したらsshします

Kotlin + SelenideでE2E自動テストのアプリケーションをつくってみた

昨年末のAdvent Calendarを読み漁ってたときにSelenideやE2E、kotlinなどのキーワードが頭に残っていました。キーワードを全部ひっくるめてkotlinでSelenideを使いE2Eテストをつくってみたいなぁと思いを馳せていたところ、プロジェクトでもE2Eテストの必要性が高まっている機運を感じ保守性と拡張性が高い設計を考えながらkotlinでE2Eテストアプリケーションをつくってみました。

はじめに

Selenideについて

SelenideはSeleniumeのラッパーです。
selenide.org

SelenideではWebDriverは自動で閉じたりElementの取得にCSSセレクタが使えます。かなり使いやすい感じになっています。

Selenium WebDriver:
WebElement customer = driver.findElement(By.id("customerContainer"));
Selenide:
WebElement customer = $("#customerContainer"); 

次の比較ページが参考になります。
Selenide vs Selenium · codeborne/selenide Wiki · GitHub

E2Eテスト自動化の必要性

次の一休.comのスライドにあるように、ユーザに価値を届けるスピードを向上に限ります。
一休.comのE2Eテスト事情 ~Selenium 3.0 対応~ /seleniumjp4_ikyu // Speaker Deck

また継続的インテグレーションの一環として必要なテストです。

何をテストするのか

前置きが長くなりましたが、ここからはアプリケーションについてまとめます。
はじめにテスト内容は認証処理にしました。

FRESH!(フレッシュ) - 生放送がログイン不要・高画質で見放題をテスト対象サイトにします。

freshlive.tv

テスト対象ページにはコード認証画面があります。

以下、認証画面で行う内容がE2Eテストの内容になります。

  • 認証画面のモジュールの整合性/画面内に必要な認証フォームが表示されているか、画面要素の崩れがないか(スクリーンショットを撮る)
  • 認証画面に遷移してコードを入力してログインできるか

Specテストのステップにすると次のようになります。

    val targetUrl = "https://freshlive.tv/auth/code"
    init {
        given("GET: $targetUrl") {
            `when`("非ログイン状態で認証ページを表示する") {
                then("画面構成に必要なモジュールがある") {
                    // 認証ページへアクセスする
                    // スクリーンショットを保存する
                    // 認証フォームがあるか
                }
            }

            `when`("非ログイン状態で認証ページに遷移して認証コードを入力する") {
                then("ログインができる") {
                    // 認証ページへアクセスする
                    // 認証コードを入力する
                    // トップへアクセスしてログインできているか確認する
                }
            }
        }

保守性と拡張性が高い設計を考える

先程の一休.comのスライドでもふれていますがPage Object Design Patternで作るのが定石です。

画面を1つのオブジェクトとして扱いページオブジェクトとテストシナリオを分離するような設計をベースにします。
またテストシナリオとテストデータを分離することでテストコードの拡張性の高さを保ちます。
各オブジェクトクラスをまとめるパッケージ構成は次のスライドを参考にまとめました。
Selenium2でつくるテストケースの構成について

パッケージ構成

次のようなパッケージ構成にそれぞれ機能ごとのクラスを配置しページオブジェクトとテストシナリオ、テストシナリオとテストデータを分離します。

spec-test
├── features
├── fixtures
├── modules
├── operators
├── pages
└── support

ここからは各パッケージについての解説です。

featuresパッケージについて
テストシナリオをまとめるパッケージです。このパッケージ配下にテストシナリオのクラスをまとめます。

fixturesパッケージについて
fixturesパッケージにはテストに必要なデータの雛形をまとめます。テストの度にシナリオにテストデータを記述することは面倒なので定型データは容易に参照できるようにオブジェクトにまとめます。fixturesパッケージのクラスにはdata classを使いました。kotlinのdata classは引数にデフォルト値を与えられることで雛形にするデータはデフォルト値にして上書きしたいデータは引数に渡すことでテストデータの拡張性を高く保てます。

// ログインに必要な認証コードの雛形データ
data class AuthCodeFixture(val code: String = "123456789012") 

modulesパッケージについて
参考にしたスライドにはないパッケージとなりますが、ページはモジュールの集合体です。1つのモジュールを複数のページで使うこともあります。1つのモジュールをオブジェクトクラスとしてmodulesパッケージにまとめます。このモジュールクラスは後述するページクラスから参照されます。

以下、モジュールクラスの例です。

interface Module {
    /**
     * WebElementの取得
     */
    fun getElement(): SelenideElement
}

abstract class AuthCodeBodyModuleBase : Module {

    companion object {
        const val cssSelector: String = ".AuthCode__body"
    }

    /**
     * 認証コード入力モジュールの取得
     */
    override fun getElement(): SelenideElement = Selenide.`$`(cssSelector)

    /**
     * 認証コード入力フィールドの取得
     */
    protected fun authCodeInputElement(): SelenideElement {
        return getElement().`$`("span input")
    }

    /**
     * 認証コード送信ボタンの取得
     */
    protected fun authSubmitElement(): SelenideElement {
        return getElement().`$`("button[type=submit]")
    }
}

class AuthCodeBodyModule : AuthCodeBodyModuleBase() {

    fun setAuthCodeInputValue(value: String) = authCodeInputElement().`val`(value)

    fun getAuthSubmitElement() = authSubmitElement()
}
  • 上記はログイン認証を入力するフォームを表したモジュールクラスです。
  • 抽象クラス(AuthCodeBodyModuleBase)はモジュール内の各要素(SelenideElement)を取得するセレクタ記述を担当します。SelenideElementを返す役割に徹底させます。
  • 具象クラス(AuthCodeBodyModule)は抽象クラスの要素(SelenideElement)を外部クラスへ公開したり、要素から見出しテキストなどのプリミティブな型オブジェクトを返す役割を担当します。
  • 抽象クラスにメンテナンス性の高いコードを寄せ、具象クラスで利用をコントロールさせることで保守性と拡張性を高めます。

operatorsパッケージについて
operatorsパッケージにはログインをする、検索するなどページのオペレーションをまとめます。

以下、オペレータクラスの例です。

interface AuthCodeOperatorBase {

    /**
     * 認証コードを入力します
     */
    fun input(module: AuthCodeBodyModule, code: String)

    /**
     * 認証します
     */
    fun submit(module: AuthCodeBodyModule)
}

class AuthCodeOperatorImpl : AuthCodeOperatorBase {

    override fun input(module: AuthCodeBodyModule, code: String) {
        // 認証コードのinputフィールドに認証コードを入力
        module.setAuthCodeInputValue(code)
    }

    override fun submit(module: AuthCodeBodyModule) {
        // 認証処理を送信
        module.getAuthSubmitElement().submit()
    }
}

上記のように認証コードを入力して送信ボタンを押下するオペレーションがまとまりました。モジュールクラスから要素を取得しsetValueやsubmitなどのオペレーションを実行します。

オペレータクラスの機能をページクラスに委譲させる
オペレータクラスは委譲を利用してページクラスに機能を委譲させます。

次のようなコードでページクラスへオペレータクラスの処理を委譲させます。

abstract class AuthCodePageBase constructor(
        private val authCodeOperator: AuthCodeOperatorBase,
        private val authCodeBodyModule: AuthCodeBodyModule
) : PageBase(ScreenshotSupportImpl()), AuthCodeOperatorBase by authCodeOperator ← 委譲させる

supportパッケージについて

テストに共通して必要なユーティリティをパッケージにまとめます。
スクリーンショットを撮る機能をsupportパッケージに配置しました。

以下、スクリーンショットのユーティリティクラスです。

interface ScreenshotSupport {

    /**
     * スクリーンショットを撮る
     */
    fun takeScreenshot(driver: WebDriver): BufferedImage

    /**
     * スクリーンショットをFilesystem保存する
     */
    fun storeImageToFs(image: BufferedImage, storePath: String)
}

class ScreenshotSupportImpl : ScreenshotSupport {

    override fun takeScreenshot(driver: WebDriver): BufferedImage {
        val screenshot = AShot()
                .shootingStrategy(ShootingStrategies.viewportPasting(100))
                .takeScreenshot(driver)
        return screenshot.image
    }

    override fun storeImageToFs(image: BufferedImage, storePath: String) {
        ImageIO.write(image, "PNG", File(storePath));
    }
}

上記のユーティリティクラスはashotライブラリを利用して画面のスクリーンショットをサポートします。

github.com
ashotを使うことでchromeブラウザで縦長な画面でも画面全体を撮ることができます。
※画面をスクロールして撮るためヘッダー要素が追随する場合はヘッダー要素が連続して写ります。

ユーティリティクラスの機能をページクラスに委譲させる
ユーティリティクラスも同様に委譲を利用してページクラスに機能を委譲させます。

abstract class PageBase(screenshot: ScreenshotSupport) : Page, ScreenshotSupport by screenshot ← 委譲させる

pagesパッケージについて
pagesパッケージには先述したモジュールクラスを参照しページを表すページクラスをまとめます。またオペレータクラス、サポートクラスの処理を委譲させることでページクラスに全ての機能が集約されます。

以下、ページクラスのコードです。

abstract class PageBase(screenshot: ScreenshotSupport) : Page, ScreenshotSupport by screenshot

abstract class AuthCodePageBase constructor(
        private val authCodeOperator: AuthCodeOperatorBase,
        private val authCodeBodyModule: AuthCodeBodyModule
) : PageBase(ScreenshotSupportImpl()), AuthCodeOperatorBase by authCodeOperator {

    /**
     * 認証コードの入力フォームモジュールが配置されているか
     */
    protected fun codeBodyModule(): AuthCodeBodyModule {
        return authCodeBodyModule
    }

    /**
     * 認証コードを入力して認証する
     */
    protected fun auth(code: String) {
        authCodeOperator.input(authCodeBodyModule, code)
        authCodeOperator.submit(authCodeBodyModule)
    }
}

class AuthCodePage : AuthCodePageBase(AuthCodeOperatorImpl(), AuthCodeBodyModule()) {

    /**
     * 認証コードの入力フォームモジュールが配置されているか
     */
    fun hasCodeBodyModule(): Boolean = codeBodyModule().getElement().`is`(enabled)

    /**
     * 認証コードを入力して認証する
     */
    fun executeAuth(code: String) = auth(code)
}
  • こちらもmodulesパッケージと同様に抽象クラスと具象クラスで役割を分けます。
  • 抽象クラス(AuthCodePageBase)ではページに必要なモジュールを取得する役割やオペレータクラスから委譲された機能の利用を管理します。
  • 具象クラス(AuthCodePage)ではモジュール要素にアクセスしながらプリミティブな型を外部クラスに公開したり、オペレータクラスの機能を実行させるメソッドを提供します。
  • 具象クラスの各メソッドの返り値の型をプリミティブな型にすることでページクラスを利用するSpecテストではSelenide、Seleniumの機能の理解が必要なくページクラスの機能を扱えます。

完成したSpecテスト

上記のパッケージ構成と各クラスの実装からSpecテストは次のようになりました。

    val url: String = "https://freshlive.tv/auth/code"
    val page = AuthCodePage()

    init {

        given("GET: $targetUrl") {
            `when`("非ログイン状態で認証ページを表示する") {

                then("画面構成に必要なモジュールがある") {

                    // 認証ページへアクセス
                    val driver = page.open(targetUrl, cookies = null)
                    // スクリーンショットを撮って./screenshotsに画像を保存
                    page.storeImageToFs(targetPage.takeScreenshot(driver), "./screenshots/auth_code.png")
                    // 認証フォームがあるか
                    page.hasCodeBodyModule() shouldBe true
                }
            }

            `when`("非ログイン状態で認証ページに遷移して認証コードを入力する") {

                then("ログインができる") {

                    // テストデータを参照する
                    val authCode = AuthCodeFixture("test_code:xxxxx")
                    // 認証ページへアクセス
                    val driver = page.open(targetUrl, cookies = null)
                    // 認証コードの入力
                    page.executeAuth(authCode.code)
                    // トップへアクセス
                    page.open(driver, "https://freshlive.tv/", cookies = null)
                }
            }
        }
    }

まとめ

  • fixtures, modules, operators, pagesが用途ごとに分離することで各パッケージとクラスの役割が明確になりました。
  • 委譲を利用することでページクラスに全ての機能を集約させることができました。
  • Specテストは見通しの良いコードになりました。(ページクラスのメソッド返り値をプリミティブ型の返り値にまとめることが鍵です)
  • kotlinで書いた所感として、data classや委譲など良い形で活用できたのとkotlinでもSelenideをベースにしたE2Eアプリケーションを書けたのでE2E自動テストのベース言語にkotlinいけるのではないかと感じました。

ソースを公開しています

今回はポイントごとにコードをコピペしたので全容はgithubを参照ください。
github.com

参考にさせていただいたページ

アプリケーション開発にあたって次のページを参考にさせていただきました。先駆者の方々ありがとうございます。

KotlinでMockテストのまとめ

1ヶ月ほどkotlinで開発をしてきて、不慣れなkotlinであってもテストをしっかり書いていこうと目標を立て臨んだ1ヶ月。
おかげでkotlinにおけるMockテストの知見が溜まってきたので、この機会にまとめていきます。

javaではJMockitでMockテストを書いてきたけど、いざkotlinでとなると弊害が多くMockitoやPowerMockに置き換えながら試していった。

どんなテストをするのか

トランザクションで扱う複数の関数のテストを次のように実現できるとプログラムを網羅的にテストできるでしょう。

  • クラス全体のパブリック関数のモック
  • クラス一部分のパブリック関数のモック
  • 呼び出す関数の引数のモック(anyString()やany()を使う)
  • クラスのプライベート関数のモック
  • クラスのプライベート関数のアサーションテスト

何をつかったか

次のテストライブラリを使いました。

github.com

  • Powermockはトランザクションで実行されるプライベート関数のモックに使います。
  • PowermockにはMockitoが内包されています。Mockitはクラス全体のパブリック関数のモックや一部分のパブリック関数のモックに使います。

jmockit.org

github.com
テストケースはJUnitではなく、kotlintestを使いました。BehaviorSpecを使うとテストコードの見通しが良くなりますね。後述しますがPowermockを使う場合はJUnitでないと動かないところがあるのでJUnitも使っています。

それではテストコードのまとめです

テスト対象とするクラスの説明

次のようなinterfaceを用意しました。

interface Ppap {
    fun leftHand(sing: Boolean): String
    fun rightHand(sing: Boolean): String
    fun woo(right: String, left: String): String
}

ピコ太郎のPPAPソングをクラス表現しました。

  • leftHand(sing: Boolean)関数は左手に持っているモノを返す関数です。引数のsingがtrueだと歌詞を返し、falseだとモノの名前を返します。(rightHand(sing: Boolean)関数も右手に持つモノで振る舞いは一緒です)
  • woo(right: String, left: String)は右手と左手のモノを合わせた歌詞を返します。

Ppapの実装クラスです。

open class PpapImpl constructor(val right: String, val left: String) : Ppap {

    val iHaveA = "I have"

    override fun leftHand(sing: Boolean): String {
        if (sing)
            return "$iHaveA $left."
        else
            return getObj(left)
    }

    override fun rightHand(sing: Boolean): String {
        if (sing)
            return "$iHaveA $right."
        else
            return getObj(right)
    }

    override fun woo(right: String, left: String): String = "%s%s.".format(left, right)

    private fun getObj(obj: String) = obj.split(Regex("\\s+")).get(1)
}

こちらのテストコードを見て頂くとプログラムのイメージが伝わるはずです。

PpapImplを使ってPPAPソングを歌うPpapSongクラスを用意しました。

class PpapSong constructor(val ppap: PpapImpl) {

    fun sing(): String {
        return "%s %s woo %s".format(
                ppap.rightHand(true),
                ppap.leftHand(true),
                ppap.woo(ppap.rightHand(false), ppap.leftHand(false)))
    }
}

PpapImplの関数をrightHand()からleftHand()、woo()と順番に呼び出しています。
PpapImplクラスの関数をモックしながらPpapSongクラスのテストをしました。

クラス全体のパブリック関数のモック

はじめにクラス全体のパブリック関数のモックです。Mockitoを活用していきます。

val ppapMock: PpapImpl = mock(PpapImpl::class.java) // PpapImplをモック対象にする

val ppapSong = PpapSong(ppapMock)

Mockito.`when`(ppapMock.rightHand(sing = true)).thenReturn("I have a Ebi.") // 各メソッドをモック
Mockito.`when`(ppapMock.leftHand(sing = true)).thenReturn("I have a Bin.")

Mockito.`when`(ppapMock.rightHand(sing = false)).thenReturn("Ebi")
Mockito.`when`(ppapMock.leftHand(sing = false)).thenReturn("Bin")

Mockito.`when`(ppapMock.woo("Ebi", "Bin")).thenReturn("EbiInBin.")

val actual = ppapSong.sing()

actual shouldBe "I have a Ebi. I have a Bin. woo EbiInBin."

Mockitoをkotlinで使ってみた、のようなコードです。

クラス一部分のパブリック関数のモック

次に一部分の関数をモックするパターンです。Mockitoのspyを使います。

val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple")
val spy = spy(target) // PpapImplをspy対象にする

val ppapSong = PpapSong(spy)

Mockito.`when`(spy.leftHand(sing = true)).thenReturn("I have a PineApple.") // 左手をAppleからPineAppleにモックする
Mockito.`when`(spy.leftHand(sing = false)).thenReturn("PineApple")

val actual = ppapSong.sing()

actual shouldBe "I have a Pen. I have a PineApple. woo PineApplePen."

leftHandの呼び出しのみモックしました。他の関数はすべてモックされず実行しています。
最後が`ApplePen`ではなく`PineApplePen`になっているのが確認できますね。

関数の引数のモック(anyString()やany()を使う)

次に関数の引数をany()などを使い任意の引数にマッチさせたりマッチが難しい場合にany()で任意の値でマッチさせるケースです。
※マッチが難しいケースとしては引数にプリミティブな型を使っていないケースや、現在時刻のLocalDateTimeを引数にしているケースなどが考えられます。

val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple")
val spy = spy(target)
val ppapSong = PpapSong(spy)

Mockito.`when`(spy.woo(anyString(), anyString())).thenReturn("PenPineAppleApplePen.") // wooメソッドの引数は任意でマッチさせてReturnの文字列を書き換えています

val actual = ppapSong.sing()

actual shouldBe "I have a Pen. I have an Apple. woo PenPineAppleApplePen."

このケースではプリミティブな型のためanyString()を利用しています。
プリミティブ型以外の場合はany()やanyObject()を使うところですが、kotlinでは嵌まりました。
詳細は以下の記事に詳しく載っていますので参照ください。
Kotlin + Mockitoでany<T>()やeq<T>()を使いたい - JDBな人生

テスト対象の関数の引数がNon-Nullなオブジェクトかどうかkotlinがチェックするため起こる現象で解決する方法も記載頂いています。
私はこのケースでド嵌まりしまして、この記事に救われました。

クラスのプライベート関数のモック

次にクラスのプライベート関数のモックです。このケースはPowerMockを使います。

@RunWith(PowerMockRunner::class) 
@PrepareForTest(PpapImpl::class)
class PpapSong_Private_Method_MockTest {

    @Test
    fun rightHand_test() {

        val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple")
        val spy = PowerMockito.spy(target)

        PowerMockito.doReturn("Ebi").`when`(spy, "getObj", "a Pen")

        val actual = spy.rightHand(false)

        assertThat(actual, `is`("Ebi"))
    }
}
  • PpapImplのrightHand関数をモックしています。doReturn()を`Ebi`にしました。
  • クラスに@RunWithと@PrepareForTestのアノテーションを指定します。
  • @PrepareForTestにはテスト対象のクラスを指定します。今回はPpapImplクラスになります。
  • PowerMockRunnerクラスの実装を見ると今のところはJUnitのみをサポートしているようなので、PowerMockを使う場合はJUnitでテストケースを書きます。
クラスのプライベート関数のアサーションテスト

最後にクラスのプライベート関数のアサーションテストです。

given("getObj method") {

   val target = PpapImpl(right = "a Pen", left = "an Apple")
   var param: String

   `when`("param is 'a Pen'") {
       then("method should return 'Pen'") {
           param = "a Pen"
           val actual = Deencapsulation.invoke<String>(target, "getObj", param)
           actual shouldBe "Pen"
       }
   }

   `when`("param is 'an Apple'") {
       then("method should return 'Apple'") {
           param = "an Apple"
           val actual = Deencapsulation.invoke<String>(target, "getObj", param)
           actual shouldBe "Apple"
       }
   }

   `when`("param is 'Ebi'") {
       then("method throws exception") {
           param = "Ebi"
           shouldThrow<ArrayIndexOutOfBoundsException> {
               Deencapsulation.invoke<String>(target, "getObj", param)
           }
       }
   }
}

JMockitを使ってプライベート関数のアサーションテストをしています。

まとめ

objectやenumクラスの例はありませんでしたがプロジェクト内ではテストケースを書いており上記で紹介した書き方と同様な書き方でテストができています。
kotlinでもモックテストをガシガシ書けますし、これからはJava製のモックライブラリではなくkotlin純正のライブラリが出てくることを期待しています。
mockito-kotlinというkotlinで書かれたモックライブラリがありましたがMockitoをラッピングしているライブラリとなっています。どこかのエントリでこちらの使用感の報告もできればしたいです。

ソースを公開しています

ソースコードを公開しています。
github.com

Kotlin + Spring Boot/ResponseEntityを使ったJSONレスポンスにJacksonの@JsonPropertyを有効にする

Spring Bootを使ってkotlinで書いています。サーバサイドでkotlinを使うと新たな発見があるのでいいですね。
ControllerのレスポンスにResponseEntityを使ったところdata classのプロパティを@JsonPropertyでリネームしたのに有効になりませんでした。
自分のググラビリティが低く解決方法が見つからず小一時間ほど費やしてしまいました。同じような人(いれば)のために解決方法をメモします。

こんな環境で試しました

  • Spring Bootは1.4.2
  • kotlinは1.0.4

次のようなdata classを用意します。

data class Member(
        val userId: Long,
        val name: String,
        @JsonIgnore
        val age: Int,
        @JsonProperty("isGold")
        val gold: Boolean = false
)

@JsonProperty("isGold")のようにJsonレスポンスではgoldのプロパティ名をisGoldにしたいです。

次のようなコントローラーでMemberクラスを返します。

@RequestMapping(value = "/member/{id}", method = arrayOf(RequestMethod.GET))
open fun getUser(@PathVariable id: Long): ResponseEntity<Member> {
    return ResponseEntity(Member(id, "name", 20, true), HttpStatus.OK)
}

次のようなレスポンスになります

curl -X GET http://localhost:8080/test/member/1 | python -m json.tool
{
    "gold": true,
    "name": "name",
    "userId": 1
}

ageは @JsonIgnoreで隠れているのに、goldがisGoldではありません。
こんな状況でした。

kotlinモジュールを使う

kotolinモジュールを追加すると解決します。
github.com

Gradle:

compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.8.4"


kotlinモジュールを追加すると次のようなレスポンスになります

curl -X GET http://localhost:8080/test/member/1 | python -m json.tool
{
    "age": 20,
    "isGold": true,
    "name": "name",
    "userId": 1
}

isGoldになりました!!・・??、@JsonIgnoreしたageがignoreされていない。。

kotlinモジュールを有効にしたら@JsonIgnoreの宣言は以下のように変える必要があります。

data class User(
        val userId: Long,
        val name: String,
        @get:JsonIgnore
        val age: Int,
        @JsonProperty("isGold")
        val gold: Boolean = false
)

data classを更新して再度レスポンスをとると

curl -X GET http://localhost:8080/test/member/1 | python -m json.tool
{
    "isGold": true,
    "name": "name",
    "userId": 1
}

期待する結果となりました:clap:

kotlinで@JsonPropertyの使い方を調べていたら棚から牡丹餅のように@JsonIgnoreの使い方も学べました。

ソースを公開しています

ソースコードを公開しています。
github.com

ElasticsearchのScroll APIをためしてみた

気になっていたElasticsearchのScroll APIの使用感を記録します。最近の開発でScroll APIを採用したい欲求がありましたが、使用感を調べる前で採用は見送りました。このままだと気になったまま使わないことになりそうなので、この機会にまとめます。

www.elastic.co

※ version 2.4をつかいました。

Scroll APIは通常のSearch requestのoffset/limitでページング取得をしないため処理中のデータ抜けが防げるメリットがあります。またScroll APIは初回リクエスト時の結果をスナップショットすることで安定した応答速度を担保します。
スナップショットをとるためリアルタイムのデータ処理の利用には向いていません。(スナップショットの挙動について試してみたので後述しています)

どんなふうに使うか?

通常のクエリとscroll=1mを加えたリクエストを送ります。(size=1にしています)

curl -XGET 'http://localhost:9200/_search?scroll=1m&size=1&pretty' -d '
{
"query" : { "match" : { "category_id" : 100 } }
}'

次のような検索結果(1件)と合わせて_scroll_idが返ってきます。

{
  "_scroll_id" : "cXVlcnlUaGVuRmV0Y2g7NTs4OkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7OTpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzEwOkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7MTE6RlFCOTVUYkhSbGFGblFWUGdVai1hdzsxMjpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzA7",
・・・
  "hits" : {
    "total" : 52,
・・・
  }
}

2件目の取得を行うために/_search/scrollのエンドポイントへscroll_idをRequest Bodyに加えてリクエストします。クエリは必要ありません。

curl -XGET 'http://localhost:9200/_search/scroll?pretty' -d '
{
"scroll_id": "cXVlcnlUaGVuRmV0Y2g7NTs4OkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7OTpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzEwOkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7MTE6RlFCOTVUYkhSbGFGblFWUGdVai1hdzsxMjpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzA7"
}'
  • 2回目以降はscroll_idを送ることで初回のリクエスト時に送った検索条件の結果が返ってきます。
  • 3回目以降も同様にscroll_idを送ることで3件目、4件目、5件目・・・と結果を取得できます。
  • 初回にsize=1としたため、2回目以降の結果も1件になります。
  • またscroll=1mとしたことで初回にリクエストした検索条件の結果を1分間の有効期限でスナップショットが取られます。

使い終わったscroll_idは破棄をする

スナップショットを残して置くのはコストがかかるためscrollが終われば次のようにscroll_idをクリアします。

curl -XDELETE localhost:9200/_search/scroll -d '
{
    "scroll_id" : ["cXVlcnlUaGVuRmV0Y2g7NTs4OkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7OTpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzEwOkZRQjk1VGJIUmxhRm5RVlBnVWotYXc7MTE6RlFCOTVUYkhSbGFGblFWUGdVai1hdzsxMjpGUUI5NVRiSFJsYUZuUVZQZ1VqLWF3OzA7"]
}'

複数のscroll_idをまとめてクリアもできます。

Scroll APIを使うときのメモ

  • Scroll APIの初回のリクエストはscroll_idを取得するためのものではなく、検索結果に加えてscroll_idが返ってくる。
  • Aggregationを含んだリクエストの場合、Aggregationの結果は初回のみ返ってくる。
  • ソート条件に制約がなければsort orderは_docにすることで安定した応答速度が得られる。

kotlin + 公式Elasticsearch ClientでScroll APIをためしてみる

せっかくなのでkotlinでコードからScroll APIをためしてみました。使ったクライアントは公式のElasticsearch Clientです。

www.elastic.co

※ version 2.4.3をつかいました

スナップショットは本当に有効なのか?

scroll=1mと設定してインデックスされたデータをscroll取得している間に、新しいソースをインデックスしても取得結果のtotal件数に変化がないか試してみました。

以下のような流れで検証します。

scrollIdが取得できれば再帰的にログ出力を繰り返し、その間に新しいソースを1件追加していきます。

実行した結果は次のようになりました。

[INFO ] totalCount={104}, id={AVkna34Rhpv5RJ12skTc}   // 初回取得時のtotal件数は104[INFO ] complete add source id={AVkqcnqbMJXjH5tvLcGB}  //新しいソースの追加が成功
[INFO ] totalCount={104}, id={AVkna_83hpv5RJ12skTd}   // total件数は初回取得時の104件から変わらずスナップショットが有効であることが確認できた
[INFO ] complete add source id={AVkqcntGMJXjH5tvLcGC}
[INFO ] totalCount={104}, id={AVknbG17hpv5RJ12skTf}
・・・

Scroll APIの仕様のとおりスナップショットが有効の状態であれば新しいソースを追加したとしてもスナップショットを指すscroll_idでリクエストをすると全体の件数は変わらないことが確認できました。

まとめ

  • ページング処理ではないため取りこぼしがない。(そもそも初回時点のスナップショットのデータを対象にスクロールするので取りこぼしはないと思われる)
  • offset/limitのページング処理のコードがなくなり、コードがシンプルになった。
  • スナップショットの有効期限が切れた場合、SearchContextMissingExceptionがスローされる。
  • SearchContextMissingExceptionを捕捉して新規スクロールを始める必要があるが、例外が起きた時点のソースIDを始点にスクロールを開始する・・・なかなか例外処理は複雑。
  • 取得したscroll_idを用いた次のスクロールに有効期限を添えることで常にスナップショットの有効期限を更新すれば例外処理を避けることができそう。
  • いまのプロジェクトに導入したくなってきたが、本番での処理時間を測定した上で最適な有効期限の設定をする必要があるため様子見する。

ソースを公開しています

今回検証したソースコードを公開しています。

github.com

Kotlinで快適なJSONパース。Klaxon: JSON for Kotlinを使ってみた。

前回の記事ではMoshiライブラリを使ったJSON文字列からのオブジェクト変換、オブジェクトからのJSON文字列変換の話でした。

naruto-io.hatenablog.com

JSONが複雑な構造でもあってもMoshiのCustom Type Adaptersを使って@ToJsonと@FromJsonを実装すればJSON←→オブジェクトの変換が難なく行えます。

難なく行えますと書きましたが、Moshiのカスタムアダプタを利用する難点もあります。
次のような難点が考えられます。

  • 複数のカスタムアダプタの実装が必要になると骨が折れる作業となる
  • 複雑ではないJSON構造でもカスタムアダプタが必要な場合には実装コストがかかる
  • 実装したカスタムアダプタの整合性テストが必要になる

カスタムアダプタを実装して整合性テストまでのコストを考えると多くのカスタムアダプタの実装は避けたいところです。
開発の過程でJSONパースの処理は多く登場します。JSON文字列をオブジェクトに変換して値を取り出すような良くあるケースでカスタムアダプタの実装は避けたいなぁ・・・と考え調べていたところ「Klaxon: JSON for Kotlin」を見つけました。

github.com

Klaxon: JSON for Kotlin

Klaxonはkotlin製のJSONパースライブラリです。簡易的な使い方の紹介をします。

次のようなJSON構造を例にします。

{
    "name": "Sakib Sami",
    "age": 23
}

次は使い方です。

val parser: Parser = Parser()
val stringBuilder: StringBuilder = StringBuilder("{\"name\":\"Sakib Sami\", \"age\":23}")
val json: JsonObject = parser.parse(stringBuilder) as JsonObject
println("Name : ${json.string("name")}, Age : ${json.int("age")}") // Name : Sakib Sami, Age : 23

KlaxonのParsrにJSON文字列の入力ストリームを渡しJsonObjectでキャストします。あとはキャストしたJsonObjectのdata classに用意されたstiringやintメソッドに取得したいJSON構造のkeyを渡せば値を参照することができます。
stringやintなどのメソッドはJSON値の型に合わせたメソッドが用意されています。その他にlongやbooleanなどがあり、値が新たなJSON構造であればobj、配列の場合はarrayのメソッドを利用します。
as JsonObjectのようにJSON値をキャストする型はobjなどのメソッドと対になっています。詳しくはドキュメントを参照してください。

AggregationBuildersクラスをJSON化してパースしてみる

以前の記事で紹介したAggregationBuildersクラスのオブジェクト構造をJSON文字列にして、KlaxonでJSONパースした例を紹介します。
AggregationBuildersクラスのオブジェクト構造は次のように表せます。

{
    "aggs_post_id": {  "terms": { "field": "post_id", "size": 1000 }, "aggregations": {
            "aggs_category_id": { "terms": { "field": "category_id", "size": 20 }, "aggregations": {
                    "aggs_user_id": { "terms": { "field": "user_id", "size": 2500 } } } } }
}

上記のようなJSONをKlaxonでパースして値を検証してみました。

val parser: Parser = Parser()
val jsonObject = parser.parse(json.byteInputStream()) as JsonObject

should("valid aggs_post_id.terms") {
    val aggsPostIdTerms = (jsonObject.get("aggs_post_id") as JsonObject).get("terms") as JsonObject

    aggsPostIdTerms.string("field") shouldBe "post_id"
    aggsPostIdTerms.int("size") shouldBe 1000
}

should("valid aggs_category_id.terms") {
    val aggsCategoryId = ((jsonObject.get("aggs_post_id") as JsonObject).get("aggregations") as JsonObject).get("aggs_category_id") as JsonObject
    val aggCategoryIdTerms = aggsCategoryId.get("terms") as JsonObject

    aggCategoryIdTerms.string("field") shouldBe "category_id"
    aggCategoryIdTerms.int("size") shouldBe 20
}

should("valid aggs_user_id.terms") {
    val aggsUserId = ((((jsonObject.get("aggs_post_id") as JsonObject).get("aggregations") as JsonObject).get("aggs_category_id") as JsonObject).get("aggregations") as JsonObject).get("aggs_user_id") as JsonObject
    val aggUserIdTerms = aggsUserId.get("terms") as JsonObject

    aggUserIdTerms.string("field") shouldBe "user_id"
    aggUserIdTerms.int("size") shouldBe 2500
}

構造が深いためas JsonObjectが連続して可読性が損なわれていますが、json構造を辿れることが伝えたく敢えて上記のように書きました。

fieldは文字列なのでstringメソッドで、sizeは数値なのでintメソッドで取得できます。

aggsPostIdTerms.string("field") shouldBe "post_id"
aggsPostIdTerms.int("size") shouldBe 1000

APIのレスポンスをKlaxonでパースして値を検証してみる

次のようなレスポンスを返すAPIを想定してKlaxonでパースして値を検証してみます。

[
    {
        "postId": 1324231431,
        "categoryId": 11,
        "user": { "userId": 1413241, "name": "John", "age": 20 }
    },
    {
        "postId": 1321231341,
        "categoryId": 22,
        "user": { "userId": 1453124, "name": "Amy", "age": 25 }
    },
    {
        "postId": 1329709858,
        "categoryId": 33,
        "user": { "userId": 1409709, "name": "Jessica", "age": 38 }
    }
]

kotlintestを使ってSpecテストをしていますのでそれぞれの値の検証意図はSpecのnameを参照してください。

init {
    given("GET: /test/content_list") {

        target = TestController()
        mvc = MockMvcBuilders.standaloneSetup(target).build()

        val response = mvc.perform(MockMvcRequestBuilders.get("/test/content_list"))
                .andExpect(MockMvcResultMatchers.status().isOk()).andReturn().response.contentAsString

        `when`("response is ok") {

            val array = Parser().parse(response.byteInputStream()) as JsonArray<JsonObject>

            then("レスポンスに含まれるPostは `3つ`") {
                val postIds = array.long("postId")
                postIds.size shouldBe 3
            }

            then("categoryIdが30以上のPostは `1つ`でpostIdは `1329709858`") {
                val post = array.filter {
                    it.long("categoryId")!! > 30L
                }
                post.size shouldBe 1
                post.get(0).long("postId") shouldBe 1329709858L
            }

            then("categoryIdが30以下のPostでuserのageが20以上のうち最後のレスポンスのuserのnameは `Amy`") {
                val post = array.filter {
                    it.long("categoryId")!! < 30
                }.findLast{
                    it.obj("user")!!.int("age")!! > 20
                }!!

                post.obj("user")!!.string("name") shouldBe "Amy"
            }
        }
    }
}

APIのレスポンスをパースすることでJsonArrayが取得できます。配列が取れればkotlinのコレクションが使えるのでfilterやmap、findLastのメソッドを利用して値を検証することでテストコードの可読性があがります。
さらにkotlintestのBehaviorSpecでテストコード全体を仕上げたので、それぞれのテストの意図も伝わりやすいです。
JSONパースにコストをかけずJSON構造を上から辿っていく間隔で値の取り出しをできるKlaxonはテストコードで重宝しそうです。

ソースを公開しています

ソースコードを公開しています。Moshiライブラリの検証が入っているプロジェクトに入れました。良ければ参考にしてみてください。

github.com