NightmareでE2Eテストをするときに役立った独自アクションのメモ

アドベントカレンダー ソフトウェアテスト6日目担当の @ks888 です。

ソフトウェアテスト Advent Calendar 2016 - Qiita

最近はNightmareでE2Eテストを書くことが増えてきました。Nightmareを使うと、例えば以下のような感じでテストを書けます。

var Nightmare = require('nightmare');
var expect = require('chai').expect; // jshint ignore:line

describe('test yahoo search results', function() {
  it('should find the nightmare github link first', function(done) {
    var nightmare = Nightmare()
    nightmare
      .goto('http://yahoo.com')
      .type('form[action*="/search"] [name=p]', 'github nightmare')
      .click('form[action*="/search"] [type=submit]')
      .wait('#main')
      .evaluate(function () {
        return document.querySelector('#main .layoutMiddle a').href
      })
      .end()
      .then(function(link) {
        console.log(link);
        expect(link).to.equal('https://github.com/segmentio/nightmare');
        done();
      })
  });
});

コード中で出てくるclickとかwaitとかはNightmareではアクションと呼ばれています。2016年12月時点では以下のようなアクションが用意されています(各アクションの意味は公式のREADMEを参考にして下さい)。

engineVersions
title
url
visible
exists
click
mousedown
mouseover
type
insert
check
uncheck
select
back
forward
refresh
wait
evaluate
inject
viewport
useragent
scrollTo
screenshot
html
pdf
cookies.get
cookies.set
cookies.clear
cookies.clearAll
authentication

アクションは自作することもできて、いくつか追加しておくと、テストを書くのが更に捗ります。今回はメモがてら、これまでに作った独自アクションを残しておきます。

HTTPヘッダーを付与する(addHeaderアクション)

var Nightmare = require('nightmare');

// アクションを追加する
Nightmare.action('addHeader',
  function (name, options, parent, win, renderer, done) {
    parent.respondTo('addHeader', function (headerKey, headerValue, done) {
      win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
        details.requestHeaders[headerKey] = headerValue
        callback({cancel: false, requestHeaders: details.requestHeaders})
      })
      done()
    })
    done()
  }, function (headerKey, headerValue, done) {
    this.child.call('addHeader', headerKey, headerValue, done)
  })

// 利用例
var nightmare = Nightmare()
nightmare
  .addHeader('Key', 'Value')
  .goto('http://localhost:8080')
  .end()
  .then()

任意のHTTPヘッダーを付与するアクションです。公式でheader()というメソッドが既にあるのですが、header()で指定したヘッダーはgoto()で指定したページをロードするときしか使われず、JS等で動的にロードする場合には使われません。addHeader()はJS等で動的にロードする場合にも指定したヘッダーが付きます。

ちなみにaddHeader()を複数回呼んでも、最後の呼び出しで指定したHeaderしか付きません。ちょっといけてないですが、今のところ困ってないので。。

特定URLへのアクセスを遮断する(blockAccessアクション)

var Nightmare = require('nightmare');

// アクションを追加する
Nightmare.action('blockAccess',
  function (name, options, parent, win, renderer, done) {
    parent.respondTo('blockAccess', function (blockUrls, done) {
      win.webContents.session.webRequest.onBeforeRequest({ urls: blockUrls }, (details, callback) => {
        callback({cancel: true})
      })
      done()
    })
    done()
  }, function (blockUrls, done) {
    this.child.call('blockAccess', blockUrls, done)
  })


// 利用例
var nightmare = Nightmare()
nightmare
  .blockAccess(['http://localhost:8080/*'])
  .goto('http://localhost:8080')
  .end()
  .then()

指定したパターンにマッチしたURLへのアクセスを全てキャンセルします。 例えばGoogleAnalyticsサーバへのアクセスをキャンセルすると、E2EテストがGoogleAnalyticsのデータに影響を与えるのを防ぐことができます。

条件付きでクリックする(clickIfアクション)

var Nightmare = require('nightmare');

// アクションを追加する
Nightmare.action('clickIf', function (condFn, selector, done) {
  var clickIfFn
  eval(
    "clickIfFn = function() {" +
    "  if (" + condFn + "()) {" +
    "    document.activeElement.blur();" +
    "    var element = document.querySelector('" + selector + "');" +
    "    if (!element) {" +
    "      throw new Error('unable to find element by selector: " + selector + "');" +
    "    }" +
    "    var event = document.createEvent('MouseEvent');" +
    "    event.initEvent('click', true, true);" +
    "    element.dispatchEvent(event);" +
    "  }" +
    "}"
  );
  this.evaluate_now(clickIfFn, done)
})

// 利用例
var nightmare = Nightmare()
nightmare
  .goto('https://github.com/')
  .clickIf(function() { return document.querySelector('#user-links') === null }, '.site-header-actions > a')
  .end()
  .then()

第一引数で与えた関数がtrueを返した場合のみ、第二引数が指定する要素をクリックします。関数はElectron内で実行されます。evaluate()click()を組み合わせても同じことができますが、こちらの方がテストの可読性が高くなるかと思っています。

独自アクションを書く上で参考になる情報

自分でアクションを書く際には、以下のリンクが役立ちました。

参考になれば幸いです。新しいアクションを書いたら、また追記します。

OSSプロジェクトにLambCIを導入しようとした話

最近はLambStatusというOSSを作っているのですが、今回は、このプロジェクトでCIを回すためにLambCIを導入しようとした話です。結果は失敗に終わったのですが、つまづいた箇所とかが参考になるかもしれないので残しておきます。

LambCIとは

LambCIは、AWS LambdaベースのCIシステムです。〜CIというとCircleCIみたいなクラウドサービスっぽいですが、クラウドサービスではなく、自分で構築して運用していくものです。

以下のMediumの記事が一時期話題になったので、聞いたことがある方もいるかもしれません。

medium.com

なぜ今更そんな自前運用のCIシステムかというと、LambCIがいわゆるサーバレスアーキテクチャで作られていることが大きいと思います。Jenkins等のCIシステムを自前運用していくのはなかなか大変ですが、サーバレスなら自前運用の辛さをかなり軽減できるかもしれない、また、CI系のクラウドサービスはビルドの並列度を上げると料金が結構高くなりますが、サーバレスなら並列度を上げても全体の料金はほぼ変わらなそう、などのサーバレスを活かした特徴が注目されているようです。

より詳しい紹介やアーキテクチャについては、上記の記事を参照して下さい。

LambCIをOSSプロジェクトで使ってみる

今回LambCIを導入するOSSプロジェクトはJavascriptで作っていて、テストはkarma、mocha、electron等を利用して動かしています。実際のテストや設定はこちらから確認できます。

LambCIのインストールについては公式のREADMEに詳しく書かれているので省略して、設定ファイル.lambci.jsの作成から始めます。レポジトリのトップディレクトリに、以下のような設定ファイル.lambci.jsを作成しました。

module.exports = {
  cmd: 'cd packages/frontend && npm install && npm run test',
  build: true
}

GitHubにPushしたところ、AWS Lambdaでのビルド中に、以下のようなエラーが出ました。

f:id:ks888:20161119150257p:plain

末尾にError: ENOSPC: no space left on device, writeと書かれています。npm install中に、ディスクサイズが足りなくなったようです。ローカル環境で確認してみると、babelやelectron等のパッケージにより、依存パッケージだけで700MB程度のディスクを消費していました。AWS Lambdaの一時ディスク容量は512MBなので、これが原因のようです。

まぁディスク容量に限らず、AWS Lambdaには色々制限があります。LambCIは、AWS Lambdaの制限によりビルドがうまく行かないときは、ECS (EC2 Container Service)との組み合わせを勧めています。ディスク制限の回避は難しそうなので、素直にECSを使ってみます。

LambCIとEC2 Container Serviceを組み合わせて使ってみる

LambCIとECSを組み合わせる場合は、以下のレポジトリを利用します。

https://github.com/lambci/ecs

こちらはまだREADMEがあまり書かれていないので、導入手順を紹介します。

1.CloudFormationスタックの立ち上げ

こちらのテンプレートを使って、AWSコンソールからCloudFormationスタックを立ち上げます。これで、ECSクラスタ等が立ち上がります。今回はt2.smallインスタンスタイプを使いました。

2.CloudFormationテンプレートとスタックの更新

(ECSではなく)LambCI本体の立ち上げに使用したCloudFormationテンプレートを更新します。LambCIのGitHubサイトからテンプレートをダウンロードして、LambdaExecutionという名前のIAM Roleリソースに、以下のポリシーを書き加えます。

{
  "PolicyName": "RunECSTask",
  "PolicyDocument": {
    "Statement": {
      "Effect": "Allow",
      "Action": "ecs:RunTask",
      "Resource": "arn:aws:ecs:*:*:task-definition/lambci-ecs-BuildTask-XXXXXXXXXXXXX"
    }
  }

XXXXXXXXXXXXXの箇所は、Step.1で作成されたECSのタスク名で置き換えてください。

書き換えたテンプレートを利用して、AWSコンソールからLambCIのCloudFormationスタックを更新します。

3.設定ファイルの更新と追加

.lambci.jsに対して、ビルドに使用するクラスタ名などを以下のように指定します。

module.exports = {
  ... 既存の設定 ...
  docker: {
    cluster: 'lambci-ecs-Cluster-XXXXXXXXXXXXX',
    task: 'lambci-ecs-BuildTask-XXXXXXXXXXXXX'
  }
}

XXXXXXXXXXXXXの箇所は、Step.1で作成したECSのクラスタ名とタスク名です。

これに加えて、Dockerfile.testというファイルをレポジトリのトップディレクトリに作成します。これは、ビルド処理を動かすDockerイメージをビルドするためのファイルです。

今回は以下のようなDockerfile.testファイルを作成しました。

FROM node:4.3.2

RUN apt-get update && apt-get install -y xvfb x11-xkb-utils xfonts-100dpi xfonts-75dpi xfonts-scalable \
  xfonts-cyrillic x11-apps clang libdbus-1-dev libgtk2.0-dev libnotify-dev libgnome-keyring-dev \
  libgconf2-dev libasound2-dev libcap-dev libcups2-dev libxtst-dev libxss1 libnss3-dev \
  gcc-multilib g++-multilib libgconf2-4 gtk2-engines-pixbuf

ADD packages/frontend/package.json /frontend_build/

RUN cd /frontend_build && npm install

ADD . .

RUN rm -rf packages/frontend/node_modules && mv /frontend_build/node_modules ./packages/frontend

CMD cd packages/frontend && xvfb-run -a --server-args="-screen 0 1024x768x24" npm run test

キャッシュを効かせるためにpakcage.jsonをごにょごにょしているため少しややこしいですが、基本的にはnpm installnpm run testをしているだけです。これに加えて、非GUI環境でelectronを動かすため、aptで色々入れています。

実際の設定ファイルは、こちらで確認できます。

これらのファイルをGitHubにPushすると、ECS上でのビルドが実行されます。なお、S3上のビルド結果画面には以下のようなログしか表示されないため、ビルド結果はCloudWatch LogsのECSのログで確認します。

f:id:ks888:20161119151548p:plain

初回は20分ほどかかりましたが、無事にビルドが成功しました。t2.smallインスタンスタイプを利用したので仕方ないと思いますが、同等のビルドをCIサービスのwerckerで動かすと7分ほどで終わったので、ちょっと遅めです。

LambCI導入をやめた理由

ここまでは進めたのですが、しばらくはクラウドサービスを使おうかな、と思っています。理由としては以下です。

  • ECS上でビルドした結果がS3に反映されない

    ECSを利用しない場合、ビルド結果はS3に渡されるため、S3を通してビルド結果を他の開発者と共有できます。同様にビルド結果を示すバッジ画像も、S3を通してREADME等に表示できます。しかしECSを利用すると、ビルド結果がS3に反映されないため、ずっとビルド中の状態になってしまいます。ビルド結果はCloudWatch Logsからわかるのですが、CloudWatch Logsの閲覧権限をパブリックに共有するのは気が進みません。

  • Lambdaの代替としてECSを使いたくない

    今回はLambdaの代わりにECSを使おうとしましたが、Lambdaに期待することをECSで実現するのは無理があると感じました。

    例えば、今回は最初t2.microインスタンスクラスタを立ち上げたのですが、これだとビルドが何回繰り返しても通りませんでした。インスタンスの性能が足りないのか思い、試しにt2.smallにしてみたところ、通りました。また、クラスタを立ち上げる度にECS Agentが古いから更新しろと警告してきます(更新は数クリックで済みますし、AMIを適切に作り直せば警告は消えると思いますが)。こういう問題は、始めからECSを利用するつもりなら何でもない話ですが、Lambdaを使うつもりだった立場からすると、ドウシテコウナッタ感があります。

    また、LambCIは、ビルド並列度を上げても全体の料金がほぼ変わらないところや、ビルドが実行されてないときは料金がほぼかからないところが利点です。しかし、ECSだとその恩恵にあずかるのは難しくなります。オートスケールを組み合わせれば多少ましになるかもしれませんが、それでも、1時間単位課金と100ms単位課金ではだいぶ料金が変わってくる気がします。

1つ目はそれほど大きな理由ではないし、回避策もあると思うのですが、2つ目の理由は結構厳しいと思い、導入をやめました。Lambdaの制限が緩和されて、ECSなくてもいけそう!と思えた段階で、改めて導入しようかと思います。

ちなみに、今はwerckerを使っています。こんな設定ファイルを書くといい感じにテストしてくれます。

Go言語のインターフェースの値は、メモリ上でどう表現されているか

前回の記事では、Go言語のスライスと文字列の値がメモリ上でどう表現されているか見てみました。今回はインターフェースへの理解を深めるべく、インターフェースの値がメモリ上でどう表現されているかGDBでみてみます。

インターフェースの値

まず、インターフェースの構造体は次のようになっています(runtime/runtime2.go#L71)。

type iface struct {
    tab  *itab
    data unsafe.Pointer
}

itabは、インターフェースの型情報や、メソッドの配列等を保持する構造体です。この構造体の定義は、後でGDBを動かすときに確認します。dataはインターフェースを実装した構造体へのポインタを保持しています。

早速、実際の値をGDBで見てみます。今回は例として次のようなコードを使います。環境は前回と同じく、OSがMac OS X 64bit、Goのバージョンが1.6.2です。

package main

import (
  "fmt"
)

type Calc interface {
  Add() int
  Mul() int
}

type Nums struct {
  a, b int
}

func (ns *Nums) Add() int {
  return ns.a + ns.b
}

func (ns *Nums) Mul() int {
  return ns.a * ns.b
}

func Print(c Calc) {
  fmt.Println(c.Add())
  fmt.Println(c.Mul())
}

func main() {
  ns := &Nums{1, 2}
  Print(ns)
}

GoのバイナリをGDBから扱いやすくするため、次のようにgcflagsオプションをつけてビルドします。

go build -gcflags "-N -l" interface.go

GDBを起動して、インターフェースの値をみてみます。// 以降の文字は後で書き足しています。

% gdb ./interface
...省略...
(gdb) break main.main  // main関数にbreakpointを設置
Breakpoint 1 at 0x2340: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go, line 29.
(gdb) break main.Print  // Print関数にbreakpointを設置
Breakpoint 2 at 0x20c0: file /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go, line 24.
(gdb) run  // 実行
Starting program: /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface
[New Thread 0x1313 of process 1307]
[New Thread 0x1403 of process 1307]
[New Thread 0x1503 of process 1307]

Breakpoint 1, main.main () at /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go:29
29      func main() {  // breakpointで止まった行
(gdb) next  // 次の行へ
30        ns := &Nums{1, 2}
(gdb) next  // 次の行へ
31        Print(ns)  // 今はこの行の実行前
(gdb) print ns  // 変数ns(*Nums)の値を確認(1)
$2 = (struct main.Nums *) 0xc82000a300
(gdb) x/2gx 0x000000c82000a300  // *ns(Nums)の値を確認
0xc82000a300:   0x0000000000000001      0x0000000000000002
(gdb) continue  // 実行を続ける
Continuing.

Breakpoint 2, main.Print (c=...) at /Users/yagami/src/go-lib/src/github.com/ks888/hello/interface.go:24
24      func Print(c Calc) {  // breakpointで止まった行
(gdb) next  // 次の行へ
25        fmt.Println(c.Add())
(gdb) print &c  // 変数c(*Calc)のアドレスを確認
$3 = (main.Calc *) 0xc820035ef0
(gdb) x/2gx 0xc820035ef0  // 変数c(Calc)の値を確認(2)
0xc820035ef0:   0x00000000002121c0      0x000000c82000a300

ポイントとしては、(1)で確認したNums構造体へのポインタ値0x000000c82000a300が、(2)で確認したCalc構造体の値に含まれていることです。ここから、iface構造体のdataフィールドには、Nums構造体へのポインタが格納されていることがわかります。

次は、iface構造体のtabフィールドが指すitab構造体を確認してみます。

itab構造体は次のように定義されています(runtime/runtime2.go#L514)。

type itab struct {
    inter  *interfacetype
    _type  *_type
    link   *itab
    bad    int32
    unused int32
    fun    [1]uintptr // variable sized
}

各フィールドは次のような意味です。

  • inter: インターフェースの型についての情報。
  • _type: インターフェースを実装した型についての情報。
  • link: 他のitab構造体へのポインタ。itab構造体の値をハッシュテーブルにキャッシュするときに使用。
  • bad: _typeの型がインターフェースを正しく実装できているかどうかを示します。メソッドが足りない場合はフラグが立ちます。
  • unused: たぶんアラインメント用です。
  • fun: メソッドの配列。正確には、インターフェースを実装した型がもつメソッドへのポインタの配列。この定義では長さ1の配列になっていますが、実際はメソッド数分の配列になっています。itab構造体の値を生成するときに、足りない分は補ってメモリ確保されます。

この構造体の値のうち、funフィールドの値をGDBで確認してみます。先程の続きから実行していきます。

(gdb) x/6gx 0x00000000002121c0  // tabフィールドの値を確認
0x2121c0:       0x00000000000da2e0      0x00000000000da920
0x2121d0:       0x0000000000000000      0x0000000000000000
0x2121e0:       0x0000000000002040      0x0000000000002080
(gdb) x/gx 0x0000000000002040  // メソッドの配列の1番目の値を確認(3)
0x2040 <main.(*Nums).Add>:      0x000000102444c748
(gdb) x/gx 0x0000000000002080  // メソッドの配列の2番目の値を確認(4)
0x2080 <main.(*Nums).Mul>:      0x000000102444c748

(3)、(4)から確認できる通り、配列の1番目にはNums構造体のAddメソッドへのポインタ、配列の2番目にはNums構造体のMulメソッドへのポインタが格納されています。

ちなみに今回は割愛しましたが、inter_typeフィールドについては、runtime/type.goで構造体の定義を確認できます。よければ値と合わせて確認してみてください。

インターフェース値の生成

ついでに、itab構造体のfunフィールド(メソッドの配列)がどう生成されているか確認してみます。

itab構造体の値はruntimeパッケージのtyp2Itab関数で生成されています(runtime/iface.go#L122)。

func typ2Itab(t *_type, inter *interfacetype, cache **itab) *itab {
    tab := getitab(inter, t, false)
    atomicstorep(unsafe.Pointer(cache), unsafe.Pointer(tab))
    return tab
}

getitab関数でitab構造体の値を生成しているようなので、そちらもみてみます(runtime/iface.go#L22)。日本語のコメントは僕が挿入したものです。

func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
    // 省略

    x := typ.x  // インターフェースを実装した型のメソッドリストを保持するuncommontype構造体

    // 省略

    var m *itab

    // 省略

    // itab構造体のメモリ確保。インターフェースのメソッド数に合わせて上乗せで確保している
    m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*sys.PtrSize, 0, &memstats.other_sys))
    m.inter = inter
    m._type = typ

search:
    // both inter and typ have method sorted by name,
    // and interface names are unique,
    // so can iterate over both in lock step;
    // the loop is O(ni+nt) not O(ni*nt).
    ni := len(inter.mhdr)  // インターフェースのメソッド数
    nt := len(x.mhdr)  // インターフェースを実装した構造体のメソッド数
    j := 0
    for k := 0; k < ni; k++ {
        i := &inter.mhdr[k]  // インターフェースのメソッド
        iname := i.name
        ipkgpath := i.pkgpath
        itype := i._type
        for ; j < nt; j++ {
            t := &x.mhdr[j]  // インターフェースを実装した構造体のメソッド
            // 名前、型、パッケージが一致しているか調べる
            if t.mtyp == itype && (t.name == iname || *t.name == *iname) && t.pkgpath == ipkgpath {
                // 一致するメソッドが見つかったので、itab構造体にメソッドへのポインタを書き込む
                if m != nil {
                    *(*unsafe.Pointer)(add(unsafe.Pointer(&m.fun[0]), uintptr(k)*sys.PtrSize)) = t.ifn
                }
                goto nextimethod
            }
        }
        // 一致するメソッドが見つからなかった場合の処理
        // 省略
        m.bad = 1
        break
    nextimethod:
    }

    // 省略
    return m
}

メソッドへのポインタの配列は、二重forループのところで埋めています。コードの通りですが、インターフェースのメソッドと、インターフェースを実装した構造体のメソッドをマッチングし、マッチしたら配列にメソッドへのポインタを書き込みます。

ちなみに省略しましたが、itab構造体はハッシュテーブルでキャッシュされています。なのでインターフェース値を作成する度にマッチングをする必要はありません。

感想

type *Interface is pointer to interface, not interfaceというエラーを調べていたときに見つけたGo開発者Russ Coxさんのブログ記事をかなり参考にしました。GDBで値を確認しつつGo言語のソースコードを読むと、Goの内部構造が少しみえてきて面白いです。

ちなみに今回活躍したGDBですが、Go言語ではあまりうまく動かないと言われています。実際使っていても、stepコマンドを出したらGo内部の関数に飛んでしまったりしました。実アプリでデバッガを使いたい場合には、delve等のGo向けデバッガを使ったほうがよさそうです。