DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

SWET視点のDroidKaigi 2018参加レポート

はじめまして。SWETの外山(@sumio_tym)です。
2018年1月よりSWETで働いています! どうぞよろしくお願いします!

さて、2018年2月8日(木)から2日間にわたって開催されたDroidKaigi 2018の参加レポートをお届けします。
DroidKaigi 2018では、私達DeNA SWETグループからも、私を含めて3名が登壇しました!

f:id:swet-blog:20180215202534j:plain
(C) 2018 DroidKaigi実行委員会

このエントリでは、SWETらしく、テスティングに関係するセッションを以下の2つに分けてご紹介します。

  • DeNA SWETメンバーによる発表
  • DeNA以外の方による発表

DeNA SWETメンバーによる発表

Androidで利用できるデバイスファームのメリット・デメリットの紹介

duckさんによる発表です。

サーバーで集中的に管理されたAndroid端末を、遠隔操作・テストできる「デバイスファーム」について、 以下の観点で比較していました。

  • 端末を操作できるか?
  • 自動テストで何が動かせるか?
  • 利用者がテストを用意しなくても良い「モンキーテスト」ができるか?
  • adbで接続できる「ダイレクトアクセス」ができるか?
  • CI/CDと連携可能か?

また、最後に、以下の点を強調していたのが印象的でした。

「必ず、無料枠で、自分達の業務に適用できるか試してみること」

デバイスファーム選びの参考にしてみてください。

UIテストの実行時間を短縮させる方法

tarappoさんによる発表です。

前半では、UIの自動テストに関する以下の点について、具体例を挙げながら解説していました。

  • テスト実行時間が増加しやすい要因
  • テスト実行時間増加により引き起こされる問題

後半では、実行時間短縮のために必要な、以下のプラクティスを解説していました。

  • 無駄にUIの自動テストを増やさない
  • sleepの利用を減らす
  • 事前にテストアセットを用意する
  • テストを実行させる端末の種類を減らす
  • テストケースを分割して、テストの実行を並列化する

特に、単純な並列化だけではスケールしない場合に、 ボトルネックを特定しつつ更に並列度を上げていく過程はとても参考になると思います。

Espressoテストコードの同期処理を究める

外山の発表です。

AndroidのUIテストツールEspressoを使っていると、 テスト対象アプリのUI更新の完了を待てずに失敗することがあります。

その問題を解決するために必要なIdlingResourceについての知識や、 UI Automatorを併用してUI更新完了を待ち合わせる方法について説明しました。

独自の非同期処理機構を持つRxJavaなどを採用しているアプリでは、Espressoによるテストの失敗頻度が高くなります。 そのようなときに、この発表を参考にして、失敗しないEspressoテストに書き換えてみてください。

また、外山が過去のDroidKaigiで発表した以下の内容も役に立つと思います。あわせて参照してみてください。

DeNA以外の方による発表

はじめてのUnit Test

@fushiroyamaさんによるハンズオンです。 自分のEspressoの発表と時間が重なっていたため、参加は叶わなかったのですが、スライドを読むだけでも参考になります。

このスライドでは、AndroidのLocal Unit Testを書くのに必要な知識が、MockitoやRobolectricの使い方も含めて丁寧に解説されています。

ハンズオンに利用するソースコードも公開されていますので、 AndroidのLocal Unit Testに入門したい方はチャレンジしてみると良いと思います。

How to improve your MVP architecture and tests

kiriminさんによる発表です。

MVPアーキテクチャを採用したアプリにおいて、Presenter部分をテストする方法を解説する内容でした。

  • MVP採用時にありがちなアンチパターンの解説
  • 正しくViewとPresenterを分離し、Mockitoを使ってテストを書く方法

が丁寧に説明されており、具体的でとても分かりやすい内容でした。

MVPアーキテクチャを採用するときはもちろん、そうでないときでも、 Androidのユニットテストを書くときの参考にすると良いと感じました。

Moving Forward with JUnit 5

Marcel Schnelleさんによる発表です。

JUnit4が抱えている問題の説明から始まり、以下のような流れで解説していました。

このGradleプラグインはAndroid Test Night #1でも少し話題になっていました。

当時はLocal Unit Testしかサポートしていませんでしたが、 現在ではInstrumented Testもサポートしている(Experimental)とのことです。

AndroidでJUnit4が使えるようになるまでは長い時間が必要でしたが、JUnit5は案外すぐに使えるようになるかも知れません。 今後が楽しみですね!

おわりに

DroidKaigi 2018のセッションのうち、テスティングに関係するものをご紹介しました。

SWETグループでは、DroidKaigiでの登壇に限らず、積極的に対外的なアウトプットを行っています。 加えて、SWETグループ主催の勉強会である、Android Test NightiOS Test Nightも開催していますので、ご興味の有る方は是非ご参加ください。

また、SWETグループではAndroidに限らずテストの自動化やCI/CDに興味があり、私達と一緒に働いてくれる仲間を募集しています。ご応募お待ちしています!

Golang testingことはじめ(3)〜アプリケーションのテスト〜

こんにちは。
2回にわたってGolang標準の testing パッケージを使ったユニットテストについてお伝えしてきました。

今回はGolangで作成したアプリケーションをテストする際に利用できるライブラリなどについて紹介します。

この文章中に登場するサンプルは GitHub にありますので、実際に動作させることが可能です。

$ go get github.com/duck8823/sample-go-testing
$ cd $GOPATH/src/github.com/duck8823/sample-go-testing
$ git checkout refs/tags/blog
$ dep ensure # 依存パッケージのダウンロード

なお、文章中のコマンドは全てバージョン1.9.2での動作を確認しています。

アプリケーションのテスト

アプリケーションをテストする場合はいくらかのテクニックが必要になります。
ここでは、モックを使ったテストやウェブアプリケーションのrouting(エンドポイント)のテストについてGolangでの書き方について記述します。

モック

通常、ある程度の大きさ以上のアプリケーションを作成する場合、複数のパッケージに分けて分割します。 分割したアプリケーションのそれぞれのコードをテストする場合、モックを利用して外部(テスト対象以外)の依存を減らすことが望ましいです。
Golangでは gomock が提供されており、 testing フレームワークと組み合わせて利用することができます。
gomock では静的なモック用のソースコードを作成します。

インストール

モックを生成する mockgen はcliツールですが、go get でインストールできます。
Golangはツールの配布・利用も簡単です。

$ go get github.com/golang/mock/mockgen

モックの作成

モックはインタフェースに対して作成します。

foods/food.go には food インタフェースが含まれているので、これに対してモックを生成します。

foods/food.go

package foods

type Food interface {
    Name() string
}

type Apple struct {
    cultivar string
}

func NewApple(cultivar string) *Apple {
    return &Apple{cultivar}
}

func (apple *Apple) Name() string {
    return apple.cultivar
}

mockgenコマンドを実行します。-sourceオプションでインタフェースが含まれるソース、-destinationオプションで出力先を指定します。

$ mockgen -source=foods/food.go --destination foods/mock_foods/mock_foods.go

foods/mock_foods/mock_foods.go

// Code generated by MockGen. DO NOT EDIT.
// Source: foods/food.go

// Package mock_foods is a generated GoMock package.
package mock_foods

import (
    gomock "github.com/golang/mock/gomock"
    reflect "reflect"
)

// MockFood is a mock of Food interface
type MockFood struct {
    ctrl     *gomock.Controller
    recorder *MockFoodMockRecorder
}

// MockFoodMockRecorder is the mock recorder for MockFood
type MockFoodMockRecorder struct {
    mock *MockFood
}

// NewMockFood creates a new mock instance
func NewMockFood(ctrl *gomock.Controller) *MockFood {
    mock := &MockFood{ctrl: ctrl}
    mock.recorder = &MockFoodMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockFood) EXPECT() *MockFoodMockRecorder {
    return m.recorder
}

// Name mocks base method
func (m *MockFood) Name() string {
    ret := m.ctrl.Call(m, "Name")
    ret0, _ := ret[0].(string)
    return ret0
}

// Name indicates an expected call of Name
func (mr *MockFoodMockRecorder) Name() *gomock.Call {
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockFood)(nil).Name))
}

デフォルトのパッケージ名はソースとなるパッケージに mock_ の接頭辞をつけたものになります。これは -package オプションで変更することができます。
その他のオプションは gomock -help で確認することができます。

モックを使ったテスト

インタフェース food を引数にした以下の関数に対してテストを記述します。

func (duck *Duck) Eat(food foods.Food) string {
    return fmt.Sprintf("%s ate %s", duck.Name, food.Name())
}

モックインスタンスは以下のように作成します。

ctrl := gomock.NewController(t)
food := mock_foods.NewMockFood(ctrl)

作成したモックから EXPECT() に続いて関数を指定し、Return の引数で戻り値を指定することができます。

food.EXPECT().Name().Return("kougyoku")

上記の場合、 Name() が呼ばれたら "kougyoku" を返します。

animals/animals_07_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods/mock_foods"
    "github.com/golang/mock/gomock"
    "testing"
)

func TestDuck_Eat_02(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    food := mock_foods.NewMockFood(ctrl)
    food.EXPECT().Name().Return("kougyoku")

    duck := animals.NewDuck("tarou")
    actual := duck.Eat(food)
    expected := "tarou ate kougyoku"
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

上記の例でduck.Eat(food)では、FoodのName()を呼び出します。
Foodはインタフェースなので本来であればその実装に依存しますが、 モックを作成して特定の文字列を返却するように指定しているのでテスト実行時の依存を減らすことができます。

$ go test -v animals/animals_07_test.go animals/animal.go 
=== RUN   TestDuck_Eat_02
--- PASS: TestDuck_Eat_02 (0.00s)
PASS
ok      command-line-arguments  0.007s

この例ではName() の例では引数は必要ありませんでした。
戻り値を設定したい関数に引数が必要な場合は、呼び出される際の引数を指定して限定できます。
値が何でもいい場合は gomock.Any() を指定できます。

ここでは

type Hoge interface {
    Foo(foo string) string
}

というインタフェースを想定します。
モックを利用する場合は以下のようになります。

hogeMock.EXPECT().Foo(gomock.Any()).Return("bar")

フラグのテスト

コマンドライン引数やオプションなど、クライアントツールやサーバーアプリを作成した場合にフラグを利用することはよくあると思います。
フラグによって動作を変更する場合のテストを想定します。 標準の flag を利用してオプションを実現していた場合、設定する値を変えて複数回 flag.Parse() しようとすると flag redefined: ... とエラーになってしまいます。
そこで、フラグを設定する場合flag.Parse()は利用せずflag.NewFlagSet()で生成されたインスタンスを利用することで、テスタブルなコードを記述することができます。

フラグをパースする関数は以下のようにしました。

app/flag.go

package app

import (
    "flag"
    "os"
)

func ParseFlag(args ...string) string {
    flg := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
    optC := flg.String("c", "default value", "flag usage")
    flg.Parse(args)

    return *optC
}

上記の関数は以下のように利用することができます。

package main

import "github.com/duck8823/sample-go-testing/app"

func main() {
    optC := app.ParseFlag(os.Args[1:]...)
    fmt.Printf("option c: %s", optC)
}

関数ParseFlagをテストするコードは以下のようにしました。

app/flag_test.go

package app_test

import (
    "github.com/duck8823/sample-go-testing/app"
    "testing"
)

func TestParseFlag(t *testing.T) {
    t.Run("with argument, returns value", func(t *testing.T) {
        actual := app.ParseFlag("-c", "hello")
        expected := "hello"

        if actual != expected {
            t.Errorf("got: %s\nwont: %s", actual, expected)
        }
    })

    t.Run("with no argument, returns default value", func(t *testing.T) {
        actual := app.ParseFlag()
        expected := "default value"

        if actual != expected {
            t.Errorf("got: %s\nwont: %s", actual, expected)
        }
    })
}
$ go test -v app/flag_test.go
=== RUN   TestParseFlag
=== RUN   TestParseFlag/with_argument,_returns_value
=== RUN   TestParseFlag/with_no_argument,_returns_default_value
--- PASS: TestParseFlag (0.00s)
    --- PASS: TestParseFlag/with_argument,_returns_value (0.00s)
    --- PASS: TestParseFlag/with_no_argument,_returns_default_value (0.00s)
PASS
ok      command-line-arguments  0.013s

異なる値でフラグのパースを複数回行なっていますが、エラーにならず実行できています。

Webアプリケーションのroutingをテストする

ウェブアプリケーションを開発する場合は、実際にエンドポイントへアクセスして正しい結果が返ってくるかテストすることがあります。

アプリケーションを起動してテストする

利用するフレームワークがhttp.Handlerの実装となっている場合は後述のhttptestが利用できるので、そちらを利用した方がよいです。

ここでは、実際にアプリを起動してhttpクライアントからリクエストを投げ、ハンドラーの想定通りのメソッドが実行されているかどうかを確認します。
また、このサンプルではEchoフレームワークを利用しています。

ランダムで空いてるポートをListen

多くのウェブフレームワークではサーバー起動時にポートを指定しますが、指定したポートが利用されていた場合はテストが実行できません。
以下の関数はランダムで空いてるポートを取得します。

func randomAddress(t *testing.T) net.Addr {
    t.Helper()

    listener, err := net.Listen("tcp", ":0")
    listener.Close()

    if err != nil {
        t.Fatal(err)
    }
    return listener.Addr()
}

サーバーの起動

以下のソースコードについてのテストを想定します。
実際にサーバーを起動し、正しいレスポンスが返却されるかをチェックします。

app/server.go

package app

import (
    "fmt"
    "github.com/labstack/echo"
    "net/http"
)

func CreateServer() *echo.Echo {
    e := echo.New()
    e.GET("/hello", hello)
    return e
}

func hello(c echo.Context) error {
    message := fmt.Sprintf("Hello %s.", c.QueryParam("name"))
    return c.String(http.StatusOK, message)
}

利用するウェブフレームワークがフォアグラウンドでサーバーを起動する場合、テストケース内でサーバーを起動すると、そこでテストがストップしてしまいます。
別のターミナルセッションでサーバーを起動した後でテストを実行することもできますがテスト実行以外のプロセスに依存するのは避けた方がよいでしょう。

goルーチン内でサーバーを起動することで、テストケース内で完結するようにしてみます。

app/server_test.go

package app

import (
    "io/ioutil"
    "net"
    "net/http"
    "net/url"
    "testing"
)

func Test_RoutingWithStartServer(t *testing.T) {
    addr := randomAddress(t)

    s := CreateServer()
    go func() {
        s.Start(addr.String())
    }()

    reqUrl := &url.URL{
        Scheme:   "http",
        Host:     addr.String(),
        Path:     "hello",
        RawQuery: "name=duck",
    }
    resp, err := http.Get(reqUrl.String())
    if err != nil {
        t.Fatal(err)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Fatal(err)
    }

    actual := string(body)
    expected := "Hello duck."
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

func randomAddress(t *testing.T) net.Addr {
    t.Helper()

    listener, err := net.Listen("tcp", ":0")
    listener.Close()

    if err != nil {
        t.Fatal(err)
    }
    return listener.Addr()
}

これを実行すると、

go test -v app/server_test.go app/server.go
=== RUN   Test_RoutingWithStartServer

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.2.6
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:50985
--- PASS: Test_RoutingWithStartServer (0.00s)
PASS
ok      command-line-arguments  0.021s

ログからも実際にサーバーが起動しているのがわかります。

goルーチンを利用してサーバーを起動した場合の課題

上述の例では正しく動いているように見えました。
しかしサーバーの起動に時間がかかってしまう場合はどうでしょうか。

--- a/vendor/github.com/labstack/echo/echo.go
+++ b/vendor/github.com/labstack/echo/echo.go
@@ -587,6 +587,7 @@ func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 // Start starts an HTTP server.
 func (e *Echo) Start(address string) error {
+       time.Sleep(30 * time.Second)
        e.Server.Addr = address
        return e.StartServer(e.Server)
 }

ウェブフレームワークechoのコードに手を加え、サーバーが起動する際にSleep処理を追加しました。

$ go test -v app/server_test.go app/server.go
=== RUN   Test_RoutingWithStartServer
--- FAIL: Test_RoutingWithStartServer (0.00s)
        server_test.go:27: Get http://[::]:64158/hello?name=duck: dial tcp [::]:64158: getsockopt: connection refused
FAIL
exit status 1
FAIL    command-line-arguments  0.013s

サーバーが立ち上がっていないのでconnection refusedのエラーが出てしまいました。
この場合、サーバーが立ち上がるまで待つといった工夫をしなければなりません。

サーバーの起動待ちを実装するのは面倒ですが、簡単に実現できる方法が用意されています。
ここではgoルーチンを使わない方法でテストを書き直してみましょう。

httptestを利用してテストを実行する

Golangでは、Webサーバーのテストをサポートするnet/http/httptestも用意されています。
このパッケージを利用することで、routingのテストも容易になります。

app/server_01_test.go

package app

import (
    "io/ioutil"
    "net/http/httptest"
    "testing"
)

func Test_RoutingWitHttpTest(t *testing.T) {
    s := CreateServer()
    server := httptest.NewServer(s)
    defer server.Close()

    client := server.Client()

    resp, err := client.Get(server.URL + "/hello?name=duck")
    if err != nil {
        t.Fatal(err)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        t.Fatal(err)
    }

    actual := string(body)
    expected := "Hello duck."
    if string(actual) != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

空いているポートの検索・利用は httptest が行なってくれるので、 シンプルに記述することができます。

$ go test -v app/server_01_test.go app/server.go
=== RUN   Test_RoutingWitHttpTest
--- PASS: Test_RoutingWitHttpTest (0.00s)
PASS
ok      command-line-arguments  0.013s

httptest.NewServerすることでラップされたサーバーが立ち上がります。 ウェブフレームワークのStart()をコールしているわけではないので、Sleepされません。
また、ポートも自分で指定せずにランダムで空いているポートを利用してくれます。

リクエストをシミュレーションする

httptestではサーバーを起動するほか、リクエストをシミュレーションする方法を用意しています。

レスポンスはResponseRecorderを介して行われます。
Recorderを利用することでレスポンス内容の取得も楽になります。

package app

import (
    "net/http/httptest"
    "testing"
)

func Test_RoutingWitHttpTestSimulate(t *testing.T) {
    s := CreateServer()

    req := httptest.NewRequest("GET", "/hello?name=duck", nil)
    rec := httptest.NewRecorder()

    s.ServeHTTP(rec, req)

    actual := rec.Body.String()
    expected := "Hello duck."
    if actual != expected {
        t.Errorf("got: %s\nwont: %s", actual, expected)
    }
}

サーバーを立ち上げる方法ではレスポンスのBodyio.Reader型のため変換が必要でしたが、 レコーダーのBodyio.Readerにバッファ機能をつけたリッチなbytes.Budder型となっています。
そのためString()で変換することができ、テストもシンプルに記述できました。

$ go test -v app/server_02_test.go app/server.go
=== RUN   Test_RoutingWitHttpTestSimulate
--- PASS: Test_RoutingWitHttpTestSimulate (0.00s)
PASS
ok      command-line-arguments  0.011s

最後に

これまで3回に渡ってGolangが標準で提供しているテストの機能の一部を紹介しました。 言語としてテストを強くサポートしていることが感じられたのではないでしょうか。
まだまだ紹介しきれていない機能やオプションなどがたくさんあります。
今後も有益な機能について紹介できればと思っております。

Golangでtestingことはじめ(2)〜テストにおける共通処理〜

こんにちは。
前回からGolangのテストについて紹介をしています。
今回の記事はその2回目(テストにおける共通処理)に当たります。

この文章中に登場するサンプルは GitHub にありますので、実際に動作させることが可能です。

$ go get github.com/duck8823/sample-go-testing
$ cd $GOPATH/src/github.com/duck8823/sample-go-testing
$ git checkout refs/tags/blog
$ dep ensure # 依存パッケージのダウンロード

なお、文章中のコマンドは全てバージョン1.9.2での動作を確認しています。

テストの共通処理

いくつかのテストケースを書いていて、共通処理をまとめたいと思ったことはないでしょうか。
他言語のテスティングフレームワークには、必ずといっていいほどこの機能が提供されています。

BeforeAll / AfterAll

全てのテストケースの前と後に実行するBeforeAllAfterAllを実現したい場合、

func TestMain(m *testing.M)

を利用します。

animals/animals_03_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods"
    "os"
    "testing"
)

func TestMain(m *testing.M) {
    println("before all...")

    code := m.Run()

    println("after all...")

    os.Exit(code)
}

func TestDuck_Say_03(t *testing.T) {
    duck := animals.NewDuck("tarou")
    actual := duck.Say()
    expected := "tarou says quack"
    if actual != expected {
        t.Errorf("got: %v\nwant: %v", actual, expected)
    }
}

func TestDuck_Eat(t *testing.T) {
    duck := animals.NewDuck("tarou")
    apple := foods.NewApple("sunfuji")

    actual := duck.Eat(apple)
    expected := "tarou ate sunfuji"
    if actual != expected {
        t.Errorf("got: %v\nwant: %v", actual, expected)
    }
}

テストファイル内に TestMain が存在している場合、 go test はTestMainのみ実行します。 testing.MRun を呼ぶことで各テストケースが実行され、失敗か成功かに応じてコードを返却します。 最終的に os.Exit0 が渡ればそのテストファイルは成功、それ以外の値の場合は失敗になります。

Run の前後に処理を挟むことでBeforeAllAfterAllが実現できます。

$ go test -v ./animals/animals_03_test.go 
before all...
=== RUN   TestDuck_Say_03
--- PASS: TestDuck_Say_03 (0.00s)
=== RUN   TestDuck_Eat
--- PASS: TestDuck_Eat (0.00s)
PASS
after all...
ok      command-line-arguments  0.007s

BeforeEach / AfterEach

続いてテストケースごとに前処理や後処理を実行する方法です。

ループによるBeforeEachの実現

この方法は可読性も低く、テストケースを指定した場合無駄に処理が行われてしまうので推奨しません。

下記のサンプルコードではループとサブテストを利用し、 BeforeEachを実現しています。

animals/animals_04_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods"
    "testing"
)

func TestDuck_04(t *testing.T) {

    println("before all...")
    var duck *animals.Duck

    for _, testcase := range []struct {
        name string
        call func(t *testing.T)
    }{
        {
            "it says quack",
            func(t *testing.T) {
                actual := duck.Say()
                expected := "tarou says quack"
                if actual != expected {
                    t.Errorf("got: %v\nwant: %v", actual, expected)
                }
            },
        }, {
            "it ate apple",
            func(t *testing.T) {
                apple := foods.NewApple("sunfuji")

                actual := duck.Eat(apple)
                expected := "tarou ate sunfuji"
                if actual != expected {
                    t.Errorf("got: %v\nwant: %v", actual, expected)
                }
            },
        },
    } {
        println("before each...")
        duck = animals.NewDuck("tarou")

        // テストケースの実行
        t.Run(testcase.name, testcase.call)

        println("after each...")
    }
    println("after all...")
}

コードが長くなってしまいましたが、

duck = animals.NewDuck("tarou")

の行ではテストケース毎に変数 duck を初期化しようとしています。

コード全体を見るとケース名と関数をスライスにいれ、それをループしてサブテストとして実行させています。
これを実行してみると、

$ go test -v ./animals/animals_04_test.go 
=== RUN   TestDuck_04
before all...
before each...
=== RUN   TestDuck_04/it_says_quack
after each...
before each...
=== RUN   TestDuck_04/it_ate_apple
after each...
after all...
--- PASS: TestDuck_04 (0.00s)
    --- PASS: TestDuck_04/it_says_quack (0.00s)
    --- PASS: TestDuck_04/it_ate_apple (0.00s)
PASS
ok      command-line-arguments  0.007s

テストケースの前後に処理を挟むことができました。

また、--run オプションで特定のテストケースのみを実行することができます。
ある関数のリファクタリングをする場合など早いフィードバックが欲しい時は、テストケースを指定することで実行時間の短縮が期待できます。

$ go test -v ./animals/animals_04_test.go --run TestDuck_04/it_says_quack
=== RUN   TestDuck_04
before all...
before each...
=== RUN   TestDuck_04/it_says_quack
after each...
before each...
after each...
after all...
--- PASS: TestDuck_04 (0.00s)
    --- PASS: TestDuck_04/it_says_quack (0.00s)
PASS
ok      command-line-arguments  0.007s

この場合も正しく実行できていますが、before each...after each... が余分にプリントされてしまっています。 --run オプションでは上から順にテストを実行し、一致しないものをスキップするようです。
スキップしたテストケースについても前処理を実行してしまうため、前処理が重い場合は全体の実行時間が長くなってしまいます。

前処理に5秒のSleepを追加してみます。

animals/animals_04_test.go

--- a/animals/animals_04_test.go
+++ b/animals/animals_04_test.go
@@ -4,6 +4,7 @@ import (
        "github.com/duck8823/sample-go-testing/animals"
        "github.com/duck8823/sample-go-testing/foods"
        "testing"
+       "time"
 )
 
 func TestDuck_04(t *testing.T) {
@@ -41,6 +42,7 @@ func TestDuck_04(t *testing.T) {
                duck = animals.NewDuck("tarou")
 
                // テストケースの実行
+               time.Sleep(5 * time.Second)
                t.Run(testcase.name, testcase.call)
 
                println("after each...")
$ go test -v ./animals/animals_04_test.go --run TestDuck_04/it_says_quack
=== RUN   TestDuck_04
before all...
before each...
=== RUN   TestDuck_04/it_says_quack
after each...
before each...
after each...
after all...
--- PASS: TestDuck_04 (10.01s)
    --- PASS: TestDuck_04/it_says_quack (0.00s)
PASS
ok      command-line-arguments  10.014s

Sleepを追加した結果、全体の実行時間が10秒もかかってしまいました。 これでは、テストケースを指定しても実行時間の短縮になりません。
また、前後処理とテストケース内の処理が分離されてしまいコード全体の可読性も悪くなってしまっています。

関数による共通化

上述の例では、テストケースの外で共通処理を呼び出すとテストケースを指定した場合は無駄に共通処理が行われてしまいました。
各テストケース内で共通化した関数を明示的に呼び出すよう書き換えてみましょう。

animals/animals_05_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods"
    "testing"
    "time"
)

func TestDuck_05(t *testing.T) {
    t.Run("it says quack", func(t *testing.T) {
        duck := createInstance()

        actual := duck.Say()
        expected := "tarou says quack"
        if actual != expected {
            t.Errorf("got: %v\nwant: %v", actual, expected)
        }
    })

    t.Run("it ate apple", func(t *testing.T) {
        duck := createInstance()

        apple := foods.NewApple("sunfuji")

        actual := duck.Eat(apple)
        expected := "tarou ate sunfuji"
        if actual != expected {
            t.Errorf("got: %v\nwant: %v", actual, expected)
        }
    })
}

func createInstance() *animals.Duck {
    duck := animals.NewDuck("tarou")
    time.Sleep(5 * time.Second)

    return duck
}

可読性は非常に高くなりました。
また、

$ go test -v ./animals/animals_05_test.go --run TestDuck_05/it_says_quack
=== RUN   TestDuck_05
=== RUN   TestDuck_05/it_says_quack
--- PASS: TestDuck_05 (5.00s)
    --- PASS: TestDuck_05/it_says_quack (5.00s)
PASS
ok      command-line-arguments  5.011s

実行時間も前処理1回分になりました。

共通メソッドのエラー処理

共通メソッド内でエラーが発生した場合、テストを失敗させる必要があります。 そこで、共通メソッドに testing.TB インタフェースを渡してやります。 Fatal をコールすることでテストを失敗にし、その場でそのテストケースを終了させてやることができます。 Error の場合はテストを失敗にしますが、テストケースは引き続き実行されます。

--- a/animals/animals_05_test.go
+++ b/animals/animals_05_test.go
@@ -9,7 +9,7 @@ import (
 
 func TestDuck_05(t *testing.T) {
        t.Run("it says quack", func(t *testing.T) {
-               duck := createInstance()
+               duck := createInstance(t)
 
                actual := duck.Say()
                expected := "tarou says quack"
@@ -19,7 +19,7 @@ func TestDuck_05(t *testing.T) {
        })
 
        t.Run("it ate apple", func(t *testing.T) {
-               duck := createInstance()
+               duck := createInstance(t)
 
                apple := foods.NewApple("sunfuji")
 
@@ -31,9 +31,10 @@ func TestDuck_05(t *testing.T) {
        })
 }
 
-func createInstance() *animals.Duck {
+func createInstance(tb testing.TB) *animals.Duck {
        duck := animals.NewDuck("tarou")
        time.Sleep(5 * time.Second)
+       tb.Error("前処理で失敗しました.")
 
        return duck
 }
$ go test -v ./animals/animals_05_test.go
=== RUN   TestDuck_05
=== RUN   TestDuck_05/it_says_quack
=== RUN   TestDuck_05/it_ate_apple
--- FAIL: TestDuck_05 (10.00s)
    --- FAIL: TestDuck_05/it_says_quack (5.00s)
        animals_05_test.go:37: 前処理で失敗しました.
    --- FAIL: TestDuck_05/it_ate_apple (5.00s)
        animals_05_test.go:37: 前処理で失敗しました.
FAIL
exit status 1
FAIL    command-line-arguments  10.012s

Errorを使っているので残りのテストケースはちゃんと実行されていることが確認できますが、 2つのサブテストにおいて同一箇所で失敗したと表示されてしまいました。
特に1つのケース内で複数回コールされる関数の場合、どこで失敗したか特定しにくくなってしまいます。 これを、ケース内のどこで失敗したかわかるようにしてみます。

共通処理内で testing.TB インタフェースの Helper をコールすることで、 呼び出し部分が表示されるようになります。

先ほど変更したソースコードに、さらに変更を加えます。

--- a/animals/animals_05_test.go
+++ b/animals/animals_05_test.go
@@ -32,6 +32,8 @@ func TestDuck_05(t *testing.T) {
 }

 func createInstance(tb testing.TB) *animals.Duck {
+       tb.Helper()
+
        duck := animals.NewDuck("tarou")
        time.Sleep(5 * time.Second)
        tb.Error("前処理で失敗しました")
$ go test -v ./animals/animals_05_test.go
=== RUN   TestDuck_05
=== RUN   TestDuck_05/it_says_quack
=== RUN   TestDuck_05/it_ate_apple
--- FAIL: TestDuck_05 (10.01s)
    --- FAIL: TestDuck_05/it_says_quack (5.01s)
        animals_05_test.go:12: 前処理で失敗しました.
    --- FAIL: TestDuck_05/it_ate_apple (5.00s)
        animals_05_test.go:22: 前処理で失敗しました.
FAIL
exit status 1
FAIL    command-line-arguments  10.013s

表示されるソースコードの行数が変わっていることがわかります。 こちらの方がテストケース内のどこで失敗したか原因を探りやすくなると思います。

最後に

テストケース毎に実行する前処理や後処理は、Golangでは少し工夫が必要でした。
しかし、シンプルに記述することができ処理の流れが分かりやすいようになっていると思います。

次回はアプリケーションやツールのテストで利用できる機能(gomockhttptest)について紹介します。

Golangでtestingことはじめ(1)〜testingパッケージを使ったユニットテスト〜

こんにちは。
Golangが一般的に使われるようになってきてもう久しいですね。
最近作られたSWET製のツールでも、Golangを採用したものがあります。
そこで、Golangの標準テストパッケージtestingやその他についてまとめたいと思います。

今回から3回にわたり、

を紹介します。

この記事を読んで一通りGolangでテストがかけるようになると嬉しいです。

この文章中に登場するサンプルは GitHub にありますので、実際に動作させることが可能です。

$ go get github.com/duck8823/sample-go-testing
$ cd $GOPATH/src/github.com/duck8823/sample-go-testing
$ git checkout refs/tags/blog
$ dep ensure # 依存パッケージのダウンロード

なお、文章中のコマンドは全てバージョン1.9.2での動作を確認しています。

testingパッケージ

testingパッケージはGolangが標準で提供している、テスト全般を支援するパッケージです。
ベンチマークやカバレッジ、標準出力のテストなどカバーしている範囲は広く、サードパーティ製のテストフレームワークに頼らずともテストが記述できます。 標準なのでGolangをインストールしたら使えます。パッケージマネージャなどで指定する必要はありません。
以下のtypeについてユニットテストを書いてみましょう。

animals/animal.go

package animals

import (
    "fmt"
    "github.com/duck8823/sample-go-testing/foods"
)

type Duck struct {
    name string
}

func NewDuck(name string) *Duck {
    return &Duck{name}
}

func (duck *Duck) Say() string {
    return fmt.Sprintf("%s says quack", duck.name)
}

func (duck *Duck) Eat(food foods.Food) string {
    return fmt.Sprintf("%s ate %s", duck.name, food.Name())
}

foods/food.go

package foods

type Food interface {
    Name() string
}

type Apple struct {
    cultivar string
}

func NewApple(cultivar string) *Apple {
    return &Apple{cultivar}
}

func (apple *Apple) Name() string {
    return apple.cultivar
}

testing では、 _test.go で終わる名前のファイルにテストコードを書きます。
また、TestXxxで始まる関数についてテストを実行します。

なお、テストコードは対応するソースコードと同一のパッケージにすることでpackage privateな変数や関数を呼び出すことができ、 APIを必要以上公開せずにテストを書くことができます。

animals/animals_test.go

package animals

import "testing"

func TestDuck_name(t *testing.T) {
    duck := &Duck{"tarou"}
    actual := duck.name
    expected := "tarou"
    if actual != expected {
        t.Errorf("got: %v\nwant: %v", actual, expected)
    }
}

パッケージ名_test をテストコードを記述するパッケージ名として利用することもできます。 パッケージ外に公開したAPIを通してのみテストしたい場合は、こちらを利用するとよさそうです。
この場合、テスト対象のパッケージもインポートする必要があります。

animals/animals_01_test.go

package animals_test

import "testing"
import "github.com/duck8823/sample-go-testing/animals"

func TestDuck_Say(t *testing.T) {
    duck := animals.NewDuck("tarou")
    actual := duck.Say()
    expected := "tarou says quack"
    if actual != expected {
        t.Errorf("got: %v\nwant: %v", actual, expected)
    }
}

テストを記述したら実行しましょう。 go testの引数にはテストが記述されたファイルを指定することができます。 テストがプロダクトコードと同一のパッケージの場合、引数にテスト対象のファイルを含める必要があります。

$ go test animals/animals_test.go animals/animal.go

プロダクトコードとテストコードのパッケージを分けてimportしている場合は、単一のファイルでも実行可能です。

$ go test animals/animals_01_test.go

他の言語ではビルドツールやテストランナーを介して実行するものが多い中、 goのサブコマンドとしてtestを実行できることからも、言語として積極的にサポートしているのがわかります。

go testの引数はファイルの他、github.com/duck8823/sample-go-testing/animalsのようにパッケージを指定することもできます。 上記の記述だと冗長的になってしまいますが、パッケージの指定はディレクトリからの相対パスとして記述することもできます。

$ go test ./animals

パッケージングされたアプリケーションを作成している場合は ./... を指定することで全ディレクトリのテストを再帰的に実行してくれます。

$ go test ./...

(1.9より以前のバージョンの場合は)

$ go test $(go list ./... | grep -v /vendor)

実行結果は以下のように表示されます。

ok      command-line-arguments  0.007s

詳細な結果を見たい場合は、-vオプションが有効です。

$ go test -v animals/animals_01_test.go
=== RUN   TestDuck_Say
--- PASS: TestDuck_Say (0.00s)
PASS
ok      command-line-arguments  0.007s

アサーション

標準のtestingパッケージでは、アサーション用の関数が用意されていません。
Errorf などの関数を利用し、自分でエラーメッセージを書く必要があります。

duck := animals.NewDuck("tarou")
actual := duck.Say()
expected := "tarou says quack"
if actual != expected {
    t.Errorf("got: %v\nwant: %v", actual, expected)
}

testify のようなサードパーティ製のアサーションライブラリも存在しています。
これらのライブラリを利用することで、記述が容易になります。

import "github.com/stretchr/testify/assert"
duck := animals.NewDuck("tarou")
actual := duck.Say()
expected := "tarou says quack"
assert.Equals(t, expected, actual)

サブテスト

テストを階層構造にすることができます。
サブテストはテストケース内で t.Run をコールすることで実現できます。
第一引数にはテストケース名、第二引数には無名関数を渡すことができます。

animals/animals_02_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods"
    "testing"
)

func TestDuck(t *testing.T) {
    duck := animals.NewDuck("tarou")

    t.Run("it says quack", func(t *testing.T) {
        actual := duck.Say()
        expected := "tarou says quack"
        if actual != expected {
            t.Errorf("got: %v\nwant: %v", actual, expected)
        }
    })

    t.Run("it ate apple", func(t *testing.T) {
        apple := foods.NewApple("sunfuji")

        actual := duck.Eat(apple)
        expected := "tarou ate sunfuji"
        if actual != expected {
            t.Errorf("got: %v\nwant: %v", actual, expected)
        }
    })
}

テスト結果は以下のように表示されます。

$ go test -v ./animals/animals_02_test.go 
=== RUN   TestDuck
=== RUN   TestDuck/it_says_quack
=== RUN   TestDuck/it_ate_apple
--- PASS: TestDuck (0.00s)
    --- PASS: TestDuck/it_says_quack (0.00s)
    --- PASS: TestDuck/it_ate_apple (0.00s)
PASS
ok      command-line-arguments  0.006s

ベンチマーク

Golangのtestingパッケージでは、ベンチマークも標準でサポートしており、簡単に記述することができます。 ベンチマークは、関数を以下のように定義することで実現できます。

func BeanchmarkXxx(b *testing.B) {
    for i := 0; i < b.N; i++ {
        //  ここに処理を記述
    }
}

animals/animals_06_test.go

package animals_test

import (
    "github.com/duck8823/sample-go-testing/animals"
    "github.com/duck8823/sample-go-testing/foods"
    "testing"
)

func BenchmarkDuck_Say(b *testing.B) {
    duck := animals.NewDuck("tarou")

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        duck.Say()
    }
    b.StopTimer()
}

func BenchmarkDuck_Eat(b *testing.B) {
    duck := animals.NewDuck("tarou")
    food := foods.NewApple("sunfuji")

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        duck.Eat(food)
    }
    b.StopTimer()
}

ベンチマークの実行は -bench オプションをつけます。

$ go test -v -bench=. ./animals/animals_06_test.go
goos: darwin
goarch: amd64
BenchmarkDuck_Say-4     10000000               139 ns/op
BenchmarkDuck_Eat-4      5000000               407 ns/op
PASS
ok      command-line-arguments  3.965s

出力結果はループの回数とループ1回あたりの処理時間です。 -benchmem オプションをつけることで、メモリに関するベンチマークも取得することができます。

前処理や後処理がある場合は ResetTimerStopTimer 関数を呼び出して正しく測定できるようにしましょう。

func BeanchmarkXxx(b *testing.B) {
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        //  ここに処理を記述
    }
    b.StopTimer()
}

カバレッジ

カバレッジも標準でサポートされています。
-cover オプションをつけることで、テスト実行結果にカバレッジ情報が付加されます。

$ go test -cover ./animals
ok      github.com/duck8823/sample-go-testing/animals   10.014s coverage: 100.0% of statements

カバレッジレポートの出力

カバレッジレポートを出力するためにには -coverprofile オプションを利用しますが、 バージョン1.9(2018/01/08現在)では複数のパッケージにまたがって利用することができません。

$ go test -cover -coverprofile cover.out ./animals ./foods
cannot use test profile flag with multiple packages

1.10からはサポートされるようなので、試してみましょう。

beta版のインストールは以下の手順で行います。

go get golang.org/x/build/version/go1.10beta1
go1.10beta1 download

go コマンドをインストールした go1.10beta1 に置き換えて実行してみましょう。

$ go1.10beta1 test -cover -coverprofile cover.out ./animals ./foods
ok      github.com/duck8823/sample-go-testing/animals   10.009s coverage: 100.0% of statements
ok      github.com/duck8823/sample-go-testing/foods 0.006s  coverage: 100.0% of statements

cover.outの出力結果は以下のようになりました。

mode: set
github.com/duck8823/sample-go-testing/foods/food.go:11.39,13.2 1 1
github.com/duck8823/sample-go-testing/foods/food.go:15.35,17.2 1 1
github.com/duck8823/sample-go-testing/animals/animal.go:12.33,14.2 1 1
github.com/duck8823/sample-go-testing/animals/animal.go:16.32,18.2 1 1
github.com/duck8823/sample-go-testing/animals/animal.go:20.47,22.2 1 1

-coverprofile で出力されるカバレッジレポートは見にくいので、 htmlとして出力しましょう。
カバレッジレポートからhtmlを生成するのも標準機能です。

go tool cover -html=cover.out -o cover.html

f:id:swet-blog:20180115132650p:plain

最後に

今回は紹介しませんでしたが他にもテストのスキップや、標準出力のテストが容易になるExampleといった魅力的な機能があります。 Golangは 公式ドキュメント も充実しているので、是非ご覧ください。

次回はtestingパッケージを利用して共通処理(Before/After)を実現する方法について紹介します。

iOS Test Night 1周年を振り返る

iOS Test Nightを立ち上げたSWETの@tarapoです。

2016/11/18にはじまった「iOS Test Night」は2017/12/5の「iOS Test Night #6」で1周年を迎えました。

f:id:swet-blog:20171220185651p:plain

今回は、その1年を振り返り次の1年の抱負を語りたいと思います。

iOS Test Nightとは

iOS Test Nightとは、SWETが主催するiOSのテストに関係することについて語り合うことを目的とした勉強会です。

本勉強会をはじめた理由は、日本語でのiOSのテストに関係する情報の少なさにあります。 しかし、皆さんテストに関することは興味があるでしょうし、何かしら行っていると思っています。そこで、情報を共有する場所があれば良いのではと思ったのが発端です。

そして、本勉強で知見を得た人が何かしらアクションを起こすことにより、良いスパイラルが生まれれば情報が自然と増えていくだろうと考えました。

どれぐらい本勉強会により情報が増えたかは分かりませんが、iOS Test Night #6では本勉強会きっかけでテストをはじめた方の発表枠をもうけたところ全て埋まりました。 何かしらの良いスパイラルは生まれつつあると思っています。

参加登録数・発表数

この1年間(合計6回)のiOS Test Nightの参加登録数と発表数は以下のとおりです。

登録数 発表数
712 47(31)
  • 登録数はキャンセルを除いたconnpassへの登録数になります。
  • 発表数の()内はユニークな人数になります。

発表数は40をこえており、ここ1年で40以上ものiOSのテストに関係する情報が共有されたことになります。 これらの発表資料についてはTest Nightグループの資料を見ていただくのが良いと思います。

この発表資料の中からSWETメンバーの発表について軽く振り返りたいと思います。

ふりかえり

主催であるSWETから毎回登壇させて頂いていますが、この1年間でSWETのメンバーが発表した回数は9回になります。

最初の発表

最初に発表したのは「iOSアプリの自動テストをはじめよう」という内容で以下がその時の発表資料です。

本発表は、自動テストをはじめる際の注意点などを紹介したものです。 今後、自動テストをはじめる際にこの注意点を思い出してもらえればと思い、私が経験したことをベースにしつつ作成したものです。

SWETの発表の傾向

発表した内容を大まかに分類すると以下のような2パターンに分けられるかと思います。

上記にあるように、自動テストに関する知見の紹介や、自動テスト(主にUIテスト)をやりはじめた後にある大変なことの知見についての情報共有を行なってきました。

UIテストについて並列化を行いやすくなってきたのはXcode8、Xcode9になってからです。 そのためこの1年ではUIテストの並列化に対する話が多くなっていると言えます。

また、上記以外にもSWETが開発しているサービスについての紹介を1回しています。 あまりiOS Test Nightでは発表していないですが、SWETで開発しているサービスは色々ありますので、そのうち本ブログで紹介できればと思います。

あれから1年

iOS Test Nightをはじめて1年間。 この1年間の間に、iOSにおけるテスト周りには色々な変化がありました。

Xcode8からBuilding for TestingとTest Without Buildingが出来るようになり、Xcode9からはシミュレーターの多重起動も出来るようになりました。

その結果、UIテストにおける実行時間の短縮が以前に比べてやりやすくなっています。

またXcode9での大きな変化は、是非ともWhat's New in Testingと題したWWDC2017の発表をまだ見ていない方は見てもらうのが良いかと思います。

このように1年間という期間は短いようで、非常に色々なことが起こります。 今まで出来なかったことが出来るようになったり、大変だったことが簡単に出来るようになったりもします。

まだまだiOS Test Nightのネタは尽きそうにありません。

これからのTest Night

今回、本記事を執筆するにあたり今までに発表された方の資料を読み直しました。 最初はテストを知らないと言っていた方も、今では普通にテストの本を読んでいる方もいます。

是非、発表したときから今までの間にどのような変化があったかをiOS Test Nightで発表して欲しいなと思います。

これからのiOS Test Nightは次の1年においても、テストをこれからはじめる人にとってもテストで苦労している人にとっても、知見を共有できる有益な場として本勉強会が存在できればと思います。

最後に

最初、iOS Test Nightをはじめる時は登壇してくれる方や参加してくれる方がどれぐらいいるか不安でしたが、この1年間で多くの方に登壇・参加していただきました。

そして、多くの知見を共有してもらい懇親会で色々な話が出来ました。

次の1年も知見を共有する場として存在していきたいと思います。 次回のiOS Test Night #7でお会いしましょう。

ZaleniumをKubernetes/GKEで動かす

この記事はSelenium/Appium Advent Calendar 2017の第18日目です。

SWETのGLやってます@okitanです。

少し前ですが、「日本Seleniumユーザーコミュニティ」のエキスパートが教えるSelenium最新事情という連載にDockerでSelenium Gridを構築して複数マシンのブラウザ自動テストを行うという記事を書きました。 記事中では、公式のSelenium GridのDockerイメージを使う方法に加えて、現在SWETで注目して運用しているZaleniumを利用する方法を紹介しました。

本記事では、Kubernetesを利用し、Zaleniumの以下の2つの構築方法を紹介します。

なお、本記事ではKubernetesに関する用語が出て来ます。 KubernetesのドキュメントWEB+DB PRESS Vol.99の「実践Kubernetes」がわかりやすいと思います。

脱線しますが、WEB+DB PRESS Vol.99には、SWETの@tarappoが寄稿した「UIテスト自動化」も特集2として載ってますのでおすすめです。

Selenium GridとZaleniumについてのおさらい

Zaleniumは複数のマシンで分散してブラウザ操作の自動化を実現するSelenium Gridを構築するためのソフトウェアです。 Selenium Gridを利用するとGoogle Chrome・Firefox・Microsoft Edge等多種多様なブラウザを管理でき、クロスブラウザテスティングが簡単にできるようになります。 Zaleniumは公式のSelenium Gridに追加で以下の機能をもっています。 (Selenium Gridに関する詳細やZaleniumの機能の詳細については、元記事を読んでください)

  • Selenium nodeのオートスケール機能
  • ブラウザの動作をSelenium Gridの管理コンソール上からのライブで確認できる機能(ライブプレビュー)
  • ブラウザ動作の録画機能
    • 録画された動画をドライバのログと合わせてWeb上で一覧できるダッシュボードという機能があります

ここで注目したいのは、Selenium nodeのオートスケール機能です。自動テストを開発プロセスに組み込むにはある程度短い時間で開発に対してフィードバックを行う必要があります。 その上、自動テスト数が増えていくにしたがって増加する実行時間を短くするため、並列実行数を増やしていかないといけません。 そのために、実行時間が何分以内に収まるような自動テストの並列実行計画の戦略と、それを受け入れるだけのキャパシティのあるテスト実行環境が必要になります。 Zaleniumのオートスケール機能はこの後者の問題を解決してくれます。

そしてオートスケールと相性の良いのがクラウドです。 今日ではKubernetesを用いてクラウドでコンテナ群を管理するのが一般的です。 もちろん、ZaleniumもKubernetesに対応しています。 また、最終的にクラウドで動かすにしてもローカルで動作確認できると便利なので、自分の開発環境で試してみる用にminikubeでの説明もあわせておこないます。

なお、以下の環境で動作確認してます。

minikube上にZaleniumを立てる

事前準備

minikubeのインストール方法を簡単に説明します。 公式のminikubeのチュートリアルは、 deprecatedとなっているdocker-machine-driver-xhyveを利用しています。 本記事ではdocker-machine-driver-xhyveの代替となるHyperkit driverのインストール方法もあわせて紹介しておきます。

Homebrew Caskを利用してminikubeをインストールします。

$ brew cask install minikube

Hyperkit driverをダウンロードして配置します。

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/docker-machine-driver-hyperkit \
    && chmod +x docker-machine-driver-hyperkit \
    && sudo mv docker-machine-driver-hyperkit /usr/local/bin/ \
    && sudo chown root:wheel /usr/local/bin/docker-machine-driver-hyperkit \
    && sudo chmod u+s /usr/local/bin/docker-machine-driver-hyperkit

KubernetesのCLIであるkubectlをインストールします。

$ brew install kubectl

これでminikubeを起動する準備が整いました。

minikubeの起動

Hyperkit driverを利用するので、--vm-driver=hyperkitを指定して起動し、Kubernetesのcontextをminikubeに向けます。 minikubeが使えるリソースは--cpu=4--memory=4096と指定できますので、利用するホストマシーンのスペックにあわせて調整してください。

$ minikube start --vm-driver=hyperkit
$ kubectl config use-context minikube

なお、筆者の環境では何度かminikubeが起動しないこともありました。 そのような場合、rm -rf ~/.minikubeと一旦環境をリセットしてから起動するとうまくいきました。

Zaleniumの起動

Dockerだけで起動していたZaleniumでは、/var/run/docker.sockをマウントすることで、ブラウザが動くコンテナをオートスケールさせることができていました。 Kubernetesにおいては、実際にブラウザが動くPodをオートスケールさせるために、ServiceAccountが必要になります。

$ kubectl create serviceaccount zalenium

作成したServiceAccountを利用してZaleniumのDeploymentを作成します。 ZaleiniumはKubernetes利用時には、app=zaleniumというラベルを利用しているので、作成時に指定しています。

$ kubectl run zalenium \
    --image=dosel/zalenium:3.8.1c \
    --overrides='{"spec": {"template": {"spec": {"serviceAccount": "zalenium"}}}}' \
    --labels=app=zalenium,role=grid \
    --port=4444 \
    -- start --chromeContainers 1 \
             --firefoxContainers 0 \
             --seleniumImageName elgalu/selenium:3.8.1-p3 \
             --videoRecordingEnabled false \
             --sendAnonymousUsageInfo false

作成したDeploymentに対応するServiceを作成してアクセスできるようにします。

$ kubectl expose deployment zalenium --type=NodePort
service "zalenium" exposed

初回作成時にはDockerイメージを取得する時間のため少々待つ必要があります。 kubectl get podsによりPodの状況を確認します。

$ kubectl get pods
NAME                        READY     STATUS    RESTARTS   AGE
zalenium-40000-547b2        1/1       Running   0          53m
zalenium-57d6bb658f-s7vrb   1/1       Running   0          53m

上記、zalenium-40000-547b2となっているのが、ブラウザが動くSelenium Nodeです。この40000は連番で、オートスケールして台数が増えるたびに数が増えていきます。 今回は起動のオプションでGoogle Chromeのコンテナを1つだけ作ると指定しているので合計で1つ起動しています。 また、zalenium-57d6bb658f-s7vrbとなっているのがZalenium本体です。

両者ともRunningになったら、起動は完了しているので、minikubeのipアドレスおよびZalenium ServiceのnodePortからSelenium GridのURLを調べます。

$ echo http://$(minikube ip):$(kubectl get service zalenium \
         -o go-template='{{(index .spec.ports 0).nodePort}}')/wd/hub
http://192.168.64.10:31243/wd/hub

Zaleniumの動作確認

ZaleniumのURLが取得できたので動作を確認します。

require "selenium-webdriver"

caps = Selenium::WebDriver::Remote::Capabilities.chrome(
  "name" => "hello zalenium",
  "recordVideo" => true
)

driver = Selenium::WebDriver.for(:remote,
  url: "http://192.168.64.10:31243/wd/hub", # (1)
  desired_capabilities: caps
)

# あとはブラウザを好きに操作してください
driver.get("http://www.selenium.jp/")
driver.quit

上記のスクリプトをselenium_script.rbとして保存し、下記のように実行してください。 なお、スクリプト中(1)の部分は上記のSelenium GridのURLが使われてますので、実行環境に応じて読み替えてください。

$ gem install selenium-webdriver
$ ruby selenium_script.rb

スクリプトの実行に伴い、http://192.168.64.10:31243/grid/admin/liveでブラウザ動作のライブプレビューが、http://192.168.64.10:31243/dashboard/で録画されたダッシュボードが確認できます。 何度かスクリプトを実行すると、ブラウザを動かすコンテナが動的に作成されて動作することが確認できます。

GKEでZaleniumを動かす

このように、ローカルに立てたminikube上でZaleniumの動作が確認できました。 そこで、今度はGKEで試してみましょう。

まずは、Google Cloud PlatformのドキュメントをみてGCPの設定とgcloudツールの設定を行ってください。

Kubernetesクラスタの作成

まずはKubernetesを動かすためのクラスタを作成します。

$ gcloud container clusters create zalenium \
         --num-nodes 1 \
         --machine-type g1-small

こちらはGCPのコンソールから作成することも可能なので、心配な人はGUIで作成してください。 クラスタを作成したらクラスタを操作するための認証情報を取得します。

$ gcloud container clusters get-credentials zalenium

クレデンシャルを取得すると、kubectl config current-contextで取得できるcontextがGKEに自動的に変更されます。 これでZaleniumを起動する準備ができました。

Zaleniumの起動

基本はminikubeと同様に実行できるのですが、クラウド上に作成した場合は第三者にアクセスされる可能性があるので今回はZaleniumに対してBasic認証をかけておきます。 Basic認証は--gridUser--gridPasswordで指定できます。

$ kubectl create serviceaccount zalenium
$ kubectl run zalenium \
    --image=dosel/zalenium:3.8.1c \
    --overrides='{"spec": {"template": {"spec": {"serviceAccount": "zalenium"}}}}' \
    --labels=app=zalenium,role=grid \
    --port=4444 \
    -- start --chromeContainers 1 \
             --firefoxContainers 0 \
             --seleniumImageName elgalu/selenium:3.8.1-p3 \
             --videoRecordingEnabled false \
             --sendAnonymousUsageInfo false \
             --gridUser swet --gridPassword test

この作成したDeploymentは以下のようにGKEのServiceとして公開することができます。

$ kubectl expose deployment zalenium --type=LoadBalancer

LoadBalancer起動後、以下のコマンドでSelenium GridのURLが取得できます。

$ echo http://swet:test@$(kubectl get service zalenium \
         -o go-template='{{{(index .status.loadBalancer.ingress 0).ip}}:{{(index .spec.ports 0).port}}')/wd/hub
http://test:swet@35.190.235.227:4444/wd/hub

ここでは、Basic認証の情報がURLについているので、--gridUser--gridPasswordを変えていた場合は適宜読み替えてください。 このBasic認証情報付きのURLはそのまま、先程のスクリプトの(1)のURLを置き換えて利用することができます。

また、Zaleniumのライブプレビュー画面とダッシュボードもBasic認証で守られていることが確認できます。

ちなみに、下のスクリーンショットはGKEを使って10並列でブラウザを動かしている様子です。

f:id:swet-blog:20171215194322p:plain
GKE上で10並列で動かしている様子

クラスタの削除

以上、GKEでZaleniumを利用可能なことがわかったのでクラスタを削除します。

$ gcloud container clusters delete zalenium

まとめ

以上のようにKubernetesを利用してクラウド上でZaleniumを利用可能となりました。 今回は導入手順を紹介するだけで実践的な内容は最小限にとどめています。 運用上注意することや工夫すること等は今後も紹介していきたいと思いますので楽しみにしていてください。

それでは楽しいクラウドでZaleniumライフを。

参考文献

iOSシミュレータでのUIテストの様子を録画してみよう

モバイル 自動化/自動テスト Advent Calendar 2017 14日目の記事です。

はじめまして、SWETグループの加瀬です(@Kesin11
12/05に行われたiOS Test Night #6ではLT枠で発表させて頂きました。

今回は発表で紹介したrecordVideoについての補足と、UIテストを録画する方法を改めて紹介したいと思います。

以後の内容についての動作確認は以下の環境で行いました。

  • Xcode 9.1
  • Ruby 2.3.4
  • Appium 1.7.1

recordVideoとは

xcrun simctl ioに含まれるiOSシミュレータの画面を録画するツールです。 XcodeのリリースノートによるとXcode 8.2より追加された機能です。

使い方はシンプルで、iOSシミュレータを立ち上げてから以下のコマンドを実行するだけです。

xcrun simctl io booted recordVideo ./test.mov

実行するとプロセスが立ち上がり、録画が開始されます。録画の終了はctrl+cです。

上記のコマンド中のbootedは「起動しているシミュレータ」という意味になります。以下のようにシミュレータのUDIDを指定することも可能です。

xcrun simctl io "FDF18984-04A9-4B9A-AD2C-E323152DFD03" recordVideo ./test2.mov

指定しているUDIDは自分の環境でのiOS 11.1、iPhone 5sのシミュレータのものです。

もう少し詳しいオプションなどはxcrun simctl io --helpで確認することができます。

recordVideoでUIテストの様子を録画する

recordVideoを使ってiOSシミュレータの画面を録画することができたので、次はUIテストの開始と終了に合わせて自動的に録画の開始と終了を実行させてみましょう。

今回は以下のシェルスクリプトを作成して簡単に制御してみました。

recorder.sh

# recordVideoをバックグラウンドで実行
xcrun simctl io booted recordVideo screenshots/test.mov &

# プロセスIDを保存
PID=`echo $!`

# テスト実行
# この例ではAppium + RSpectでテストを実行しています
bundle exec rspec spec/scenario_test.rb

# バックグラウンドのrecordVideoにSIGINTシグナルを送信
kill -2 $PID

これだけでテストの起動と終了に合わせて録画ができるようになりました。

Appium + RSpecと組み合わせる

テストに合わせて録画することはできましたが、以下の点をまだ改良できそうです

  • 録画するiOSシミュレータの指定がbootedなので複数のシミュレータが起動している場合は選択できない。UDIDを指定する場合は調べるのが大変
  • UIテスト全体が録画されるため、失敗したテストケースが動画のどの部分か調べることが大変

これらの問題を解決するためAppium + RSpecでのテストに組み込むためのコードをRubyで書いてみました。

recorder.rb

require 'open3'
require 'json'

class Recorder
  attr_reader :udid, :pid, :file_path

  def initialize(driver)
    @udid = _get_udid(driver)
  end

  def _get_udid(driver)
    device_name = driver.caps[:deviceName]
    platform_version = driver.caps[:platformVersion]

    # appiumが実行しているシミュレータのUDIDを取得
    stdout, _stderr, _status = Open3.capture3('xcrun simctl list --json devices')
    json = JSON.parse(stdout)
    booted_device = json.dig('devices', "iOS #{platform_version}").find do |device|
      device['state'] == 'Booted' && device['name'].match(device_name)
    end

    booted_device['udid']
  end

  def start(file_path)
    raise 'UDID is null' unless @udid
    raise 'Already started recording' if @pid

    # バックグラウンドで録画を開始
    @pid = spawn("xcrun simctl io #{@udid} recordVideo #{file_path}", out: '/dev/null', err: '/dev/null')
    @file_path = file_path
    Process.detach(@pid)
  end

  def stop
    raise 'Any recording process started' unless @pid

    # 録画終了
    killed_process_num = Process.kill('SIGINT', @pid)
    raise "Kill pid: #{@pid} did not end correctly" unless killed_process_num.positive?

    # たまに終了に時間がかかる場合があるので待つ。既に終了している場合はエラーになるのでrescueで無視する
    begin
      Process.waitpid(@pid)
    rescue Errno::ECHILD
    end

    @pid = nil
  end

  def remove_video
    raise 'file_path is null' unless @file_path

    File.delete(@file_path)
    @file_path = nil
  end
end

RecorderクラスはrecordVideoを扱いやすくするためのクラスです。start()とstop()で録画をコントロールするのと、Appiumのdriverから録画するシミュレータを特定しています。

シミュレータの特定はAppiumのcapabilityとインストール済みのシミュレータ情報を表示するxcrun simctl listを組み合わせて実現しています。

まず、driverのcapabilityからdeviceName(端末名)とplatformVersion(iOSのバージョン)を取り出します。
xcrun simctl listの実行結果にはシミュレータのUDIDが含まれているので、先程のdeviceNameとplatformVersionと照合することによりUDIDを得ることができます。

次はRecorderクラスを実際に使用するコードです。RSpecを使う場合には共通処理をヘルパーとしてテスト本体とは分離することが多いと思いますので、そこに組み込みます。

spec_helper.rb

capability = {
  caps: {
    # iPhone 5s, iOS 11.1のシミュレータの場合は以下を設定
    platformVersion: '11.1',
    deviceName:      'iPhone 5s',

    # その他の設定は環境に応じて設定してください
    app:             APP_PATH,
    platformName:    'iOS',
    automationName:  'XCUITest',
  },
  appium_lib: {
    wait: 60
  }
}

RSpec.configure do |config|
  config.before(:each) do |example|
    @driver = Appium::Driver.new(capability)
    @driver.start_driver

    @recorder = Recorder.new(@driver)
    @recorder.start("#{ROOT}/screenshots/#{example.description}.mov")
  end

  config.after(:each) do |example|
    @recorder.stop

    # テストが通った場合は録画を消す(=失敗したものは残る)
    @recorder.remove_video unless example.exception

    @driver.driver_quit
  end
end

RSpecはRSpec.configureでbefore(:each)とafter(:each)に処理を仕込むことが可能なのでこれを利用しています。
before(:each)で録画を開始し、after(:each)で録画を終了することでテストケース毎に録画を分割しています。
さらにテストケースが無事に成功した場合には動画を削除することで、失敗したテストケースの動画だけがscreenshot/のディレクトリに最終的に残ります。

必要な処理はこれだけで、実際のテストケースを書くファイルには対応は不要です。

デモ

録画した動画のデモです。実際のUIテストはこの前後に別のテストケースが実行されており、失敗したこのテストケースの部分だけ動画が保存されます。

f:id:swet-blog:20171205133146g:plain:w300
録画したUIテストの様子

ちなみにこちらのデモはiPhone 5sのシミュレータで録画したものであり、25秒の動画で4.5MBのサイズでした。

まとめ

今回はiOS Test Night #6で発表したLTの補足という形でrecordVideoの紹介と、UIテストの様子を録画するTipsを紹介させて頂きました。

今回はAppium + RSpecでのUIテストと組み合わせてみましたが、recordVideo自体はRubyやAppiumに依存するツールではないため他にも活用方法が考えられると思います。
ぜひ色々試してみてください。