astamuse Lab

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

TypescriptでJavascriptの這い寄る混沌からなんとか抜け出した話

f:id:astamuse:20170823011437j:plain

お久しぶりでございます。scalaでバックエンドを開発しているaxtstar(@axtstart)でございます。

前回はマクロって言ったり、関数って言ったり、Functionだったりする何か。~Google Spread Sheet~ の話をしましたが、今回は、Typescriptを使って、Javascriptで書かれたテストコードを効率化した話を書いてみようと思います。

TL;DR

Typescriptとデザインパターンの「テンプレート・メソッド・パターン」が分かる方はこの記事は読まずに、
「エンジニア・デザイナーの採用情報」のバナーからぜひ弊社にご応募ください。

テストは重要

プロダクションをデプロイする前に、テストを行うのが当然なのはご存知の通りですが、 私の担当してるプロジェクトでは、導通確認やトランザクションチェックにヘッドレスブラウザ*1を用いた試験を導入しています。

デプロイした後のプロダクションに対してもテストを実行しシステムの健全動作を定期的に確認しています。

↓こんなテスト↓

STEP1 → STEP2 → STEP3 → STEP4

STEPは各ページに相当

→は画面遷移

PhantomJS

担当プロジェクトでは経緯があって、PhantomJSを上記テストに利用しています。

PhantomJSは、

  • Javascript(ES5)互換であること
  • ヘッドレスブラウザ(Qt web kitを使用)であること
  • 画面キャプチャ機能があること
  • NodeJS上で動くわけではないこと*2
  • 残念ながら主要メンテナーの方が最近ステップダウンされた*3

などの特徴があり、よく知られたヘッドレステストツールです。

問題点

しかしながら、Javascriptで記述していたためというわけでは無いのですが、 以下の問題を抱えている状態でした。

  • 可読性が低い(あくまで処理の順番通りにコードが書かれているだけ)
  • 構造化されていない(処理が最初から終わりまで1メソッドで書かれていたりする)
  • 似て非なるテストが多くあり、メンテナンスに時間を要する
  • あるテストではAssertがあるのに他のものではなかったりする(テスト品質にバラツキがある)

這い寄る混沌

そんな中、テスト全てに影響のある修正が行われることになりました。

修正そのものは(それほど)難しくは無いのですが、それが動作する動線が多く、このためのテスト対応を、 現状のソースに対して行うのはSAN値が下がる大変だと直感しました。

↓ちなみにこのような修正↓

STEP1 → STEP2 → STEP3 → STEP4

STEP1 → STEP1-2 → STEP2 → STEP3 → STEP4

そこで今回このコードを一気にモダン化しようと思い至りました。

AltJS導入

かつては、CoffeeScriptのソースが直接読み込めたPhantomJSですが、version 2以降はメンテナンスの関係からその対応は外されています。*4

ですので、モダン化を行うには、現状、AltJSと呼ばれる、対象の言語を、PhantomJSが動作するJavascriptにトランスパイルする言語・環境が必要になってきます。

こちらのエントリでBabel、ES6の話を書いているので初めはBabelによるES5へのトランスパイルを試そうかな*5と思っていました。

しかし、@typesの記事を目にして、Typecriptを採用してみることにしました。

※これ以前はtsdとかTypingsとかNodeJSの設定以外にも色々手を加える部分が必要なTypescriptはとっつきにくいなと感じていました。

修正前

さて、

「そのもの」のソースはお見せできないので、今回、大幅に簡略、概念化した、修正前のソースを用意しました。

Webページの遷移において、ユーザが好きな何かを選択して画面遷移するもの、としています。

※そうは言っても、あくまで今回のブログ用に作成したものなので、「そのもの」よりかなり可読性は高いです。

phantom.casperPath = './node_modules/casperjs';
phantom.injectJs('./node_modules/casperjs/bin/bootstrap.js');
phantom.casperTest = true;

var casper = require('casper').create();
var fs = require('fs');

var url = "http://sample.com/";
var wait_time= 300;
var title;

casper.test.begin('test start', function suite(test) {

    //UserAgent指定
    casper.userAgent('Mozilla/5.0 (Macintosh略');

    casper.start().viewport(1024, 768).thenOpen(url, function() {
        test.assertTitle("タイトル", "title is タイトル");
        title = this.getTitle();
    });

    casper.thenEvaluate(function() {
            document.querySelector('input[name="ログインID"]').value = 'ログインID';
            document.querySelector('input[name="パスワード"]').value = 'パスワード';
            document.querySelector('#clickable').click();
        }, '')
        // STEP 1
        .then(function() {
            this.test.assertTitleMatch(/STEP1/, "title contains STEP1");
            this.wait(wait_time, function() {
                this.fillSelectors('form', {
                    'select[name="好きな映画"]': 'マッドマックス 怒りのデス・ロード',
                    'select[name="好きな車"]': 'Peugeot 106'
                }, false);

                this.click('#clickable');//Next Page
            });
        })
        // STEP 2
        .then(function() {
            this.wait(wait_time, function() {
                this.test.assertTitleMatch(/STEP2/, "title contains STEP2");

                this.fillSelectors('form', {
                    'select[name="好きなチーム"]': '阪神タイガース',
                    'select[name="好きな選手"]': 'ジネディーヌ・ジダン'
                }, false);

                this.click('#clickable');//Next Page
            });
        })
        // STEP 3
        .then(function() {
            this.wait(wait_time, function() {
                this.test.assertTitleMatch(/STEP3/, "title contains STEP3");

                this.fillSelectors('form', {
                    'select[name="好きな国"]': 'フランス',
                    'select[name="好きな曲"]': '愛なんだ'
                }, false);

                this.click('#clickable');//Next Page
            });
        })
        // STEP 4
        .then(function() {
            this.wait(wait_time, function() {
                this.test.assertTitleMatch(/STEP4/, "title contains STEP4");

                this.fillSelectors('form', {
                    'select[name="好きな番組"]': '正解するカド',
                    'select[name="好きな本"]': 'The Three Body Problem'
                }, false);

                this.click('#clickable');//Next Page
            });
        });

  casper.run(function () {
    test.done();
  })
  .then(function(){
    this.exit();
  });

});

このようなJSファイルが数十個あるような状態でした。

このソース群一個一個個別に、同じ追加の処理を書いて行くのは、やはり遠い目になるのは間違いないので、 今見てもこの選択は良かったなと思っています。

目標

Typescript導入による目標を下記のごとく定めました。

  • 構造化による差分プログラムを可能にすること
  • Enum化によるインテリセンスの活用、値入力の効率化、ランダム取得対応

構造化

上記を基本として、必要に応じてクラスをオーバライドすることで、 各処理に対応するようにしました。

また管理したい単位でクラス設計を行いました。

  • People ユーザの属性を保持しておくクラス
  export class People{
    /** ログインID */
    public loginID:string
    /** パスワード */
    public passwd:string
    /** 好きな映画 */
    public movie:string = MovieEnum.fury
    /** 好きな車 */
    public car:string = CarEnum.peugeot106
    //以下略
  }

入力のものはstring、選択式のものはenumを使用しました(後述)

↓構造↓

基本構造として、URLを開いてログインするだけで終わる処理をBASEとして記述します。 そのフローの中で、何もしない処理を記述します

Start → Login → Step1(何もしない) → Step2(何もしない) → Step3(何もしない) → Step4(何もしない)

export module TestModule {
  export class Register {
      //開始URL
      public url = "http://sample.com"

      //人物設定
      public people:People = new OrdinalPeople()

      protected beginningString:string = "start"
      protected assertTitle:string
      protected sizeWidth:number = 1024
      protected sizeHeight = 768

       /**
        * スタート処理(は共通として扱う)
        */
       protected emitStart = () => {
          var url = `${this.url}`
          var assertTitle = this.assertTitle
          var sizeWidth = this.sizeWidth
          var sizeHeight = this.sizeHeight

          casper.start().viewport(sizeWidth, sizeHeight).thenOpen(url, function() {
              var title = this.getTitle()
              casper.test.assertTitle(assertTitle, "title is " + title)
          })
       }

       /**
        * ログイン処理(は共通として扱う)
        */
       protected emitLogin = () => {
          var loginID = this.people.loginID
          var passwd = this.people.passwd

          casper.thenEvaluate(function(loginID:any,passwd:any) {
                  (<HTMLInputElement>document.querySelector('input[name="ログインID"]')).value = loginID
                  (<HTMLInputElement>document.querySelector('input[name="パスワード"]')).value = passwd
                  (<HTMLButtonElement> document.querySelector("#clickable")).click()
              }, loginID, passwd)
       }

      /** STEP1処理 */
      protected emitStep1 = () => {}

      /** STEP2処理 */
      protected emitStep2 = () => {}

      /** STEP3処理 */
      protected emitStep3 = () => {}

      /** STEP4処理 */
      protected emitStep4 = () => {}

       /**
        * casperの実行
        */
       protected emitRun = () => {
          casper.run(function () {
              casper.test.done()
          })
          .then(function(){
              this.exit()
          })
       }

      /** 実際に呼び出す処理 */
      protected casperFunc = () => { 
        var emitStart = this.emitStart //スタート
        var emitLogin = this.emitLogin //ログイン
        var emitStep1 = this.emitStep1 //Step1入力を想定
        var emitStep2 = this.emitStep2 //Step2入力を想定
        var emitStep3 = this.emitStep3 //Step3入力を想定
        var emitStep4 = this.emitStep4 //Step4入力を想定

        var emitRun = this.emitRun //テストの実行

          casper.test.begin(this.beginningString, function suite(test:any) {
              emitStart();//スタート
              emitLogin();//ログイン
              emitStep1();//Step1入力を想定
              emitStep2();//Step2入力を想定
              emitStep3();//Step3入力を想定
              emitStep4();//Step4入力を想定

              emitRun();//テストの実行
          });
       }
  }
}

上記のクラスを継承し、Step1の処理を差分で書き換えるようにしました

Start → Login → Step1(映画、車) → Step2(何もしない) → Step3(何もしない) → Step4(何もしない)

export class MovieRegister extends Register {
    //人物設定
    people = new OrdinalPeople()

      /**
      * step1の設定JSON
      */
      getStep1 = (people:People) =>{
                      return {
                            'input[name="好きな映画"]':people.movie,
                            'input[name="好きな車"]': people.car
                        }
      }

      /**
      * step1入力時のcasper処理
      */
      emitStep1 = () => {
        var step1 = this.getStep1(this.people)
          //↑ここでクラスプロパティをvarに詰め替える
        casper.then(function() {
                this.fillSelectors('form', step1, false)
                this.click('#clickable');//Next Page
        })
    }
  }

Enum化

設定値をEnum化することで、テストコーディング時にインテリセンス(タブ補完、オートコンプリーション)による、効率的なコーディングを可能にしました。

export enum UserAgentEnum {
    /**
    * UserAgent指定PC
    */
    PC  = <any>'Mozilla/5.0 略',
    /**
    * UserAgent指定SP
    */
    SP = <any>"Mozilla /5.0 (iPhone;略"
}

f:id:astamuse:20170822221038p:plain

Stringのenum型が許容されないため、文字列の前に<any>を指定する必要がありました。

トランスパイルされたJavascriptは下記のようになっていました。

    /**
     * User Agnet指定
     */
    var UserAgentEnum;
    (function (UserAgentEnum) {
        /**
         * UserAgent指定PC
         */
        UserAgentEnum[UserAgentEnum["PC"] = 'Mozilla/5.0 略'] = "PC";
        /**
         * UserAgent指定SP
         */
        UserAgentEnum[UserAgentEnum["SP"] = "Mozilla /5.0 (iPhone;略"] = "SP";
    })(UserAgentEnum = TenCasper.UserAgentEnum || (TenCasper.UserAgentEnum = {}));

このため、例えばランダムで値を取得したい(ランダムでUserAgentを変更したい)場合サイズ取得時に2で割る必要がありました。

    export function getRandomEnum(o:any) {
        var len = Object.keys(o).length / 2// size of Object
        var x = Math.floor(Math.random() * len) // generate random value till the max size
        return <any>Object.keys(o)[x]
    }

このような形で選択式の部分をEnum化しTypescriptの特徴である、型システムを最大限利用できるようにしました。

また、Enum値のランダム取得を行うことで、網羅率を高める取り組みが比較的容易になったかなと思います。

朗報

Typescript2.4 から stringのenumがサポートされるようになりました。*6

export enum UserAgentEnum {
    /**
    * UserAgent指定PC
    */
    PC  = 'Mozilla/5.0 略',
    /**
        * UserAgent指定SP
        */
    SP = "Mozilla /5.0 (iPhone;略"
}

↓トランスパイル↓

/**
    * User Agnet指定
    */
var UserAgentEnum;
(function (UserAgentEnum) {
    /**
        * UserAgent指定PC
        */
    UserAgentEnum["PC"] = "Mozilla/5.0 \u7565";
    /**
        * UserAgent指定SP
        */
    UserAgentEnum["SP"] = "Mozilla /5.0 (iPhone;\u7565";
})(UserAgentEnum = TenCasper.UserAgentEnum || (TenCasper.UserAgentEnum = {}));

さきほどのランダムの取得もシンプルになりました。

export function getRandomStringEnum(an:any) {
    //String Enum may be just same size of definition?
    var len = Object.keys(an).length  // size of Object

    var x = Math.floor(Math.random() * len) // generate random value till the max size

    var key = <any>Object.keys(an)[x]
    target = an[key]

    return target
}

まとめ

Typescriptで プログラムができるようになり、以下のメリットがありました。

  • 型システムの恩恵により、ミスペルなどのケアレスミスの軽減
  • enumをランダムで回すテストによる、網羅率の向上
  • 動線でのキャプションの一貫性の無さ、css-selectorの命名の違いの検出
  • SAN値の低下を防ぐ

最後に

アスタミューゼでは現在、エンジニア・デザイナーを募集中です。 興味のある方はぜひ下記バナーからご応募ください。

*1:テスト用途のディスプレイを必要としないブラウザのことです

*2:そのため、NodeJSで使用可能なものが使えない、機能が違うなどがあります。またリモートデバッグ機能はありますが、ステップバイステップの実行はできないようです

*3:https://www.infoq.com/jp/news/2017/04/Phantomjs-future-uncertain

*4:https://github.com/ariya/phantomjs/issues/12410

*5:PhantomJSそのもののES6対応もversion 2.5で予定はされている模様です https://github.com/ariya/phantomjs/issues/14506

*6:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-4.html#string-enums

Copyright © astamuse company, ltd. all rights reserved.