JavaScript」タグアーカイブ

ぷよぷよプログラミングで右回転を実装する

前回の記事で、セガが提供している「ぷよぷよプログラミング」を紹介しました。

公式のソースコードでは、左回転はできますが右回転ができません。右回転できるようソースコードを改変します。

ぷよぷよのゲーム画面

左回転の処理を理解する

右回転できるようにするためには、すでに実装されている左回転のコードをコピーして修正すれば実現できるはずです。まずは、ソースコードを読んで左回転の処理を理解します。

前の記事でも説明していますが、game.js の loop() の中で、mode が”playing”のとき(プレイヤーの操作を判定している箇所)と、 “rotating”のとき(ぷよを回転している箇所)のコードを調べます。Player.playing() が “rotating” を返した場合に、Player.rotating() で回転していることがわかります。Player.playing() と Player.rotating() の中を見てみましょう。

function loop() {
    switch (mode) {
     ...中略...
        case "playing":
            // プレイヤーが操作する
            const action = Player.playing(frame);
            mode = action; // 'playing' 'moving' 'rotating' 'fix' のどれかが帰ってくる
            break;
     ...中略...
        case "rotating":
            if (!Player.rotating(frame)) {
                // 回転が終わったので操作可能にする
                mode = "playing";
            }
            break;

Player.playing() の中を見てみると、左回転を行う↑キーの入力判定があります。そして、操作するぷよの現在の回転角度に応じて cx, cy, canRotate をセットしています。cx は回転後の横移動の量を、cy は縦移動の量を表します。例えば、操作するぷよが左端にあるときに左回転を行うと、操作するぷよ全体を右に1つずらす必要があり、このときに cx が 1 になります。操作するぷよの左右が壁や別のぷよで囲まれている場合は、回転できないと判断し、canRotate を false にしています。canRotate が true の場合のみ、”rotating” を呼び出し元に返しています。

    static playing(frame) {
        ...中略...
        } else if (this.keyStatus.up) {
            ...中略...
            const rotation = this.puyoStatus.rotation;
            let canRotate = true;

            let cx = 0;
            let cy = 0;
            if (rotation === 0) {
                ...中略...
            }

            if (canRotate) {
                ...中略...
                return "rotating";
            }
        }

Player.rotating() の中を見ると、ぷよの現在の回転角度である this.puyoStatus.rotation に 90° を足していることがわかります。ratio が 1 になるまでは回転の途中のため false を返しており、ratio が 1 になり 90° 回転し終わったときに true を返しています。

    static rotating(frame) {
        // 回転中も自然落下はさせる
        this.falling();
        const ratio = Math.min(1, (frame - this.actionStartFrame) / Config.playerRotateFrame);
        this.puyoStatus.left = (this.rotateAfterLeft - this.rotateBeforeLeft) * ratio + this.rotateBeforeLeft;
        this.puyoStatus.rotation = this.rotateFromRotation + ratio * 90;
        this.setPuyoPosition();
        if (ratio === 1) {
            this.puyoStatus.rotation = (this.rotateFromRotation + 90) % 360;
            return false;
        }
        return true;
    }

ここまでで、左回転を行っている処理をざっくり理解できました。ここからは、右回転の処理を実装していきます。必要な処理を一度に実装するとミスに気づきにくいため、以下の手順で段階的に実装していきます。

  1. 左回転を↑キーからzキーに変更する
  2. xキーでも左回転できるようにする
  3. xキーで右回転できるようにする
  4. 単純に右回転できない場合の処理を追加する

左回転を↑キーからzキーに変更する

元のソースコードでは、↑キーを押すとぷよが左回転します。これをzキーで左回転、xキーで右回転するよう実装します。まずは↑キーの入力判定しているところを、zキーに置き換えます。zキーのキーコードは仕様によると 90 です。keyStatusオブジェクトのキーであるupをrotateLeftに名称変更し、e.keyCodeの条件を 38 から 90 に変更します。下記のソースコード以外の箇所もすべて変更します。

    static initialize() {
        // キーボードの入力を確認する
        this.keyStatus = {
            right: false,
            left: false,
            rotateLeft: false,
            down: false,
        };
        // ブラウザのキーボードの入力を取得するイベントリスナを登録する
        document.addEventListener("keydown", (e) => {
            // キーボードが押された場合
            switch (e.keyCode) {
                ...中略...
                case 90: // zキー
                    this.keyStatus.rotateLeft = true;
                    e.preventDefault();
                    return false;
                ...中略...

変更したソースコードを動かしてみましょう。↑キーは反応しなくなり、zキーで左回転できれば成功です。

xキーでも左回転できるようにする

左回転の既存の処理をそのまま利用して、xキーでも左回転できるようにします。this.keyStatus.rotateLeft に関するコードをコピーして、その直後に貼り付けます。xキーのキーコードは 88 です。下記のソースコード以外の箇所もすべて変更します。

    static initialize() {
        // キーボードの入力を確認する
        this.keyStatus = {
            right: false,
            left: false,
            rotateLeft: false,
            rotateRight: false,
            down: false,
        };
        // ブラウザのキーボードの入力を取得するイベントリスナを登録する
        document.addEventListener("keydown", (e) => {
            // キーボードが押された場合
            switch (e.keyCode) {
                ...中略...
                case 90: // zキー
                    this.keyStatus.rotateLeft = true;
                    e.preventDefault();
                    return false;
                case 88: // xキー
                    this.keyStatus.rotateRight = true;
                    e.preventDefault();
                    return false;
                case 39: // 右向きキー
                ...中略...

以下の this.keyStatus.rotateLeft で条件分岐している箇所も、まずはコピーして貼り付け、rotateRight に書き換えます。

    static playing(frame) {
        ...中略...
        } else if (this.keyStatus.rotateLeft) {
          ...中略...
        } else if (this.keyStatus.rotateRight) {
          ...中略...
        }
        ...中略...

変更したソースコードを動かしてみましょう。xキーを入力しても、zキーと同じように左回転すれば成功です。

xキーで右回転できるようにする

左回転は 90° ですが、右回転は -90° です。回転する角度を変えられるよう、Player.rotating() で 90° と記載されている箇所を変数 this.rotateAngle に置き換えます。以下のコードで this.puyoStatus.rotation に360の余りを代入していることから、this.puyoStatus.rotation は 0 ~ 360° である必要があるようです。this.rotateAngle が -90°のときに this.puyoStatus.rotation がマイナスにならないように、360を足す処理を加えています。

    static playing(frame) {
            ...中略...
        } else if (this.keyStatus.rotateLeft) {
                ...中略...
                this.puyoStatus.x += cx;
                this.rotateAngle = 90;
                const distRotation = (this.puyoStatus.rotation + this.rotateAngle) % 360;
                // 変更前:const distRotation = (this.puyoStatus.rotation + 90) % 360;
                ...中略...
    }
    static rotating(frame) {
        ...中略...
        this.puyoStatus.rotation = this.rotateFromRotation + ratio * this.rotateAngle;
        // 変更前:this.puyoStatus.rotation = this.rotateFromRotation + ratio * 90;
        this.setPuyoPosition();
        if (ratio === 1) {
            this.puyoStatus.rotation = (this.rotateFromRotation + this.rotateAngle + 360) % 360;
            // 変更前:this.puyoStatus.rotation = (this.rotateFromRotation + 90) % 360;
            return false;
        }
        return true;
    }

変更したソースコードを動かしてみましょう。zキーで左回転、xキーで右回転すれば成功です。

これで完成かと思いきや、この状態だと問題があります。ぷよを一番右に動かしてから、xキーを教えてみましょう。なんと、右回転したぷよが隠れてしまいます。期待する動きとしては、右回転時にぷよ全体が左に動くべきです。他にも、ぷよが一番左にあるときに右回転すると、ぷよ全体が右に移動してしまうといった問題もあります。次はこれらの問題を修正します。

右回転前の画面

xキー入力

右回転後の画面

単純に右回転できない場合の処理を追加する

ぷよの現在の回転角度に応じて、ぷよを左右や上下にずらす処理が必要です。以下のように、もともと左回転の処理であるコードを変更します。右回転の処理は、左回転の処理を左右反対にすればいいので、角度は左右反対に(0°→180°、180°→0°)、x軸はプラスマイナス反対に(x-1→x+1、x+1→x-1、cx=1→cx=-1)変更します。

    static playing(frame) {
        ...中略...
        } else if (this.keyStatus.rotateRight) {
            ...中略...
            if (rotation === 180) {
                // 右から上には100% 確実に回せる。何もしない
            } else if (rotation === 90) {
                // 上から右に回すときに、右にブロックがあれば左に移動する必要があるのでまず確認する
                if (y + 1 < 0 || x + 1 < 0 || x + 1 >= Config.stageCols || Stage.board[y + 1][x + 1]) {
                    if (y + 1 >= 0) {
                        // ブロックがある。右に1個ずれる
                        cx = -1;
                    }
                }
                // 左にずれる必要がある時、左にもブロックがあれば回転出来ないので確認する
                if (cx === -1) {
                    if (y + 1 < 0 || x - 1 < 0 || y + 1 >= Config.stageRows || x - 1 >= Config.stageCols || Stage.board[y + 1][x - 1]) {
                        if (y + 1 >= 0) {
                            // ブロックがある。回転出来なかった
                            canRotate = false;
                        }
                    }
                }
            } else if (rotation === 0) {
                // 右から下に回す時には、自分の下か右下にブロックがあれば1個上に引き上げる。まず下を確認する
                if (y + 2 < 0 || y + 2 >= Config.stageRows || Stage.board[y + 2][x]) {
                    if (y + 2 >= 0) {
                        // ブロックがある。上に引き上げる
                        cy = -1;
                    }
                }
                // 右下も確認する
                if (y + 2 < 0 || y + 2 >= Config.stageRows || x + 1 < 0 || Stage.board[y + 2][x + 1]) {
                    if (y + 2 >= 0) {
                        // ブロックがある。上に引き上げる
                        cy = -1;
                    }
                }
            } else if (rotation === 270) {
                // 下から左に回すときは、左にブロックがあれば右に移動する必要があるのでまず確認する
                if (y + 1 < 0 || x - 1 < 0 || x - 1 >= Config.stageCols || Stage.board[y + 1][x - 1]) {
                    if (y + 1 >= 0) {
                        // ブロックがある。左に1個ずれる
                        cx = 1;
                    }
                }
                // 右にずれる必要がある時、右にもブロックがあれば回転出来ないので確認する
                if (cx === 1) {
                    if (y + 1 < 0 || x + 1 < 0 || x + 1 >= Config.stageCols || Stage.board[y + 1][x + 1]) {
                        if (y + 1 >= 0) {
                            // ブロックがある。回転出来なかった
                            canRotate = false;
                        }
                    }
                }
            }

            if (canRotate) {
            ...中略...

これで右回転に必要な処理はすべて実装できました。ぷよが縦の状態で一番右にあるとき、一番左にある時、ぷよが下に回転する状態で別のぷよが右下にある時、などおかしな挙動にならないか確認しましょう。

おわりに

実装済みの左回転を参考にすれば、右回転も簡単に実装できるかと思っていましたが、意外と必要な処理が多く、理解することや変更する箇所が多くて大変でした。今回の変更内容は以下のGitHubのコミット履歴で確認できます。

https://github.com/shoarai/puyopuyo-programing/commit/1a1484d7f56bdaa1ad2c917320817d84a92436df

おまけ:VSCode・フォーマッタ・Webサーバ設定

公式の開発環境ではMonacaが利用できます。自分は使い慣れたVSCodeを使いたかったため、右回転の処理を追加する前に以下の設定を行いました。

以下のVSCodeのワークスペースファイルを、ルートフォルダに作成します。ファイル保存時に自動フォーマットできるよう、フォーマッタであるPrettierを使用します。このファイルをVSCodeで開いて実装します。

{
    "folders": [
        {
            "path": "."
        }
    ],
    "settings": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "extensions": {
        "recommendations": ["esbenp.prettier-vscode"]
    }
}

以下のPrettierの設定ファイルもルートフォルダに作成します。自分のPCのディスプレイ幅に合わせて一行の文字数上限を設定し、元のソースコードと同じタブ幅を設定します。

printWidth: 150
tabWidth: 4

アプリの起動のために、簡易的なWebサーバであるhttp-serverを使います。以下のファイルに”start”コマンドとして追加します。

{
    ...中略...
    "scripts": {
        "monaca:preview": "npm run dev",
        "dev": "browser-sync start -s www/ --watch --port 8080 --ui-port 8081",
        "start": "npx http-server www"
    },
    ...中略...
}

ターミナルで以下のコマンドを実行すると、アプリが起動します。

npm start

ぷよぷよプログラミングのソースコードを理解する

作りたい落ちゲーがあり、落ちゲーの作り方を調べていました。自分にとっての落ちゲーといえば、ぷよぷよ。そこで見つけたのが、公式のセガが提供している「ぷよぷよプログラミング」です。落ちゲーの作り方を学ぶため、ぷよぷよプログラミング」のソースコードを理解したいと思います。

ソースコードは以下の公式サイトからダウンロードできます。

https://puyo.sega.jp/program_2020

アプリ画面

以下は実際のアプリ画面です。←/→キーで左/右に移動でき、↑キーで左回転できます。右回転はできないようです。アプリを起動すると思わずやってしまいますね。やっぱりぷよぷよは楽しい。

ぷよぷよのゲーム画面

起動時の処理

www/src/game.jsで、Webページの読み込みが完了したときに initialize() と loop() を実行しています。initialize() はデータの初期化であったり、キーボード入力時の処理の登録を行っているようです。loop() でゲームを開始しています。

// 起動されたときに呼ばれる関数を登録する
window.addEventListener("load", () => {
    // まずステージを整える
    initialize();

    // ゲームを開始する
    loop();
});

loop() の最後の行で、requestAnimationFrame() を実行しています。これにより、loop() が繰り返し実行されます。1/60秒後に呼び出すとコメントがありますが、仕様によると、多くのブラウザではディスプレイのリフレッシュレートに合わせて呼び出されるようです。

loop() の繰り返しの中で行う処理を、modeの値に応じて切り替えています。

function loop() {
    switch (mode) {
        case "start":
            // 最初は、もしかしたら空中にあるかもしれないぷよを自由落下させるところからスタート
            mode = "checkFall";
            break;
        case "checkFall":
            // 落ちるかどうか判定する
            if (Stage.checkFall()) {
                mode = "fall";
            } else {
        ...中略...
    }
    frame++;
    requestAnimationFrame(loop); // 1/60秒後にもう一度呼び出す
}

modeの状態遷移

modeの値がどのように変化するのかわかりやすくするため、状態遷移図に起こしてみました。

modeの状態遷移図

アプリ実行時には、ぷよはステージ上に一つもありません。よって、start → checkFall → checkErase → newPuyoへと特に何も処理をせずに遷移します。newPuyo で新しいぷよを作成し、playing でプレイヤーの操作を受け付ける状態になります。操作がなければ playing のままですが、移動や回転操作があれば moving か rotating に変わります。操作するぷよがステージの床に着いたら fix になり、checkFall に戻ります。

checkFall では、自由落下するぷよがあるかを判定し、fall で自由落下の描画を行います。自由落下とは、下段のぷよを消して上段のぷよが降ってくるなどの状態のことです。checkErase では、削除するぷよがあるかを判定し、erasing でぷよの削除を行います。

newPuyo で新しいぷよが作成できないと gameOver になり、batankyu となってばたんきゅーという文字を画面に描画します。

今後やりたいこと

mode の状態遷移がわかったことで、全体としてどんな処理を行っているのかが理解できました。今回久しぶりにぷよぷよをやったのですが、またハマりそうです。せっかくソースコードを理解したので、機能を追加していくのも楽しいかなと思っています。

自作のWebアプリをAndroidアプリ化できなかった

先日、風邪ログというWebアプリをリリースしました。自分の体調を記録して振り返り、自分がどんなときに体調が悪くなるかを理解できるアプリです。Webアプリですが、Google Playストアでもアプリを見つけてもらえるように、Androidアプリ化できるか試してみました。Androidアプリ化の方法はいくつかありますが、今回は一番さくっとできそうなWebViewを使った方法を試しました。

風邪ログのカレンダー画面
風邪ログの体調入力画面

WebViewで動かしてみる

WebViewを使ってWebアプリを動かす方法を、Android公式の記事を参考に試しました。

https://developer.android.com/guide/webapps/webview?hl=ja

まずはAndroid Studioで新規プロジェクトを作成し、Empty ActivityをTemplateとして選びます。この初期状態でアプリを実行すると、空の画面が表示されます。

まずはactivity_main.xmlにWebViewを記載します。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <!-- 以下のブロックを記載 -->
    <WebView
        android:id="@+id/webview"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

次に、インターネット上で動くWebアプリにアクセスできるよう、AndroidManifest.xmlに許可設定を記載します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.isolity.kazelog">

    <!-- 以下のブロックを記載 -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        ...

最後に、MainActivity.ktにWebViewの設定を記載します。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 以降の行を記載
        val myWebView: WebView = findViewById(R.id.webview)
        // Webアプリ内のリンクをWebView内で開くようにする
        myWebView.webViewClient = WebViewClient()
        // JavaScriptの有効化
        myWebView.settings.javaScriptEnabled = true
        // Web Storageの有効化
        myWebView.settings.domStorageEnabled = true
        // Webアプリの読み込みのため、風邪ログのURLを指定
        myWebView.loadUrl("https://kazelog.shoarai.com")
    }
}

上記のコードでアプリを実行すると、風邪ログのトップページが表示されました。

WebView上でGoogleログインできない

ここでやっかいなのが、以下の処理です。

// Webアプリ内のリンクをWebView内で開くようにする
myWebView.webViewClient = WebViewClient()

風邪ログは、EmailによるログインやGoogleアカウントによるログインをサポートしています。ログインをボタンを押したときに、Googleのログイン画面に遷移することになるんですが、上記の設定を行わないとGoogle ChromeなどのAndroid規定のブラウザでログイン画面が開いてしまいます。上記の設定をするとこで、ログイン画面がWebView内で開くようになります。

さてここで本題なのですが、GoogleはWebView内でのログインをサポートしていません。たとえWebView内でGoogleにログインしようとしても、以下のエラー画面が表示されます。

認証エラー画面

この件に関する公式のドキュメントを探したところ、以下の記事が見つかりました。やはりWebViewからGoogleログインに必要なOAuthリクエストが許可されなくなるとのことです。

「ウェブビュー」と言われる埋め込みブラウザから Google への OAuth リクエストは許可されなくなります。

https://developers-jp.googleblog.com/2016/09/modernizing-oauth-interactions-in-native-apps.html

ということでWebアプリである風邪ログを、Androidアプリとしてリリースすることはできませんでした。Googleログインを行わないWebアプリであれば、リリースできるかもしれませんね。

kizAPIを使って関連語を取得する

このページでは、kizAPIを使ってある言葉の関連語を取得する方法を紹介します。

kizAPIとは

kizAPIとは、kizasi.jpというブログ検索サイトが提供しているWeb APIです。機能としては、ある言葉に関連する言葉を取得したり、今話題になっている言葉を取得できたりします。利用規約はここの最下部「利用規約」にあるので、利用する場合はあらかじめご確認ください。

関連語の取得

下記index.htmlをブラウザで表示すると、「sho」に関連する言葉が表示されます。依存モジュールとしてjQueryとjquery.xdomainajax.jsを利用しています。jquery.xdomainajax.jsはクロスドメイン対策で利用しています。ここからダウンロードして下さい。

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>kizAPI</title>
</head>
<body>
  <h1>Terms</h1>
  <div id="terms"></div>
  <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
  <script src="js/jquery.xdomainajax.js"></script>
  <script src="js/kizapi.js"></script>
  <script src="js/index.js"></script>
</body>
</html>

kizAPIにアクセスしているのがkizapi.jsです。引数で渡した検索語の関連語を取得できます。

// js/kizapi.js
/**
 * @fileOverview kizapi API
 */
(function(global) {
  'use strict';
  var kizapi = {
    /**
     * 関連語を取得する
     * @param    {string}   key   検索語&nbsp;
     * @param    {string}   span  '24':1日, '1w':1週間 or '1m':1月
     * @return   {object}         レスポンス
     * @property {string[]} terms 関連語
     */
    getRelatedTerms: function(key, span) {
      var defer = $.Deferred();
      if (this._reqFlag && this._reqId.abort) {
        this._reqId.abort();
      }
      this._reqFlag = true;
      var that = this;
      this._reqId = $.ajax({
        type: 'GET',
        url: 'http://kizasi.jp/kizapi.py',
        data: {
          span: span,
          kw_expr: key,
          type: 'coll'
        }
      }).done(function(data, statusText, jqXHR) {
        var xml = data.results[0];
        var json = $.parseXML(xml);
        var terms = [];
        $(xml).find('channel').find('item').each(function() {
          var item = $(this).text();
          var items = item.split(/\r?\n/g);
          var term = items[1].trim();
          terms.push(term);
        });
        defer.resolve({
          terms: terms
        });
      }).always(function() {
        that._reqFlag = false;
      });
      return defer.promise();
    },
    _reqId: {},
    _reqFlag: false
  };
  if ('process' in global) {
    module.exports = kizapi;
  }
  global.kizapi = kizapi;
}((this || 0).self || global));

kizAPIを呼び出しているのがindex.jsです。検索語「sho」の関連語を画面に表示しています。

// js/index.js
$(function() {
  var key = 'sho';
  kizapi.getRelatedTerms(key, '1m').done(function(data) {
   var html = '';
   var terms = data.terms;
   for (var i = 0, len = terms.length; i < len; i++) {
     html += '<span>' + terms[i] + '</span>;';
   }
   $('#terms').html(html);
 });
});

関連語取得サービス

上記のモジュールを利用して作ったのが「ke」です。「ke」は検索したいキーワードの関連語を表示してくれます。
keのアプリ画面

感想

以上、いろいろなWebAPIを試してみて、何に活用できるかを考えるのは楽しいですね。

KarmaでDOM操作のテストをする

このページでは、KarmaでDOM操作のテストをする方法を紹介します。

テスト対象

例として、下記のコードをテストしたいとします。index.htmlを開くと、メッセージが表示されるものです。

<!-- index.html -->
<!DOCTYPE html>
<head>
  <meta charset="utf-8">
</head>
<body>
  <p id="message"></p>
  <script src="js/jquery.js">
  <script src="js/app.js">
  <script>
    $(function() {
      app.showMessage('init');
    }());
  </script>
</body>
</html>
// app.js
(function(global) {
  'use strict';
  global.app = {
    showMessage: function(message) {
      $('#message').text(message);
    }
  };
}(this.self));

フォルダ構成は下記の通りです。appフォルダにテスト対象のコードを入れ、specフォルダにテストコードを書いていきます。

Project
  ├─ app
  │    ├─ index.html
  │    └─ js
  │       ├─ jquery.js
  │       └─ app.js
  └─ spec

テスト環境構築

karma-html2js-preprocessorのインストール

karma-html2js-preprocessorはKarmaのプラグインであり、HTMLファイルをjsから読み込むことができるようになります。

$ npm install karma-html2js-preprocessor --save-dev

karmaの設定ファイルを変更し、filesとpreprocessorsにHTMLファイルを追加します。

// karma.conf.js
module.exports = function(config) {
  config.set({
    ...
    files: [
      'app/js/jquery.js',
      'app/js/app.js',
      'app/*.html'
      'spec/**/*.js',
    ],
    preprocessors: {
      'app/*.html': ['html2js']
    },
    ...
  });
};

テストコードの記述

specフォルダにテストコードappSpec.jsを作成し、beforeEachでdocumentのbodyにindex.htmlを読み込みます。テスト対象のコードでjQueryを使用しているので、テストコードからも使用しています。

// appSpec.js
describe('app', function() {
  var text = 'text';
  beforeEach(function() {
    document.body.innerHTML = __html__['app/index.html'];
  });
  it('should show message', function() {
    app.showMessage(text);
    expect($('#message').text()).toEqual(text);
  });
});

画像読み込みエラー

index.htmlで画像ファイルを読み込んでいる場合、テスト実行時にWARNINGが表示されます。

<!-- index.html -->
<!DOCTYPE html>
<html>
  ...
  <img src="img/image.png">
  ...
</html>
$ npm test
...
WARN [web-server]: 404: /img/image.png
...

このエラーが表示されないようにするには、プロキシを介して画像ファイルを読めるようにします。画像ファイルはファイルの変更監視が必要ないのと、ブラウザ起動時にscriptタグで読み込まないので、watchとincludedをfalseにします。プロキシには、ベースとなるフォルダのパスを基点にするために’base’を付加したパスを設定します。

// karma.conf.js
module.exports = function(config) {
  ...
  files: [
    'app/js/jquery.js',
    'app/js/app.js',
    'app/*.html',
    {pattern: 'app/**/*.png', watched: false, included: false, served: true},
    'spec/**/*.js'
  ],
  proxies: {
    '/img/': '/base/img/'
  },
  ...
};

Karmaでカバレッジを取る

このページでは、Karmaのテスト実行時にカバレッジを取る方法を紹介します。

カバレッジ取得環境の構築

karma-coverageのインストール

karma-coverageはkarmaのプラグインであり、テストを実行するとカバレッジが取れます。karmaのテスト環境構築方法はこちらで紹介しています。

$ npm install karma-coverage --save-dev

karmaの設定ファイルを修正します。reportersに’coverage’を追加し、preprocessorsにカバレッジを取りたいファイルを指定します。

// karma.conf.js
module.exports = function(config) {
  config.set({
    ...
    files: [
      'app/js/jquery.js',
      'app/js/app.js',
      'test/**/*.js'
    ],
    reporters: ['progress', 'coverage'],
    preprocessors: {
      'app/js/app.js': ['coverage']
    }
    ...
  });
};

カバレッジ取得

Karmaのテストを実行すると、自動的にカバレッジが取得します。取得したカバレッジはcoverageフォルダにHTML形式で保存されます。

$ npm test
(karma startのことです)

coverage/index.html
karmaのカバレッジ画面

ちなみに

カバレッジデータはテストを実行すれば作成されるため、コミットする必要はありません。バージョン管理している場合はignoreに追加しておきましょう。

[.gitignore]
...
coverage

karma.conf.jsをwatchする

このページでは、Karmaの設定ファイルであるkarma.conf.jsの変更を検知して、テストを再実行する方法を紹介します。

はじめに

karma.conf.jsには、テスト対象やテストコードのファイルパスや記述します。autoWatchをtrueにすればファイルの変更を検知できますが、karma.conf.js自体の変更を検知できません。テスト実行中にファイルを増やすこともあるため、karma.conf.jsもwatchしたいと思います。
karmaのテスト環境構築方法はこちらで紹介しています。

nodemonのインストール

nodemonを使うと、ファイルの変更を検知してタスクを再実行できます。

$ npm install nodemon --save-dev

ファイルの監視とテストの実行

監視するファイルと実行するタスクを指定して、nodemonを実行します。karma.conf.jsを変更すると、テストが止まって再実行されます。

$ node_modules/.bin/nodemon --watch karma.conf.js --exec node_modules/.bin/karma test

コマンドの整理

毎回長いコマンドを書くのも大変なので、npm testでファイルの監視を含めたテストを実行できるようにします。

[package.json]
{
  ...
  "scripts": {
    "test": "nodemon --watch karma.conf.js --exec karma start"
  },
  ...
}

下記コマンドでテストが実行できます。

$ npm test

Karma+JasmineでJavaScriptをテストする

このページでは、KarmaというテストランナーとJasmineというテストフレームワークを使って、JavaScriptをテストする方法を紹介します。

関連記事

このページの続きとして、以下の関連記事があります。順番は関係ないので、必要なものを見てください。

テスト対象

例として、下記のコードをテストしたいとします。index.htmlを開くと数値の合計値が表示されるものです。

<!-- index.html -->
<!DOCTYPE html>
<head>
  <meta charset="utf-8">
</head>
<body>
  <p id="message"></p>
  <script src="js/app.js">
  <script src="js/jquery.js">
  <script>
    $(function() {
      var num = app.sum(1, 2);
      app.showMessage(num);
    }());
   </script>
</body>
</html>
// app.js
(function(global) {
  'use strict';
  global.app = {
    sum: function(a, b) {
      return a + b;
    },
    showMessage: function(message) {
      $('#message').text(message);
    }
  };
}(this.self));

フォルダ構成は下記の通りです。appフォルダにテスト対象のコードを入れ、specフォルダにテストコードを書いていきます。

Project
  ├─ app
  │    ├─ index.html
  │    └─ js
  │       ├─ jquery.js
  │       └─ app.js
  └─ spec

テスト環境構築

事前準備

Node.jsはインストール済みとして、package.jsonがない場合は作ります。設定は自由に。

$ npm init

karmaのインストール

npmからkarmaをインストールします。

$ npm install karma --save-dev

karmaの設定ファイルを作成します。テストフレームワークとしてJasmine、ブラウザにChrome、テストコードのディレクトリをspecフォルダ以下のjsファイルに指定します。

$ node_modules/.bin/karma init
Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine
Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no
Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> Chrome
>
What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
> app/js/jquery.js
> app/js/app.js
> spec/**/*.js
WARN [init]: There is no file matching this pattern.
>
Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.
>
Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes
Config file generated at ".../Project/karma.conf.js".

テストコードを書く

app.jsのテストコードとして、specフォルダにappSpec.jsを作成します。appオブジェクトのsum関数をテストします。

// appSpec.js
describe('app', function() {
  it('should sum numbers', function() {
    expect(app.sum(1, 2)).toEqual(3);
  });
});

テストを開始します。するとブラウザが立ち上がりテストコードが実行されます。「SUCCESS」と表示されていて、テストが成功したことがわかります。テストにかかった時間も表示されるため、処理速度の目安にすることもできます。

$ node_modules/.bin/karma start
INFO [karma]: Karma v0.12.37 server started at https://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 43.0.2357 (Mac OS X 10.10.4)]: Connected on socket IFN6So2x3Bv54q3o_ch4 with id 97782541
Chrome 43.0.2357 (Mac OS X 10.10.4): Executed 1 of 1 SUCCESS (0 secs / 0.002 secChrome 43.0.2357 (Mac OS X 10.10.4): Executed 1 of 1 SUCCESS (0.005 secs / 0.002 secs)

karmaの結果画面

コマンドの整理

上記では「node_modules/.bin/karma」を実行しており、binのパスまで指定する必要がありますが、npm経由でコマンドを実行すると、パスを指定しなくても実行できます。

[package.json]
{
  ...
  "scripts": {
    "test": "karma start"
  },
  ...
}

下記コマンドでテストが実行できます。

$ npm test

ちなみに

DOM操作を行っているappのshowMessage関数のテスト方法はこちらを紹介しています。

Nightwatch.jsのテストをいろいろなブラウザで動かす

WebアプリのUIテストができるSeleniumですが、それをラップして色々なAPIを提供してくれるのがNightwatch.jsです。アプリをJavaScriptで書いていて、テストも同じ言語で書きたい人には特におすすめです。

ただNighwatch単体だと不十分なところがあり、さらなるラッパーを探してたところhttps://github.com/sethmcl/nightwatchを発見。使ってみたところ動かず;そこで少し修正したものを公開します。特徴は下記のとおり。

  • Selenium Serverを事前に動かさなくていい。
  • ChromeとかIEでテストできる。
  • APIが簡単に作れる。

使い方

まずgithubからクローンしてください。

$ git clone https://github.com/shoarai/nightwatch.git
$ cd nightwatch
$ npm install

下記のコマンドで、デフォルトのテストが動きます。
Selenium Serverも組み込まれていて、勝手に動いて勝手に止まります。

$ npm test

ブラウザ選び

デフォルトのテストはFirefoxで動きますが、Google Chrome、Internet Explorerも選べます。Nigthwatchfile.jsを修正してください。

test_settings: {
  default: {
    ...
      desiredCapabilities: {
      browserName: 'firefox',
      // browserName: 'chrome',
      // browserName: 'internet explorer',
      ...

WindowsでChromeを使いたい場合は下記も修正してください。

selenium: {
  cli_args: {
    // 'webdriver.chrome.driver': './node_modules/.bin/chromedriver',      // in Mac
    'webdriver.chrome.driver': './node_modules/.bin/chromedriver.cmd',  // in Windows
    ...

APIを作る

tests/spec/search.jsでは、pageオブジェクトにあるhomepagesearch_resultsの2つのモジュールを使っています。これらを使うためにはbeforeにある処理が必要です。

var path = require('path');
module.exports = {
  tags: ['sanity', 'search'],
  before: function(client) {
    require('nightwatch-pages')(client, path.resolve(__dirname, '..', 'pages'));
  },
  'Bing search from homepage': function (client) {
    var searchTerm = 'selenium';
    client
      .page.homepage.load()
      .page.homepage.search(searchTerm)
      .page.search_results.assertResults(searchTerm)
      .page.search_results.navImages()
      .saveScreenshot('./screen/selenium.png')
      .end();
  }
};

tests/pages/search_results.jsに上記のsearch_resultsモジュールの関数が書いてあります。新しいモジュールを追加したい場合は、tests/pagesにモジュール名にしたい名前のファイルを追加してください。

module.exports = {
  selectors: {
    'resultDiv': '#b_results',
    'navImages': 'nav ul li:nth-child(2) a'
  },
  assertResults: function (text) {
    return this.client.assert.containsText(this.selectors.resultDiv, text);
  },
  navImages: function () {
    return this.client.click(this.selectors.navImages);
  }
};

以上です。よりよいテスト自動化を。