astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

おためしpuppeteerにテストフレームワークを添えて

お久しぶりです。
最近、新しいチームにアサインされ気の向くままに動いているYanagita@宮崎です。

新しいチームでの開発は最初の開発フェーズはほぼほぼ終え、リリースに向け最終テストに入っている段階です。
そこで空いた時間ができ始めたので自動テストの導入検討を開始しました。
今回は、前にaxtstar(@axtstart)氏が書かれた「おためしpuppeteer」に出てきたpuppeteerにテストフレームワークを組み合わせてテストの実行を行いたいと思います。

lab.astamuse.co.jp

テストフレームワーク

テストフレームワークでは、出来上がったコードやアプリケーションに対して、テストコードの実行やテスト結果の判定、集計などを行ってくれる枠組みを提供しています。
今回はNode.jsで使用可能なjavascriptベースのテストフレームワークを調べたところ以下が挙げられるのかなと思いました。

で、今回はMochaを使用した際の導入からテストコード作成、テスト実行までを紹介したいと思います。

Mochaのセットアップ

npmを使用してMochaのインストールを行います。
※ コマンドはUbuntu上で実行しています。別環境で行う際はそれぞれの環境に読み替えてください。

$ npm install --save-dev mocha  /* テストプロジェクトのみにインストール */

puppeteerが未インストールの場合は、puppeteerのインストールも行ってください。
mochaのモジュールは「./node_modules/mocha/bin/mocha」になりますが、毎回binまで潜って実行するのは面倒なのでプロジェクトディレクトリのルートにpackage.jsonを作成しておきます。

{
  "scripts": {
    "test": "mocha"
  }
}

作成が完了したら、「npm test」でテストの実行が可能です。

$ npm test

> @ test /home/t.yanagita/workspace/mocha-project
> mocha

Warning: Could not find any test files matching pattern: test
No test files found
npm ERR! Test failed.  See above for more details.

ERR!となっていますがテストコードが無いので今は問題ありません。

テストコード

テストサンプル用のWebページはこちら、サンプルなので特に入力チェック等は行っていません。

  • 入力ページ(index.html)
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Input Page</title>
    <!-- https://github.com/pure-css/pure/ -->
    <link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/pure-min.css">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <form action="./complete.html" method="post" class="pure-form pure-form-stacked">
      <fieldset>
          <!-- 見出し -->
          <legend>Test Form</legend>
          <!-- メールアドレスフォーム -->
          <label for="mail_address">Mail Address</label>
          <input id="mail_address" type="text" name="mail_address" placeholder="mail address">
          <!-- パスワード -->
          <label for="password">Password</label>
          <input id="password" type="password" name="password" placeholder="Password">
          <!-- 送信ボタン -->
          <button type="submit" class="pure-button pure-button-primary">login</button>
      </fieldset>
  </form>
  </body>
</html>
  • 完了ページ(complete.html)
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Complete Page</title>
    <!-- https://github.com/pure-css/pure/ -->
    <link rel="stylesheet" href="https://unpkg.com/purecss@1.0.0/build/pure-min.css" >
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <fieldset>
        <legend>Complete</legend>

        <a class="pure-button" href="./index.html">Back</a>
    </fieldset>
  </form>
  </body>
</html>
  • テストコードはこちら(./test/test.js)
    Mochaでは、プロジェクトルートディレクトリの直下にtestディレクトリを作成し、そのtestディレクトリ配下にテストコードを配置します。
const puppeteer = require('puppeteer');
const assert = require('assert');

describe('テストサンプル', function() {

  // mocha設定
  this.timeout(0); /* 解説1 */

  const WAIT_FOR_TIMEOUT = 2500; /* ms */
  const VIEW_WIDTH = 1024; /* px */
  const VIEW_HEIGHT = 768; /* px */

  // テスト前処理
  before(async function() {
    global.browser = await puppeteer.launch({
        headless: true,
        ignoreHTTPSErrors: true,  /* 解説2 */
        arg: [
          `--window-size=${VIEW_WIDTH},${VIEW_HEIGHT}`
        ]
      });
  });

  // テストケース
  it ('テストケース', async function() {

    const page = await global.browser.newPage();
    await page.setViewport({ width: VIEW_WIDTH, height: VIEW_HEIGHT }); /* 解説3 */

    await page.goto('http://localhost/index.html');

    await page.waitFor(WAIT_FOR_TIMEOUT); /* 解説4 */

    assert.equal(await page.title(), 'Input Page'); /* 解説5- 1*/

    // スクリーンショット - 入力前
    await page.screenshot({path: 'input_form_before.png', fullPage: true}); /* 解説6 */

    // テストデータ入力
    await page.type('[name=mail_address]', 'test.user@astamuse.co.jp');
    await page.type('[name=password]', 'password');

    // スクリーンショット - 入力後
    await page.screenshot({path: 'input_form_after.png', fullPage: true});

    // submit
    await page.click('[type=submit]');

    await page.waitFor(WAIT_FOR_TIMEOUT); /* 解説4 */

    // スクリーンショット - 完了画面
    await page.screenshot({path: 'complete_page.png', fullPage: true});

    assert.equal(await page.title(), 'Complete Page'); /* 解説5 - 2 */
    const result = await page.$eval('legend', node => node.innerText);
    assert.equal(result, 'Complete'); /* 解説5 - 3 */

  });

  // テスト後処理
  after (function () {
    // ブラウザ閉じます。
    global.browser.close();
  });
});

mochaとおためしpuppeteerになかったpuppeteerの実装部分について補足します。

  • 解説1 mochaでは1テストケース毎のタイムアウト時間が設けられています。デフォルトでは2000msとなっているので、テスト実施にかかる時間に合わせてタイムアウト時間を増やすか、0を設定することでタイムアウトを無効化できます。(サンプルは無効化してます。)

  • 解説2 puppeteerのテストサーバが自己証明書を使用している場合、「 ignoreHTTPSErrors: true」で自己証明書のエラーを回避します。(デフォルトはfalse)

  • 解説3 headlessブラウザのウィンドウサイズを設定します。widthのサイズはWebページの最小幅より狭く設定すると画面キャプチャ時に切れてしまいます。

  • 解説4 ページ遷移中の待機時間を設定します。時間に待機以外にも、Selectorを設定することで次のページのElementが見つかるまでやfunctionを使用して特殊な待機条件を作成することが可能です。

  • 解説5 ページ内のElementから情報を引き出してassert.equalで検証を行います。タイトル情報はtitleメソッドが用意されていますが他のElement操作にはpage.$、page.$$, page.$eval、page.$$evalメソッドが用意されています。

  • 解説6 ページ全体を画面キャプチャする際は、オプションに「fullPage: true」を追加する。(デフォルトはfalse)

テスト実行

インストール完了時に実行した「npm test」でテスト実行します。
テストコードが正常に終了した場合は

$ npm test

> @ test /home/t.yanagita/workspace/mocha-project
> mocha

  テストサンプル
    ✓ テストケース (7262ms)

  1 passing (12s)

passingが正常にテストを終えた件数です。

解説5 - 3の第ニ引数を'failure'に変更して、エラーが発生した場合は

npm test

> @ test /home/t.yanagita/workspace/mocha-project
> mocha

  テストサンプル
    1) テストケース

  0 passing (7s)
  1 failing

  1) テストサンプル
       テストケース:

      AssertionError: 'Complete' == 'failure'
      + expected - actual

      -Complete
      +failure
      
      at Context.<anonymous> (test/test.js:56:12)
      at process._tickCallback (internal/process/next_tick.js:109:7)

npm ERR! Test failed.  See above for more details.

failingが失敗したテストの件数です。NG部分の結果も合わせて出力されます。

補足1:testディレクトリのサブディレクトリにテストコードを配置した場合は、「npm test test/*」でサブディレクトリを含めたテストコードの実行が可能です。
補足2:特定のテストコードを実行する場合は、「npm test test/test.js」でtest.jsのみの実行が可能です。

さいごに

puppeteer + machaでの導入、テストコード作成、実行するところまでさっくりと済ませました。ポイントさえ押さえれば引っかかること無く実行まで行えると思います。
感じたことは、Mochaに限らず最初に挙げたフレームワークは他の言語のテストフレームワークと似た構成となっているので、これまでにテストフレームワーク(今のチームだとサーバサイドはSpecs2)を使用してテストコードを作成した経験があれば学習コストをあまり掛けずに導入できるかなと感じました。(別チームで使用しているPhantomJSと比較した個人の感想)

お願い

アスタミューゼではたくさんのエンジニア&デザイナを募集しています。
気になる方は下からご応募下さい!新しい出会いをメンバー一同お待ちしてます!
(地方の方も遠慮せずご応募ください!きっと柔軟に対応してくれます!!!)

/etc/hosts で同じホスト名に違うIPアドレスを設定したらどうなるか

f:id:astamuse:20181127232540j:plain

こんにちは。並河(@namikawa)です。

最近、秋も終盤に差し掛かってきていて、すっかり冬らしい気候になってまいりました。

冬といえば、スノボー・こたつでみかん・やっぱり外せないのはラーメンでしょうか。

さて、例によって今回もLinux運用環境での小ネタになります。結構前の話ではあるのですが、若手から質問されて調べた話のメモを書き残しておこうかと思います。

hosts ファイルで、同じホスト名に違うIPアドレスを設定してみる

では、早速タイトルに記載した内容を実際の環境で試してみたいと思います。 テストしてみた環境は "Ubuntu 18.04.1 LTS" となります。

まず、 /etc/hosts に以下のような内容でIPアドレスとホスト名の定義を行います。

192.168.10.100  example01
192.168.10.110  example01
192.168.21.100  example01

で、まずはデフォルトの状態で、ホスト名からIPアドレスを確認してみます。

$ getent hosts example01
192.168.10.100  example01
192.168.10.110  example01
192.168.21.100  example01

"Ubuntu 18.04" のデフォルトの設定では、全てのIPアドレスが返ってきました。

この辺の挙動については man host.conf を確認すると、オプションによって挙動が変わることがわかります。 具体的には、影響があるのは multireorder の部分でドキュメントをざっくり訳すと以下のような感じです。

  • multi : "on"の場合は定義されている(複数の)IPアドレスを全て返す。"off"の場合は最初のアドレスを返す。デフォルトは"off"。manには"on"にすると大規模なhostsファイルの運用時にはパフォーマンスに影響出るとのこと。(そりゃそうだろう)
  • reorder : "on"の場合はローカルアドレス(同じサブネット内)が最初にリストアップされる。"off"の場合は機能しない。デフォルトは"off"。

ということで、なんとなく推察できるかと思いますが、"Ubuntu 18.04" のデフォルトでは /etc/host.conf が以下で設定されていました。

$ cat /etc/host.conf
order hosts,bind
multi on

設定による挙動の差異をまとめてみます

multi off, reorder off

$ getent hosts example01
192.168.10.100  example01

multi on, reorder off

$ getent hosts example01
192.168.10.100  example01
192.168.10.110  example01
192.168.21.100  example01

multi on, reorder on

$ getent hosts example01
192.168.21.100  example01
192.168.10.110  example01
192.168.10.100  example01

実際にコマンドを実行しているマシンは 192.168.21.0/24 のネットワーク配下にいるため、返ってくるIPアドレスのリストが変わっています。

multi off, reorder on

$ getent hosts example01
192.168.10.100  example01

reorder on の設定については multi onになっていないと並べ替えは行われないみたいです。

さいごに

ということで、重箱の隅をつつく系のLinux運用環境小ネタでした。

最後に、毎度で恐縮なPRタイムですが、弊社ではエンジニア・デザイナーを絶賛大募集しておりますので、少しでも気になれば、カジュアルにランチでもしながらお話ししましょう。疑問・質問などございましたら、お手数ですが (@namikawa) まで気軽にDM等いただければと思います。

それでは!=͟͟͞͞(๑•̀=͟͟͞͞(๑•̀д•́=͟͟͞͞(๑•̀д•́๑)=͟͟͞͞(๑•̀д•́

@namikawa が書いた過去記事)

Google Guice について調べたあれこれ

こんにちは。エンジニアのNishikawaです。

今日はGoogle Guiceについて色々調べて実際に使ってみたので、そのことについて書きます。

Google Guiceとは

Google GuiceはGoogleが開発しているDI用のフレームワークです。結構昔から存在しているフレームワークで初出は2007年とWikipediaにはあります。

最新は2018.11.14 時点では4.2.2です。

Wiki

https://ja.wikipedia.org/wiki/Google_Guice

GitHub

https://github.com/google/guice

Google Guiceは、後ほど紹介しますが結構簡単にDIができるようになっています。そのためScala界隈だとPlay Frameworkに使われています。

Play Frameworkを初めて使った人のなかにはDIの仕組みに戸惑った人も中にはいるかと思いますが、この記事を少しでも参考にしていただければ幸いです。

どうやって使うのか

詳しい使い方は公式のユーザガイドを見ていただければと思いますが、とりあえず使うには以下のポイント(DIの基本ですが)を抑えれば大丈夫です。

  • 注入する依存性の作成
  • 依存性の注入先の作成
  • 依存性と注入先のバインド

以上をScalaコードに落とすと以下になります。

import com.google.inject.{AbstractModule, Guice}
import javax.inject.{Inject, Singleton}


class Dependency {
  def print: Unit = println("Hello!")
}

@Singleton
class Receiver @Inject()(dependence: Dependency) {
  def execute: Unit = dependence.print
}

object Executor {
  def main(args: Array[String]): Unit = {
    val injector = Guice.createInjector(new AbstractModule{})

    val instance = injector.getInstance(classOf[Receiver])

    instance.execute
  }
}

DIの旨味のないコードになっておりますが、使うとしたらこれが最小の構成なのではないかなと思います。

Guiceは「Guice.createInjector」メソッドでインジェクタを作成する際にAbstractModuleを継承したクラスのインスタンスを必要としており、ここに依存関係の設定を記載することで、インスタンス生成時にそれらのクラスをバインディングした状態でインスタンスを生成してくれます。

この際、上記のように特に設定を記載しない場合は勝手に存在するクラスをバインドしてくれます。

実行すると以下のようになります。

Hello!

上記のコードをもう少し汎用的にしてみます。先ほど自動でバインディングされていた「Dependency」クラスをトレイトに変更し、それを継承した「DependencyImpl1」と「DependencyImpl2」クラスを作成します。トレイトには実装は何もせず、Impleに対してメソッドを実装します。

mainメソッドにはそれぞれの依存関係の設定を記載したインジェクタを作成し、そこからインスタンスを呼ぶようにします。それが以下のコードです。

import com.google.inject.{AbstractModule, Guice}
import javax.inject.{Inject, Singleton}


trait Dependency {
  def print: Unit
}

class DependencyImpl1 extends Dependency {
  def print: Unit = println("Dependency1 - Hello!")
}

class DependencyImpl2 extends Dependency {
  def print: Unit = println("Dependency2 - Hello!")
}

@Singleton
class Receiver @Inject()(dependence: Dependency) {
  def execute: Unit = dependence.print
}

object Executor {
  def main(args: Array[String]): Unit = {
    val injector1 = Guice.createInjector(new AbstractModule{
      override def configure(): Unit = {
        bind(classOf[Dependency]).to(classOf[DependencyImpl1])
      }
    })

    val instance1= injector1.getInstance(classOf[Receiver])

    instance1.execute

    val injector2 = Guice.createInjector(new AbstractModule{
      override def configure(): Unit = {
        bind(classOf[Dependency]).to(classOf[DependencyImpl2])
      }
    })

    val instance2 = injector2.getInstance(classOf[Receiver])

    instance2.execute

  }
}

ここで注目して欲しいのが、インジェクタ呼び出しの際に記載している「AbstractModule」です。ここで「configure」メソッドをオーバライドしており、この中に依存関係の設定を記載しております。

上記を実行すると以下のようになります。

Dependency1 - Hello!
Dependency2 - Hello!

現場でどうやって使ったか

DIは現場ではよくテスタビリティやメンテナンス性を上げるために利用されます。

今回は、アプリケーション開発を行う上でBigQueryに接続するために作成したDAOの部分でDIを利用しました。

簡単な構成

f:id:astamuse:20181114124932p:plain

考え方としては、DAOは発行するクエリだけを保持して下位のレイヤーに私検索を行うようにしようと考えました。 その過程でSQLの実行環境が時と場合によって変わることを想定し、ExecutionEnvironment トレイトを作成しました。 今回は実際の環境ではBigQueryを、テストではH2を使用したかったため、このExecutionEnvironmentトレイトを継承した「GoogleApiServicesBigQueryExecutionEnvironment」クラスと「ScalikeJDBCExecutionEnvironment」を作成しました。

実装したBigQueryに対するクエリ発行のコードは以下のようになります。(コード自体は色々と長いのでDIに関係しそうなところだけ記載しております)

trait ExecutionEnvironment {

  def find(query: QueryTrait): List[Map[String, Any]]

  def count(query: QueryTrait): Long

}

trait EnvironmentConfig {

  ...

}

trait QueryTrait {

  ...

}

class Query extends QueryTrait {
  ...
}

class GoogleApiServicesBigQueryExecutionEnvironment @Inject()(envConf: EnvironmentConfig) extends ExecutionEnvironment {

  override def find(query: QueryTrait): List[Map[String, ANy]] = {
    // BigQuery用のクエリ発行処理
    ...
  }

  override def count(query: QueryTrait): Long = {
    // BigQuery用のクエリ発行処理
    ...
  }

}

class GoogleApiServicesBigQueryEnvironmentConfig extends EnvironmentConfig {

  // 接続設定用の処理
  ...

}

case class TestModel(id: Long, name: String)

@Singleton
class TestDAO @Inject()(environment: ExecutionEnvironment) {

  def findById(id: Long): List[TestModel] = {
    // クエリオブジェクトの作成
    val query: Query = ...

    val result = environment.find(query)

    ...

    // 後続のデータ整形処理
  }
}

これに対してテストを行うときはBigQueryではなくh2上で行うのでScalikeJDBCで行いますので以下のようになりました。(こちらもさっきと同じでDIに関するところのみ)

import org.specs2.mutable.Specification

class ScalikeJDBCExecutionEnvironment @Inject()(envConf: EnvironmentConfig) extends ExecutionEnvironment {

  override def find(query: QueryTrait): List[Map[String, ANy]] = {
    // ScalikeJDBC用のクエリ発行処理
    ...
  }

  override def count(query: QueryTrait): Long = {
    // ScalikeJDBC用のクエリ発行処理
    ...
  }

}

class ScalikeJDBCEnvironmentConfig extends EnvironmentConfig {

  // 接続設定用の処理
  ...

}
class TestDAOSpec extends Specification {

  "TestDAO" >> {

    "IDでレコードを検索する" >> {

      val injector = Guice.createInjector(new AbstractModule{
        override def configure(): Unit = {
          bind(classOf[ExecutionEnvironment]).to(classOf[ScalikeJDBCExecutionEnvironment])
          bind(classOf[EnvironmentConfig]).to(classOf[ScalikeJDBCEnvironmentConfig])
          bind(classOf[TestDAO]).asEagerSingleton()
        }
      })

      val instance= injector.getInstance(classOf[TestDAO])

      val result = instance.findById(1)

      // assertion
      ...
    }

  }

}

上記の仕組みにより、本番ではBigQueryを、テストではScalikeJDBCを使用することができるようになったため単体試験がやりやすくなりました。

使ってみての感想

今回Google Guiceを使って見た感想としては、とても簡単という印象でした。設定についてはコードで書いてあげればいいので従来のXMLをたくさん書くみたいなことをしない分、実装もテストもしやすいというメリットがあると個人的には思います。

また、GuiceはPlay frameworkでも使用されているので利用方法を習得することは無駄じゃないと思っております。

学習コストも比較的低かったので、実装のパートナーに説明し展開することも簡単にできました。

以上がメリットです。

デメリットについては、今回プロジェクトで使ってみてとくにありませんでした。

もう少し使い倒せば粗も見つかるのでしょうか、普通に使う分には問題なかったので皆様も新規開発等で短期間で開発を行わないといけなく、クラス設計をする上で悩んだ場合は導入の検討をしてみるのはいいかもしれません。

以上

Copyright © astamuse company, ltd. all rights reserved.