読者です 読者をやめる 読者になる 読者になる

Page Object PatternをベースにTestCafeでE2Eテストを作ってみた


以前のエントリでKotlinでSelenideを使ったE2Eテストを作ったときもPage Object Patternを利用して見通しの良いテストコードが書けました。TestCafeでも同様にPage Object Patternを利用することが推奨されています

今回はTestCafeでもPage Object Patternを利用してテストを書いてみました。

何をテストするか

次のサイトのログイン認証をテストします。

freshlive.tv

テスト内容としては次のとおりです。

  • 認証画面のURLを開く
  • IDとパスワードを入力する
  • ログインボタンをクリックする
  • ログインが完了するとHome画面にリダイレクトされるので正しくリダイレクトされているか
  • ログインが完了するとヘッダーモジュールにアカウント名が表示されてるので正しく表示されているか

テストコードのフォルダ構成

プロジェクトのフォルダ構成は次のように役割ごとに整理しました。

tests
├── features
├── operators
├── pages
└── support
  • featuresにはスペックコードをまとめます。
  • operatorsにはPageオブジェクトから画面要素を参照してログインボタンをクリックするなどのオペレーションをまとめます。
  • pagesには画面の要素を参照できるようなPageオブジェクトを画面ごとにまとめます。今回は認証画面とHome画面をPageクラスにしました。
  • supportにはスペックコードでテストを進めるときに画面キャプチャを撮るなどのユーティリティ機能をまとめます。

次からはTestCafeのAPIを利用して記述したコード例を紹介します。

Pageクラス

TestCafeのAPIを利用して認証画面のPageクラスを作ります。

import { Selector } from 'testcafe';

export default class AuthPage {
    constructor () {

        this.url = 'https://freshlive.tv/auth/fresh_id';

        this.idInput = Selector('#user_id');
        this.pwInput = Selector('#password');

        this.submitBtn = Selector('button[type=submit]');
    }
}
  • 認証画面のURLを変数に定義する。
  • テストに必要な画面要素はSelectorを利用して定義する。
  • idInputはログインID、pwInputはパスワードの入力フィールドです。
  • submitBtnはログインボタンです。

これで認証画面の要素をまとめたAuthPageクラスができあがりました。

Operatorクラス

TestCafeのAPIを利用してOperatorクラスを作ります。
今回は認証画面でログイン認証する必要があるのでログイン操作をまとめます。

import {t} from 'testcafe';
import AuthPage from '../../pages/auth/auth-page.js';

export default class AuthOperator {
    constructor () {
        this.page = new AuthPage();
    }

    async open() {
        await t
            .navigateTo(this.page.url)
    }

    async authorize(id, password) {
        await this.open();

        await t
            .typeText(this.page.idInput, id)
            .typeText(this.page.pwInput, password)
            .click(this.page.submitBtn)
    }
}
  • constructor ()でAuthPageクラスからPageオブジェクトを生成します。
  • authorize(id, password)では認証画面を開きIDの入力とパスワードの入力を行い最後にログインボタンをクリックしています。typeTextclickで入力とクリックの操作ができます。
  • tはTestCafeのTestConrollerオブジェクトです。async-awaitとimportをすることでテスト実行時のTestConrollerオブジェクトと同期できます。0.13.0のリリース(Using test controller outside of test code (#1166))でTestConrollerをテストコード外でも参照することができるようになりました。(いいね!)

Supportクラス

TestCafeのAPIを利用してのSupportクラスを作ります。
今回は画面キャプチャを撮るユーティリティクラスを作りました。

import {t} from 'testcafe';

export default class ScreenshotSupport {
    constructor () {
    }

    async take() {
        await t
            .takeScreenshot;
    }
}
  • TestControllerには画面キャプチャを撮るメソッドが用意されています。
  • 取った画像を配置するフォルダもテスト実行時のオプションで指定できます。
  • 保存される画像のサイズは表示したブラウザのサイズになり縦長の画面の場合には画面全体を撮ることはできないようです。

Specクラス

TestCafeのAPIを利用してのSpecクラスを作ります。
このクラスでテスト内容をまとめていきます。

import { expect } from 'chai';

import HomePage from '../../pages/home/home-page.js';
import AuthOperator from '../../operators/auth/auth-operator.js';
import ScreenshotSupport from '../../support/screenshot-support.js';

const homePage = new HomePage();
const authOperator = new AuthOperator();
const screenshot = new ScreenshotSupport();

const userId = ' xxxxxxxx';
const password = 'xxxxxxxx';
const accountId = 'My Account';

fixture `auth fixtures`;

test('authorized at page then is appeared your account name on element of header.', async t => {

    // ログインする
    await authOperator.authorize(userId, password);

    // スクリーンショット撮る
    await screenshot.take();

    // ログイン後にHomeにリダイレクトをするのでURLが正しく切り替わっているか
    const docURI = await t.eval(() => document.documentURI);
    expect(docURI).eql(`${homePage.url}?&login_succeeded=true`);

    // ヘッダーメニューにログインしたアカウント名が表示されているか
    await t
        .expect(homePage.accountMenuDropdown.exists).eql(true)
        .expect(homePage.accountName.exists).eql(true)
        .expect(homePage.accountName.innerText).eql(accountId);
});
  • fixtureはテストをカテゴライズする機能でテスト実行時にfixtures単位でテストを実行するオプションがあります。
  • testではテストのタイトルとテストコードを記述していきます。
  • Operatorクラスでまとめた認証画面でログインするauthorize(userId, password)を呼びだしてログインを実行しています。
  • ログイン後にURLが切り替わっているかdocURI変数にURLを入れてチェックしています。
  • 最後にexpectを利用してヘッダーモジュールにアカウント名が表示されているかをチェックしています。

まとめ

  • Page Object Design PatternベースにTestCafeでテストを書いてみました。新しいAPIということもあり画面をオブジェクト化しやすい機能が揃っている印象です。
  • テストコードから画面要素と画面操作を分離してコード化することができました。
  • 以前のエントリではChatOpsとの連携も試してみました。E2Eテストを快適に実施と運用していくことを考えるとTestCafeの採用を検討していきたい:coffee:

ソースを公開しています

github.com

関連エントリ

naruto-io.hatenablog.com

WebDriver不要のTestCafeを使ったE2EテストをChatOpsに導入してみた

今回はE2EテストをつくれるTestCafeをつかってみたエントリです。

devexpress.github.io

以前のエントリではSelenideをつかったE2Eテストの紹介をしました。Selenideも特徴がありますがTestCafeも抜群の特徴があります。
TestCafeではテストコードはNodeで書いていきます。そのため広くエンジニアメンバーがE2Eテストを書けるメリットがあります。ES2016 using をつかってテストコードが書かれているためasync/awaitなどの特徴的な構文も使えます。

最大の特徴はテストをリモートで実行できること

TestCafeのプリインストールでテストするブラウザとしてchromesafariが用意されています。ブラウザを指定したテストの実行は次のようになります。

f:id:n_soushi:20170224192652p:plain

実行するとchromesafariが立ち上がり`tests/test.js`のテストが実行されます。
この方法は予めブラウザを指定する方法ですが テストを実行するURLを生成するremoteというオプションがあります。
実行は次のようになります。

f:id:n_soushi:20170224192938p:plain

remote:1としている箇所がリモートで確認するURLの生成数を指定しています。TestCafeは指定されたURL数を生成して返します。テスターはURLをコピーして好きなブラウザにアクセスしてテストを実行できます。ブラウザにアクセスした後の実行結果は次のようになります。

f:id:n_soushi:20170224192759p:plain

TestCafeは自身でテスト用のブラウザを持たずユニークなURLを生成してテスターにブラウザを選ばせることで多種多様なブラウザのテストを可能にします。

このリモート機能を活用してE2EテストをChatOpsに組み込むアイデアを考え実装してみました。

TestCafeのRemoteConnetionをChatOpsに導入する

E2Eテストの運用にChatOpsを導入します。テスターはslackからBot経由でテスト開始を通知してBotとTestCafeが連携します。
それぞれ図にすると次のようになります。

リモートURLをslackで受け取るまで

f:id:n_soushi:20170224170519p:plain

  1. テスターはslackに@bot test web送信する。
  2. Botはイベントを受け取りTestCafeにリモートURLの生成依頼を通知する。
  3. TestCafe(hapi)は生成依頼を受け取りリモートURLを生成してBotへ返す。
  4. BotはリモートURLをルームに送信する。

こんな感じの流れで補足として次のような内容があります。

  • docker-composeでHubotとTestCafeのサビースをつくりコンテナ化した。
  • HubotとTestCafeはHTTP通信でリモートURLのやり取りを行う。
  • TestCafeにはHubotのようなwebhookはないのでhapi.jsで簡易的なAPIを作成した。
  • リモートURLの生成はドキュメントにもあるように簡単に行える。
createTestCafe('localhost', 1337, 1338)
    .then(testcafe => {
        runner = testcafe.createRunner();
        return testcafe.createBrowserConnection();
    })
    .then(remoteConnection => {

        const url = remoteConnection.url
        // ここでslackにリモートURLを送信する
        
        remoteConnection.once('ready', () => {
            runner
                .src('tests/test.js')
                .browsers(remoteConnection)
                .reporter('custom-reporter')
                .run()
                .then(function () { /* ... */ })
                .then(failedCount => { /* ... */ })
                .catch(error => { /* ... */})
        });
    });

※ 抜粋したコードなので詳細はgithubで確認できます

リモートURLを好きなブラウザで開きテスト結果を受け取るまで

f:id:n_soushi:20170224180736p:plain

  1. テスターは好きなブラウザでリモートURLを開く。
  2. TestCafeはリモートURLのアクセスを検知してテストを実行する/テスト結果をHubotに送信する。
  3. Hubotは受け取ったテスト結果をルームに送信する。

こんな感じの流れで補足として次のような内容があります。

  • TestCafeにはReporterクラスをカスタマイズすることでテスト結果のフォーマットを拡張することができる
  • コンソールにテスト結果を表示するのではなくHubotのwebhookに送信したいのでRerporterクラスを拡張した

実際のslackでBotとやり取りしている画面キャプチャ

f:id:n_soushi:20170224183659p:plain

テスターはslackをコントローラーにしてE2Eテストを開始から完了(レポート受信)までワンストップで行えます。

まとめ

  • 当初のTestCafeのリモート機能を利用したE2EテストをChatOpsに組み込むアイデアを実現できた。
  • slackと連動してattachmentをつかってレポート通知をリッチな表示にできた。
  • レポート内容は他にもあるので実運用に必要な情報の精査が必要。
  • TestCafeでテストを書いてみた、というテーマには触れていないので次回はPageModelをベースにTestCafeでE2Eテストを書いてみる。

ソースを公開しています

github.com

nginx-rtmp-module + FFmpeg + HLSで動画配信ができるdocker-composeをつくった

タイトルのdocker-composeをつくっていきます。ローカルで配信確認したいときにシュッと起動できるようにします。
次のような構成でつくりました。

コンテナ構成

f:id:n_soushi:20170217102235p:plain

それぞれのコンテナについてまとめます。

RTMP server

  • nginx-rtmp-moduleをつかってRTMP serverをたてる。

github.com

  • RTMP serverは`rtmp context`でストリームを受け取るapplicationを追加(`application encoder`)。
  • `application encoder`はストリームを受け取るとFFmpegで動画を3つのビットレードにエンコードする。
  • `application encoder`でエンコードした動画をHLS変換する`application hls`へストリームする。
  • `application hls`は変換したm3u8、tsファイルを`/data/hls`に配置する。
  • `/data/hls`はホストで同期されていてStreaming serverからもm3u8、tsファイルを参照できるようにしている。
rtmp {
    server {
        listen 1935;

        application encoder {
            live on;

            exec ffmpeg -i rtmp://localhost:1935/$app/$name
                        -c:a aac -strict -2 -b:a 32k -c:v libx264 -x264opts bitrate=128:vbv-maxrate=128:vbv-bufsize=128 -rtbufsize 100M -bufsize 256k -preset veryfast -f flv rtmp://localhost:1935/hls/${name}_low
                        -c:a aac -strict -2 -b:a 64k -c:v libx264 -x264opts bitrate=256:vbv-maxrate=256:vbv-bufsize=256 -rtbufsize 100M -bufsize 512k -preset veryfast -f flv rtmp://localhost:1935/hls/${name}_mid
                        -c:a aac -strict -2 -b:a 128k -c:v libx264 -x264opts bitrate=512:vbv-maxrate=512:vbv-bufsize=512 -rtbufsize 100M -bufsize 1024k -preset veryfast -f flv rtmp://localhost:1935/hls/${name}_high;
        }

        application hls {
            live on;

            hls on;
            hls_path /data/hls;
            hls_nested on;
            hls_fragment 2s;

            hls_variant _low BANDWIDTH=160000;
            hls_variant _mid BANDWIDTH=320000;
            hls_variant _high  BANDWIDTH=640000;
        }
    }
}

Broadcast server

  • mp4ファイルをFFmpegを使いループ再生させRTMP serverへストリームしている。
  • Broadcast serverはコンテナの1つのためlocalhostではRTMP serverに接続できない。
  • 次のように環境変数を参照するようにしている。
ffmpeg -re -stream_loop -1 -i '/data/broadcast_source.mp4' -f flv "${RTMP_SERVER_URL}/${STREAM_NAME}"
  • docker-composeで環境変数 'RTMP_SERVER_URL'と'STREAM_NAME'を定義している。

Streaming server

  • NginxでHTTP serverをたてる。
  • typesに'm3u8'と'ts'を追加している。
  • rootを'/data'としてRTMP serverと同期しているm3u8とtsファイルにアクセスしている。
server {
        listen 80;

        location /hls {
            types {
                application/vnd.apple.mpegurl m3u8;
                video/mp2t ts;
            }
            root /data;
            add_header Cache-Control no-cache;
            add_header Access-Control-Allow-Origin *;
        }
    }
}

docker-compose

ホストとコンテナでファイルを同期しているのでdindを使いホストもコンテナ化しています。

ホスト側のdocker-compose

version: "3"

services:
  dind_streaming_host:
    container_name: streaming_host
    image: docker:1.13.0-dind
    privileged: true
    ports:
      - 5301:2375
      - 1935:1935
      - 80:80
      - 5000:5000
    volumes:
      - ./video/clouds_over_the_mountain_hd_stock_video.mp4.mp4:/var/container/data/broadcast_source.mp4

コンテナに必要なポートと`Broadcast server`から配信する動画をコンテナに同期しています。

コンテナ群のdocker-compose

version: "3"

services:
  rtmp:
    container_name: rtmp_server
    build: ./container/nginx_rtmp
    ports:
      - 1935:1935
    volumes:
      - /var/container/data:/data
      - /var/container/log/rtmp/nginx/:/var/log/nginx

  stream:
    container_name: stream_server
    build: ./container/nginx_stream
    ports:
      - 80:80
    volumes:
      - /var/container/data:/data
      - /var/container/log/stream/nginx/:/var/log/nginx

  broadcast:
    container_name: broadcast_server
    build: ./container/ffmpeg_broadcast
    depends_on:
      - rtmp
    environment:
      RTMP_SERVER_URL: "rtmp://rtmp:1935/encoder"
      STREAM_NAME: broadcast
    volumes:
      - /var/container/data/:/data

起動方法と動画配信方法

ホストとコンテナでファイルを同期しているのでdindを使いホストもコンテナ化しているので起動にはdindとdirenvが必要です。

docker-composeの起動方法

まずはホスト側の起動から。
もしdindのイメージがなければプルします。

docker pull docker:1.13.0-dind

ホスト側を起動します。

$ cd (path-to 'streaming-host-sample')
$ docker-compose up -d

続いてコンテナ群をまとめているdocker-composeを起動します。

$ cd (path-to 'streaming_host')
direnv: loading .envrc
direnv: export +DOCKER_HOST
$ docker-compose up -d

※ 'streaming_host'ディレクトではdirenvによってディレクトリでは環境変数`DOCKER_HOST`が先に起動したdockerのホストに切り替わっています。

これですべてのコンテナが起動できました。

動画の確認方法

コンテナが起動できたらsafariで`http://localhost/hls/broadcast.m3u8`にアクセスすると`Broadcast server`から配信している動画を再生できます。
今回はプレイヤーを用意せずsafariで直接m3u8を参照しています。

f:id:n_soushi:20170217113815p:plain

サンプルの動画はDISTILLからDLしました。

www.wedistill.io

ライブ配信方法

wirecastなどの配信ツールを使うことでライブ配信ができます。

www.telestream.net

出力先のRTMPサーバに'rtmp://localhost:1935/encoder'を指定します。
ストリームを'live'として配信した場合は`http://localhost/hls/live.m3u8`にアクセスするとライブ配信した動画を確認できます。

まとめ

ソースを公開しています

github.com

mackerelのグラフアノテーションをChatOpsに加えてみた

mackerelからリリースされたグラフアノテーションを追加できるコマンドラインツールをgoで作りました。
作ったコマンドをBot経由で実行できるうようにしてチームのChatOpsに加えていきたいのでbot scriptも作りました。
コマンド作成方法やslack経由で実行できるようにする手順などの紹介エントリです。

グラフアノテーション

mackerelのグラフアノテーションについてはリリースから確認できます。

mackerel.io


ローカルで起動したコンテナのグラフを対象にアノテーションをつけると次のようになりました。
f:id:n_soushi:20170209131211p:plain

このアノテーションの作成をslackのBotにお願いして簡単にアノテーションが追加できるオペレーションを導入します。

コマンドラインツールをつくる

それではmackerelにグラフアノテーションを追加するコマンドラインツールを作っていきます。

クライアントにはmackerel-client-goを使います。

github.com


コマンドラインライブラリはcobraを使います。

github.com

cobraはKubernetesやDocker(distribution)などで採用されています。
cobraを使えば簡単にサブコマンドを作れます。

どんなコマンドを作るか

次のようなコマンドを作ります。

$ graph-annotation post -s 'アノテーションのタイトル' MackerelRole1 MackerelRole2

flagの-sでグラフに表示されるアノテーションのタイトルを指定できます。
パラメータにはrole名を複数指定できます。
チャットからメッセージを入力したいのでflagsを少なくします。

ファイル構成

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

./cmd
├── graph-annotations
│   └── main.go
├── post.go
└── root.go

次からはcobraでコマンドを作る手順です。

コマンドの概要はroot.goにまとめる

コマンドの概要をroot.goにまとめます。

var RootCmd = &cobra.Command{
	Use:   "graph-annotations",
	Short: "Graph-annotations is a very simple tool for mackerel graph annotations API.",
	Long:  "Complete API documentation is available at https://mackerel.io/api-docs/entry/graph-annotations",
	Run: func(cmd *cobra.Command, args []string) {
	},
}

func init() {
	cobra.OnInitialize()
}
グラフアノテーションの追加はpost.goにまとめる

グラフアノテーションの追加(mackerel-client-goをつかっているところ)はpost.goに実装しています。

func init() {
	postCmd.Flags().StringVarP(&title, "title", "s", "", "required: annotation title")
	postCmd.Flags().StringVar(&description, "description", "", "[optional] annotation details")
	postCmd.Flags().StringVar(&service, "service", os.Getenv("MACKEREL_SERVICE_NAME"), "required: service name, when it's empty value then will use enviroment variable of 'MACKEREL_SERVICE_NAME''")
	RootCmd.AddCommand(postCmd)
}

var title string
var description string
var service string

var postCmd = &cobra.Command{
	Use:   "post",
	Short: "Creating graph annotations",
	Long:  "Tne post command creates graph annotations via graph-annotations API.",
	Example: `graph-annotations post -s 'deploy application' ExampleRole1 ExampleRole2
graph-annotations post --service ExampleService -s 'deploy application' ExampleRole1 ExampleRole2`,
	RunE: func(cmd *cobra.Command, args []string) error {

		// flagのバリデーションを省略しています

		time := time.Now().Unix()
		client := mkr.NewClient(os.Getenv("MACKEREL_API_KEY"))

		annotation := &mkr.GraphAnnotation{
			Service:     service,
			Roles:       args,
			From:        time,
			To:          time,
			Title:       title,
			Description: description,
		}

		err := client.CreateGraphAnnotation(annotation)

		if err != nil {
			return errors.Wrap(err, "client error.")
		}


		fmt.Printf("completed. params title:%s, from:%d to:%d, service:%s, roles:%s", title, time, time, service, args)
		return nil
	},
}

このようにグラフの追加はpost.goにして削除が必要であればdelete.goを追加してサブコマンドを分けることができます。

※ 今回のコマンドではアノテーションをつける時刻範囲は現在時刻にしています。
※ mackerelのサービス名の指定がなければ環境変数の'MACKEREL_SERVICE_NAME'を参照しています。

post.goで整理したコマンドのヘルプ

post.goに整理したコマンドのヘルプは次のように参照できます。

$ graph-annotation help post
Tne post command creates graph annotations via graph-annotations API.

Usage:
  graph-annotations post [flags]

Examples:
graph-annotations post -s 'deploy application' ExampleRole1 ExampleRole2
graph-annotations post --service ExampleService -s 'deploy application' ExampleRole1 ExampleRole2

Flags:
      --description string   [optional] annotation details
      --service string       required: service name, when it's empty value then will use enviroment variable of 'MACKEREL_SERVICE_NAME'' (default "local")
  -s, --title string         required: annotation title

hubotを起動してslackに常駐させたBotにコマンドを実行させる

ローカルでhubotを起動してslackを連携させます。

slackで発行したトークンを環境変数に加えます。

$ export HUBOT_SLACK_TOKEN=<your slack token>

hubotを起動します。

$ cd (path-to-hubot)
$ ./bin/hubot --adapter slack

すべてローカルで実行することでローカルで起動したhubotがトークンを使いslackと連携できます。
f:id:n_soushi:20170209141526p:plain

slackでの利用イメージ

次のようなメッセージをBotに送ると先程つくったコマンドが実行されるようにします。

@localbot note -s 'プッシュ通知を送信しました' web
コマンドを実行するscript

コマンドを実行するscriptは次のように'note '以降の文字列をコマンドに渡して実行しています。

child_process = require 'child_process'

module.exports = (robot) ->
  robot.respond /note\s+(.+)$/, (msg) ->
    option = msg.match[1]

    child_process.exec "graph-annotation post #{option}", (error, stdout, stderr) ->
          if !error
            output = stdout+''
            msg.send output
          else
            output = stderr+''
            msg.send output

slackからメッセージを送ってみる

f:id:n_soushi:20170209142943p:plain

bot経由でグラフアノテーションに追加できました。

mackerelのグラフにも追加したアノテーションが表示されました。
f:id:n_soushi:20170209144516p:plain

まとめ

  • mackerel-client-goが提供されているのでChatOpsに組み込む工程に集中できました。
  • その他の言語でもクライアントライブラリは提供されているのでChatOps以外にもデイリーのバッチ処理実行前や後でアノテーションを追加してログを残す、なんてこともできます。
  • 今回のmackerelの設定はmicroservice毎にroleを分けていることを想定して複数のroleを指定できるようにしました。
  • アノテーションの範囲を時間指定できるのでflagに追加してもよさそうです。今回は簡単にchatでアノテーションをつけられることをコンセプトに時間の指定は省略しました。

ソースを公開しています。

github.com

Pact Broker DockerコンテナをつかってPact Broker環境を構築してみた

Consumer-Driven Contract テストをフレームワークさせるPactをつかったサンプルプロジェクトを前回のエントリでは紹介しました。
naruto-io.hatenablog.com

前回のエントリではPactファイルをConsumerとProviderともにファイルシステムを用いて参照していました。
Pact Brokerを導入すればPactファイルのレポジトリ環境が構築できます。
今回のエントリではPact Brokerの構築を紹介します。

Pact Broker

Pact Brokerはこちらのgithubレポジトリから参照できます。
github.com

Pact Brokerの特徴を抜粋すると

  • ConsumerとProviderの間のPact共有の課題を解決する
  • Pactの管理をPact Brokerが一元管理するためConsumerとProviderともにPact管理/Pactリリースの手間がなくなる
  • 最新のAPIドキュメント管理をPact Borkerが保証する
  • サービスが提供するAPIのインタラクションが確認できる
  • microserviceの依存関係をビジュアライズする

特徴についてはスクリーンショットも合わせて参照ください。

Pact Broker Docker container

Pact Brokerをローカルに構築していきます。Pact BrokerのDockerコンテナが用意されていますので今回はこちらを使います。

https://hub.docker.com/r/dius/pact_broker/

DBにPostgresを推奨していますので合わせてPostgresのDocker Containerを使います。
ここで2つのコンテナが必要になったのでDocker Composeで構成をまとめるついでにMackerelのコンテナも構成に入れてコンテナ監視もさせてみます。

dind(Docker in Docker)をつかってローカルでPact Brokerを動かす

Mackerelコンテナはホストのdocker.sockをvolumesを用いてコンテナにリンクさせる必要があるのでローカルで動かすときはDocker In Dockerがしたくなります。
ローカルPCにホストとなるコンテナを立てて、その中にPact Broker、Postgres、Mackerelのコンテナをぶら下げたいのですが、この課題を解決してくれるのがこちらの記事です。
blog.stormcat.io

この記事に習い次のような構成でDocker in DockerをローカルPCに構築しました。

pact-broker-host
├── container
│   ├── mackerel-agent
│   │   ├── Dockerfile
│   │   ├── mackerel-agent
│   │   │   └── mackerel-agent.conf
│   │   └── startup.sh
│   ├── pact-broker
│   │   └── Dockerfile
│   └── postgres
│       ├── Dockerfile
│       └── init_db.sql
└── docker-compose.yml

dind + direnvコンボのローカル環境構築はとても捗ります。ぜひ参考に。

Pact Brokerの機能をザッと確認

先のコンテナ構成を起動しlocalhost:8080にアクセスするとPact Brokerの管理ツールがお目見えします。

1:ConsumerがPactファイルをPact Brokerにパブリッシュする

Pact BrokerにPactファイルがないと機能が確認できませんので次のPactファイルをPact Brokerへパブリッシュします。

Pactファイル

{
	"consumer": {
		"name": "gateway_service"
	},
	"provider": {
		"name": "user_service"
	},
	"interactions": [
		{
			"provider_state": "there is a user named 1192-User",
			"description": "a get request for a user",
			"request": {
				"method": "GET",
				"path": "/user/1192"
			},
			"response": {
				"body": {
					"Name": "1192-User"
				},
				"headers": {
					"Content-Type": "application/json"
				},
				"status": 200
			}
		}
	],
	"metaData": {
		"pactSpecificationVersion": "1.1.0"
	}
}

Pactファイルをパブリッシュする

curl -v -XPUT \-H "Content-Type: application/json" \
-d@pacts/gateway_service-user_service.json \
http://localhost:8080/pacts/provider/user_service/consumer/gateway_service/version/1.0.1

運用イメージとしてはこのパブリッシュアクションをCircleCIなどのビルドタスクに入れます。

※ 1.0.1のバージョンを更新していくことでPact Brokerがバージョン管理をしてくれます。

2:Pact Brokerではgateway_service(Consumer)とuser_serivce(Provider)の関係が管理されている

ConsumerとProviderのリスト
f:id:n_soushi:20170204232556p:plain

  • user_serviceのConsumerがProviderであることが明確になっています。


パブリッシュしたPactファイルから生成されたAPIドキュメント
f:id:n_soushi:20170204232645p:plain

  • 今回はAPIのインタラクションが1つだけでしたが複数登録すればAPIドキュメントがPact Brokerに一元管理できます。


ConsumerとProviderのNetwork Graph
f:id:n_soushi:20170204235439p:plain

  • サービスから矢印が向いているとConsumer→Providerの関係を表します。
  • このサービスはどのサービスから参照されているか図解してくれます。


※ 関係図を充実させるためにgateway_serviceとuser_serivce以外のサービスも増やしました。

3:Pact BrokerからPactファイルを参照してProvider側でCDCテストを実行する

サンプルのProviderはpact-jvmを使っています。
Pact Brokerと簡単に連携できる@アノテーションが用意されています。

@PactBroker(host = "localhost", port = "8080", tags = arrayOf("latest"))

上記のアノテーションを加えテストを実行するとPact BrokerからPactファイルを取り出しCDCテストが行われます。

まとめ

Pactを使えばCDCテストのフレームワーク化の恩恵が受けられ更にPact Brokerを導入することでPactファイルの管理からAPIドキュメント、ConsumerとProviderのNetwork GraphなどなどCDCテストの運用の手助けが手厚く受けられます。
管理下のmicroserviceすべてにCDCテスト + Pactの導入は腰が重いのでクリティカルなmicroservice間の連携の一部から導入を開始したり、外部に公開するAPIはProviderとしてPactファイルのテストを導入してConsumer側にAPI仕様をクリアにすることもできますしCDCテスト+Pactの知見を活かせるところを今後は探っていきます。

ソースを公開しています

github.com

関連エントリ

naruto-io.hatenablog.com

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します