はてなブックマークのREST APIを利用する

目的

はてなブックマークをREST API経由で登録してみます。
環境は以下の通りです。
・windows 10
・python 3.7.4

はてなブックマークのREST APIについては下記を参照してください。
http://developer.hatena.ne.jp/ja/documents/bookmark/apis/rest#authorization

事前準備

pythonを使用してはてなブックマークをREST API経由で登録するにはOAuth認証をする必要があります。
ここではOAuth認証を行うための事前準備について説明します。

Consumer Keyの取得

(1)Consumer Keyを取得するため、OAuth 開発者向け設定ページにアクセスします。この際、はてなのユーザーIDに紐づくパスワードの入力が要求されます。

(2)「新しいアプリケーション」の作成ボタンを押します。

(3)下記を参考に必要な項目を変更し、Consumer KeyとConsumer Secretをメモしておきます。

・アプリケーションの名称、説明、URLを変更します。スクリプトで使用するのでURLは127.0.0.1になっています。

・Consumer KeyとConsumer Secretをメモします。この値は外部に漏れないようにしましょう。

・必要な権限を割り当てます。はてなブックマークを登録するだけなら「write_public」が必要になります。

・「変更する」ボタンを押下します。
・「提供アプリケーション一覧に戻る」をクリックすると、新しいアプリケーションが追加されています。
※どうも削除する方法はないようで、使わなくなったら「無効化」するようです。

requests-oauthlibのインストール

OAuth認証を自前で実装するのは辛いのでrequests-oauthlibをインストールします。
https://github.com/requests/requests-oauthlib

はてなブックマークの登録

はてなブックマークを登録するためのサンプルコード

https://github.com/mima3/qiita_exporter/blob/master/hatena_api.py

"""はてなREST APIを操作する.

はてなのRESTAPIについては下記を参照してください。
http://developer.hatena.com/ja/documents/bookmark/apis/rest

また、OAuthの認証方法については下記のページを参考にしています.

"""

import requests
# https://github.com/requests/requests-oauthlib
from requests_oauthlib import OAuth1Session

class HatenaApiError(Exception):
    """HatenaApiErrorのエラーが発生したことを知らせる例外クラス"""

class HatenaApi:
    """はてなAPIを操作するクラス."""
    LOGIN_URL = 'https://www.hatena.ne.jp/login'
    session = None

    def _get_rk(self, hatena_id, password):
        """はてなIDとログインパスワードからrkを取得します。
        以下のコードを利用しています
        
        https://github.com/iruca/hatena-oauth-python/blob/master/get_access_token_util.py

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
        Returns:
            rk文字列
        Raises:
            HatenaApiError: rk文字列が取得できなかったとき。ID/パスワードが間違っているか、rkを取得するためのはてなAPIの仕様が変わった
        """
        payload = {'name': hatena_id, 'password': password}
        response = requests.post(self.LOGIN_URL, data=payload)
        if not "Set-Cookie" in response.headers:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        if not "rk=" in response.headers['Set-Cookie']:
            raise HatenaApiError("cannot get rk.ID/Password is wrong, or Hatena API spec changed.")
        rk = response.headers["Set-Cookie"].split("rk=")[1].split(";")[0]
        return rk

    def _get_authorization_redirect_url(self, user_id, password, authorization_url):
        """authorization_urlにアクセスしてアプリケーションの許可を行う."""
        rk = self._get_rk(user_id, password)
        res_auth = requests.get(authorization_url, headers={"Cookie" : "rk="+ rk}).text
        rkm = res_auth.split("<input type=\"hidden\" name=\"rkm\" value=\"")[1].split("\"")[0]
        oauth_token = res_auth.split("<input type=\"hidden\" name=\"oauth_token\" value=\"")[1].split("\"")[0]
        res_redirect = requests.post(
            authorization_url,
            headers={"Cookie": "rk="+ rk},
            params={
                "rkm": rkm,
                "oauth_token": oauth_token,
                "name": "%E8%A8%B1%E5%8F%AF%E3%81%99%E3%82%8B"
            }
        )
        return res_redirect.url

    def authorize(self, user_id, password, consumer_key, consumer_secret, scope):
        """OAuth認証を行う.

        Args:
            hatena_id:  はてなID文字列
            password: はてなIDに対応するはてなログイン用のパスワード
            consumer_key: 「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key
            consumer_secret:「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret
            scope : 取得権限. ex "read_public,write_public"
        """
        self.session = OAuth1Session(
            consumer_key,
            consumer_secret,
            callback_uri="http://localhost/hogehoge"  # このURLは実際にアクセスできる必要はありません.
        )
        self.session.fetch_request_token(
            "https://www.hatena.com/oauth/initiate?scope={}".format(scope)
        )
        authorization_url = self.session.authorization_url("https://www.hatena.ne.jp/oauth/authorize")
        redirect_response = self._get_authorization_redirect_url(
            user_id,
            password,
            authorization_url
        )
        self.session.parse_authorization_response(redirect_response)
        self.session.fetch_access_token("https://www.hatena.com/oauth/token")

    def add_bookmark(self, url, comment="", tags=None):
        """はてなブックマークの追加または更新を行う.
        このメソッドを実行する前に、authorizeを呼び出す必要がある.

        Args:
            url:  ブックマーク対象のURL
            comment: コメント
            tags: タグの一覧
        """
        if not self.session:
            raise HatenaApiError("call authorize method.")
        if tags:
            for t in tags:
                comment += "[{}]".format(t)
        return self.session.post(
            "https://bookmark.hatenaapis.com/rest/1/my/bookmark?url=",
            params={
                "url": url,
                "comment" : comment,
            }
        )

上記のクラスは以下のように使用します。

    api = HatenaApi()
    api.authorize("はてなのユーザ名", "パスワード", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_key", "「OAuth 開発者向け設定ページ」にて作成したアプリケーションのconsumer_secret", "write_public")
    res = api.add_bookmark("http://developer.hatena.com/ja/documents", "test!", [])
    res.raise_for_status()
    print(res.text)

解説

OAuth認証を行ったあとに,はてなブックマーク用のAPIを実行するだけですが、WebアプリケーションではなくスクリプトだけでOAuth認証を行うのは結構面倒くさいです。

理由はOAuth認証の途中で以下のような画面を表示してユーザが該当のアプリケーションを許可するプロセスが入るためです。

この問題は以下のページにて解決策が書かれています。

はてなのOAuth API用のアクセストークンを簡単に取得する [python]
https://www.iruca21.com/entry/2017/05/24/090000

簡単にいうと、ユーザーが画面で許可するボタンを押したのと同じ動きを行っています。先のコードでいうと_get_authorization_redirect_urlが、その動きに当たります。

また、python3xで動くようにするのと、できるだけ処理をrequests_oauthlibで行うように直してあります。

JavaScriptでテキストの差分を見るライブラリ

まえがき

JavaScriptで2ファイルのテキストの差分を確認するためのDiff用ライブラリについて調べます。

difflib

image.png

GitHub:
https://github.com/cemerick/jsdifflib

デモサイト
http://cemerick.github.io/jsdifflib/demo.html

サンプルコード

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
<link rel="stylesheet" href="diffview.css">
<script src="difflib.js"></script>
<script src="diffview.js"></script>
<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>
<!-- End Matomo Code -->
    </head>
    <body>
        <div id="output"></div>
        <script  type="text/javascript">
          const src1 = document.getElementById('src1').innerText;
          const src2 = document.getElementById('src2').innerText;

          var base = difflib.stringAsLines(src1);
          var newtxt = difflib.stringAsLines(src2);

          // create a SequenceMatcher instance that diffs the two sets of lines
          var sm = new difflib.SequenceMatcher(base, newtxt);

          // get the opcodes from the SequenceMatcher instance
          // opcodes is a list of 3-tuples describing what changes should be made to the base text
          // in order to yield the new text
          var opcodes = sm.get_opcodes();
          var contextSize = 0;
          document.getElementById('output').append(diffview.buildView({
              baseTextLines: base,
              newTextLines: newtxt,
              opcodes: opcodes,
              // set the display titles for each resource
              baseTextName: "Base Text",
              newTextName: "New Text",
              contextSize: contextSize,
              viewType: 1 // 0にするとbaseとnewTextが別の列になります
          }));

        </script>
    </body>
</html>

使用感

・シンプルで使い易いですが、細かい調整はできないようです。(たとえば文字レベルでの差分の表示は現時点でできない)
・積極的な開発はおこなわれていないようです。
・BSDライセンスです。

prettydiff

image.png

GitHub
https://github.com/prettydiff/prettydiff/

デモ
https://prettydiff.com/

ドキュメント
https://prettydiff.com/documentation.xhtml

サンプル

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
<script src="prettydiff/js/browser.js"></script>
<link href="prettydiff/css/index.css" media="all" rel="stylesheet" type="text/css"/>

<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>
<!-- End Matomo Code -->
    </head>
    <body>
        <div class="white" id="prettydiff"></div>
        <script  type="text/javascript">
          const src1 = document.getElementById('src1').innerText;
          const src2 = document.getElementById('src2').innerText;

// integrate into the browser
let output     = "",
    prettydiff = window.prettydiff,
    options    = window.prettydiff.options;
options.source_label = "修正前";
options.source = src1;
options.diff_label = "修正後";
options.diff = src2;
options.diff_format = "html";

options.mode = "diff";
options.language = "auto";
options.lexer = "text";
options.wrap = 10;
options.diff_view = 'inline'; // 'sidebyside'で別の列で表示
output         = prettydiff();
console.log(output);
          document.getElementById('prettydiff').innerHTML = output;
// You can include the Pretty Diff code in any way that is convenient,
// whether that is using an HTML script tag or concatenating the
// js/browser.js code with your other code.

        </script>
    </body>
</html>

使用感

・ブラウザで使用するbrowser.jsはnpmでインストール後、tscコマンドで作成されます
さまざまなオプションがあります
・ライセンスはCC0

mergely

image.png

GitHub
https://github.com/wickedest/Mergely

デモ
http://www.mergely.com/

サンプル

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>DIFF</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/codemirror.min.js"></script>
<link rel="stylesheet" media="all" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/codemirror.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.32.0/addon/search/searchcursor.min.js"></script>
<script src="mergely/libs/mergely.js" type="text/javascript"></script>
<link rel="stylesheet" media="all" href="mergely/libs/mergely.css" />
    </head>

<script id="src1" type="sourcecode">
function b() {
  console.log('TESTdddddddddddddddddddddddddddddddddddddddddddddddddddddddddsfafdasdfffffffffffffffffffffffffff0');
  console.log('TEST');
  console.log('TEST1');
}
</script>
<script id="src2" type="sourcecode">
function b() {
  console.log('TEST');
  console.log('TEST2xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');
  console.log('TEST3');
}
</script>

<body>
<div class="mergely-full-screen-8">
  <div class="mergely-resizer">
    <div id="mergely"></div>
  </div>
</div>

        <script>
$(document).ready(function () {
    $('#mergely').mergely({
        wrap_lines : true,
        cmsettings: { 
            readOnly: false,
            lineNumbers: true
        },
        lhs: function(setValue) {
            setValue(document.getElementById('src1').innerText);
        },
        rhs: function(setValue) {
            setValue(document.getElementById('src2').innerText);
        }
    });
});
        </script>
</body>

    </body>
</html>

使用感

・依存ライブラリが多い(JQuery,codemirror)
・codemirrorがエディタ用のライブラリなので、修正したテキストの差分がすぐ確認できる
・ライセンスはGNU LGPL v3.0

まとめ

・差分を表示するだけならprettydiffがよさそうです。
・マージなどの編集が必要ならmergelyになるでしょうが、JQueryに依存しています。
・2ファイルを入力とするのでなく、diffの結果を入力としてHTML表示するならdiff2htmlも使えそうです。
・なお、画像の差分をとるならjs-imagediffが使えそうでした(未検証)

参考

JavaScript based diff utility [closed]
https://stackoverflow.com/questions/3053587/javascript-based-diff-utility

Qiitaの記事をGitHubに移行する

はじめに

一つのサービスに依存するのは、リスクだと思うのでQiitaの記事をGitHubに移行するスクリプトを書いてみます。

なお、私の記事は以下のようになりました。
https://github.com/mima3/note

動作環境

Python 3.7.4
Windows10

事前準備

Qiitaのアクセストークンを取得します。

(1)設定画面のアプリケーションタブから「新しいトークンを発行する」を押下します。

image.png

(2)読み取り権限を付けて「発行」を押下します
image.png

(3)アクセストークンをメモしておきます
image.png

使用方法

(1)以下のリポジトリからスクリプトを取得する
https://github.com/mima3/qiita_exporter

(2)下記の形式でスクリプトを実行する。

python qiita_to_github.py [userid] [accesstoken] [保存先フォルダ] [GitHubのブロブのルートURL ex.https://github.com/mima3/note/blob/master]

(3)保存先フォルダをGitHubに登録します。

やっていること

・QiitaAPIを利用してQiita上の指定ユーザが記載した全てのマークダウンを取得します。
・画像をローカルにダウロードして、リンクを書き換えます。
・コードブロック以外の行にて、改行コードの前にスペース2ついれて改行を行います。
・「#タイトル」という記述があったら「# タイトル」に直します。
・コードブロックのタイトル(例:「```python:test.py」)が表示されないので対応します。
・自分の記事へのURLを移行先のURLに修正する

課題

・タグとかの表現をどうするか。
・コメントとかの取り扱いをどうするか。
・タイトルをファイル名としているが、使っている文字によっては動かない。タイトルによっては大文字に変換とかの小細工が必要になると思う。
・もしかするとWikiの機能を利用した方がいいかも。。。
・Qiitaで表示できてたTwitterの埋め込みは旨く表示されない。
image.png

image.png
GitHub PagesでJekyllが上手く動作しないマークダウンがある。たとえば{%s}とかいうコードが混ざっていると失敗する。
image.png

image.png

 

Vue.js+ bulmaでダイアログを表示してみる

目的

JavaScriptのフレームワークであるVue.jsとCSSのフレームワークである bulmaを使用してダイアログを表示してみる。

image.png

サンプル

Vue.jsにbulmadialog-componentコンポーネントを追加します。
その後、任意のタイミングで親側からコンポーネントのメソッドshowDialogを実行します。
この際、以下のプロパティを持つオブジェクトをパラメータに指定します。

  • title : ダイアログのタイトル
  • contents : ダイアログの内容。HTMLタグは無効
  • html : ダイアログの内容。HTMLのタグは有効
  • buttons : ボタン情報の配列
    • caption : ボタンのタイトル
    • callback : コールバック関数

コンポーネント側

IE11で動くようにしています。

bulma_dialog.js

/**
 * bulma + vue.jsでダイアログを表示します。
 * html: vue.jsの管理下に以下を追加します
 *   <bulmadialog-component ref="dialog"></bulmadialog-component>
 * js:Vue.jsを作成するときにコンポーネントを追加する
 *  components: {
      'bulmadialog-component': BulmaDialog,
    },        
 * js:Vue.jsの親のメソッドにて以下を実行
 *  this.$refs.dialog.showDialog({
        title:'わっふるる',
        //contents:'わっふるぼでぃ0\nsadfasfd',
        html : 'あたえたt<br>awrawtあたえたt<br>',
        buttons : [
          {
            caption : 'はい',
            callback : function () {
              console.log('はい');
            }
          },
          {
            caption : 'いいえ',
            callback : function () {
              console.log('いいえ');
            }
          }
        ]
      });
 */
// eslint-disable-next-line no-unused-vars
const BulmaDialog = {
  /* eslint-disable max-len */
  template: (function() {/*
      <div v-bind:class="{ 'is-active': isShow }" class="modal">
        <div class="modal-background"></div>
          <div class="modal-card">
              <header class="modal-card-head">
                  <p class="modal-card-title">{{data.title}}</p>
              </header>
              <div >
              </div>
              <section v-if="data.html" v-html="data.html" class="modal-card-body"></section>
              <section v-else class="modal-card-body">{{data.contents}}</section>
              <footer class="modal-card-foot"  style="justify-content: flex-end;">
                  <button v-for="btnObj in data.buttons" type="button" class="button" @click="btnObj.callback(); isShow = false;">{{btnObj.caption}}</button>
              </footer>
          </div>
      </div>
      </div>
    */}).toString().match(/\/\*([^]*)\*\//)[1],
  /* eslint-enable */
  data: function() {
    return {
      isShow: false,
      data: {
        title: '',
        body: '',
        html: '',
        buttons: [],
      },
    };
  },
  methods: {
    showDialog: function(data) {
      this.isShow = true;
      this.data.title = data.title;
      this.data.contents = data.contents;
      this.data.html = data.html;
      this.data.buttons = data.buttons;
    },
  },
};

使う側

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Hello Bulma!</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
    <script src="/phptest/codeview/js/bulma_dialog.js"></script>
  </head>
  <body>
  <div id="app">
    <bulmadialog-component ref="dialog"></bulmadialog-component>
    <button class="button is-primary" @click="test1">ダイアログA</button>
    <button class="button is-primary" @click="test2">ダイアログB</button>
  </div>
<script>
var app = new Vue({
  el: '#app',
  components: { //Scopedが使える
    'bulmadialog-component': BulmaDialog,
  },
  data: function() {
      return {
      }
  },
  methods: {
    test1 : function () {
      this.$refs.dialog.showDialog({
        title:'確認(TEXT)', 
        contents: "ふんぐるい むぐるうなふ くとぅるう るるいえ うがふなぐる ふたぐん<br>Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn",
        buttons : [
          {
            caption : 'はい',
            callback : function () {
              console.log('test1 はい');
            }
          },
          {
            caption : 'いいえ',
            callback : function () {
              console.log('test2 いいえ');
            }
          }
        ]
      });
    },
    test2 : function () {
      this.$refs.dialog.showDialog({
        title:'確認(HTML)', 
        html: "ふんぐるい むぐるうなふ くとぅるう るるいえ うがふなぐる ふたぐん<br>Ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn",
        buttons : [
          {
            caption : 'YES',
            callback : function () {
              console.log('Yes');
            }
          },
          {
            caption : 'No',
            callback : function () {
              console.log('No');
            }
          }
        ]
      });
    }
  }
})
</script>

  </body>
</html>

参考

SafariでもエラーにならないJavascriptのヒアドキュメントの書き方
https://qiita.com/ampersand/items/c6c773ba7ae9115856d0

メモ

Bulma自体がIEのサポートを部分的にのみしかしていないので、テンプレートリテラルを使ってIEをあきらめた方が無難かも

Bulma uses autoprefixer to make (most) Flexbox features compatible with earlier browser versions. According to Can I use, Bulma is compatible with recent versions of:

  • Chrome
  • Edge
  • Firefox
  • Opera
  • Safari

Internet Explorer (10+) is only partially supported.

https://github.com/jgthms/bulma

Twig + bulma でページネーションをやってみる

目的

PHPのテンプレートフレームワークであるTwigとCSSのフレームワークであるBulmaでページネーションをやってみる
image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

コード

PHPでテンプレートを使用する際に下記のデータを指定してください。

  • currentPage: 現在のページ番号
  • maxPage : 最大ページ番号
  • pageRange : ここで指定したページ数分、最初のページ, 最終ページ, 現在ページの前後ページへのリンクを省略せずに表示する。

PHP側

        return $this->view->render(
            $response,
            'commitlog.twig',
            [
                'BASE_PATH' => $this->config['BASE_PATH'],
                'commitlogs' => $commitlogs,
                'pageLimit' => $limit,
                'currentPage' => $page,
                'maxPage' => $maxPage,
                'pageRange' => 2
            ]
        );

ページネーション用のテンプレート

pagination.php

<nav class="pagination is-centered" role="navigation" aria-label="pagination">
    {% if currentPage != 1 %}
        <a class="pagination-previous" href="?page={{currentPage - 1}}">Previous</a>
    {% endif %}
    {% if currentPage != maxPage %}
        <a class="pagination-next" href="?page={{currentPage + 1}}">Next page</a>
    {% endif %}
    <ul class="pagination-list">
        {% set preItemHasEllipsis = false %}
        {% for i in range(1,maxPage) %}
            {% if i == currentPage %}
                <li><a class="pagination-link is-current" aria-lasbel="Goto page {{i}}">{{i}}</a></li>
                {% set preItemHasEllipsis = false %}
            {% elseif (i <= 1 + pageRange) or 
                      (i >= maxPage - pageRange) or
                      ((currentPage - pageRange <= i) and (i <= currentPage + pageRange))
            %}
                <li><a class="pagination-link" aria-lasbel="Goto page {{i}}" href="?page={{i}}">{{i}}</a></li>
                {% set preItemHasEllipsis = false %}
            {% elseif preItemHasEllipsis == false and ( 
                        (i == 1 + 1 + pageRange) or 
                        (i == maxPage - pageRange - 1) or
                        (i == currentPage - pageRange - 1) or
                        (i == currentPage + pageRange + 1)
                    )
            %}
                <li><span class="pagination-ellipsis">…</span></li>
                {% set preItemHasEllipsis = true %}
            {% endif %}
        {% endfor %}                
    </ul>
</nav>

読み出し側のテンプレート

// 略
        <h1 class="title">コミットログ</h1>
        <div class="content">
            <div class="table-container">
             // 略
            </div>

            {{ include("component/pagination.twig")}}
// 略

メモ

ループは効率が悪そうなので、速度が重要なら分岐でうまいこと作った方がいいと思う。

Githubでスターが多いWYSIWYGエディタ(2019年11月)

はじめに

2019/11時点でGithubにあるWYSIWYGタグでスターが多いライブラリを調べてみます。

Quill

概要

Github
https://github.com/quilljs/quill

Demo
https://quilljs.com/
image.png

・テーブルの作成はできないようです(ver2.x用にテーブル追加のプラグインはある)
・クリップボードを経由して画像のアップロードが可能です。
highlight.jsを使用してコードブロックのハイライトが可能のようです

対象ブラウザ
image.png
IEは非推奨のようです。

ライセンス
BSD 3-clause

サンプル

1.3.7のサンプル

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <!-- highlight.js を使う場合はquillの前に参照する-->
    <link rel="stylesheet"
          href="http://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.16.2/build/styles/default.min.css">
    <script src="http://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.16.2/build/highlight.min.js"></script>

    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

  </head>
  <body>

<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>
<button id="btnContent">コンテンツ取得</button>
<button id="btnImage">イメージの挿入</button>
<button id="btnDisable">編集可能/不可能</button>

<script>
var Delta = Quill.import('delta');
const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    syntax : true,              // Include syntax module
    // https://quilljs.com/docs/modules/toolbar/
    toolbar : [
      ['bold', 'italic', 'underline', 'strike'],
      [{ 'color': [] }, { 'background': [] }], 
      ['link', 'image'] ,
      ['code-block']
    ]
  }
});

document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});

document.getElementById('btnImage').addEventListener('click', function() {
  // このあたりを工夫すればクリップボードからの画像貼り付け等ができそう・・
  console.log(quill.getSelection(true).index);
  quill.insertEmbed(quill.getSelection(true).index, 'image', 'https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png');
});

let enableEditor = false;
document.getElementById('btnDisable').addEventListener('click', function() {
  quill.enable(enableEditor);
  enableEditor = !enableEditor;
  console.log(enableEditor);
});

/**
 * ペーストのイベント追加例
 */
quill.root.addEventListener("paste", function (t) {
  console.log('paste');
  console.log(t);
  return true;
} , false);

</script>
  </body>
</html>

拡張モジュール

画像の貼り付けについて

quill-image-drop-and-paste
https://github.com/chenjuneking/quill-image-drop-and-paste

image.png

quill-image-drop-and-pasteはどうも以下のように修正しないと動作しないようです。
export.ImageDropAndPasteを使用しているが、設定していないので替わりにImageDropAndPasteを設定する。

(function(){var exports={};
"use strict";Object.defineProperty(exports,"__esModule",{value:true});var _createClass=function(){function e(e,t){for(var a=0;a<t.length;a++){var n=t[a];n.enumerable=n.enumerable||false;n.configurable=true;if("value"in n)n.writable=true;Object.defineProperty(e,n.key,n)}}return function(t,a,n){if(a)e(t.prototype,a);if(n)e(t,n);return t}}();function _classCallCheck(e,t){if(!(e instanceof t)){throw new TypeError("Cannot call a class as a function")}}var ImageDropAndPaste=function(){function e(t){var a=arguments.length>1&&arguments[1]!==undefined?arguments[1]:{};_classCallCheck(this,e);this.quill=t;this.options=a;this.handleDrop=this.handleDrop.bind(this);this.handlePaste=this.handlePaste.bind(this);this.quill.root.addEventListener("drop",this.handleDrop,false);this.quill.root.addEventListener("paste",this.handlePaste,false)}_createClass(e,[{key:"handleDrop",value:function e(t){var a=this;t.preventDefault();if(t.dataTransfer&&t.dataTransfer.files&&t.dataTransfer.files.length){if(document.caretRangeFromPoint){var n=document.getSelection();var i=document.caretRangeFromPoint(t.clientX,t.clientY);if(n&&i){n.setBaseAndExtent(i.startContainer,i.startOffset,i.startContainer,i.startOffset)}}this.readFiles(t.dataTransfer.files,function(e,t){if(typeof a.options.handler==="function"){a.options.handler(e,t)}else{a.insert.call(a,e,t)}},t)}}},{key:"handlePaste",value:function e(t){var a=this;if(t.clipboardData&&t.clipboardData.items&&t.clipboardData.items.length){this.readFiles(t.clipboardData.items,function(e,t){if(typeof a.options.handler==="function"){a.options.handler(e,t)}else{a.insert(e,t)}},t)}}},{key:"readFiles",value:function e(t,a,n){[].forEach.call(t,function(e){var t=e.type;if(!t.match(/^image\/(gif|jpe?g|a?png|svg|webp|bmp)/i))return;n.preventDefault();var i=new FileReader;i.onload=function(e){a(e.target.result,t)};var r=e.getAsFile?e.getAsFile():e;if(r instanceof Blob)i.readAsDataURL(r)})}},{key:"insert",value:function e(t,a){var n=(this.quill.getSelection()||{}).index||this.quill.getLength();this.quill.insertEmbed(n,"image",t,"user")}}]);return e}();exports.default=ImageDropAndPaste;
window.Quill.register('modules/imageDropAndPaste',ImageDropAndPaste)})(); // export.ImageDropAndPaste->ImageDropAndPaste
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
    <script src="https://cdn.quilljs.com/1.3.7/quill.js"></script>

    <script src="quill-image-drop-and-paste-master/quill-image-drop-and-paste.min.js" type="text/javascript"></script>

  </head>
  <body>
<button id="btnContent">コンテンツ取得</button>
<br>
クリップボードからイメージをbase64で張り付けている。
<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>

<script>

const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    imageDropAndPaste : true
  }
});
document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});
</script>
  </body>
</html>

テーブル操作

quilljs-table

quilljs-table
https://github.com/dost/quilljs-table

image.png

サンプルを見る限り、テーブルの削除や列、行の削除がGUIからできそうにないです。
最終コミット日が2017年。

quilljs-table

quilljs-table
https://github.com/volser/quill-table-ui

quilljs v2.0.0-dev.3が必要になります。
最終更新日は2019年10月25日です。

テーブルの操作は以下のようなイメージになります。
image.png

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Quill</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.0-dev.3/quill.min.js" type="text/javascript"></script>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/quill/2.0.0-dev.3/quill.snow.min.css" rel="stylesheet">

    <script src="https://unpkg.com/quill-table-ui@1.0.5/dist/umd/index.js" type="text/javascript"></script>
    <link href="https://unpkg.com/quill-table-ui@1.0.5/dist/index.css" rel="stylesheet">

  </head>
  <body>
<button id="btnContent">コンテンツ取得</button>
<button id="btnTable">テーブル追加</button>
<br>
クリップボードからイメージをbase64で張り付けている。
<!-- Create the editor container -->
<div id="editor">
  <p>Hello World!</p>
</div>

<script>

Quill.register({
  'modules/tableUI': quillTableUI.default
}, true);

const quill = new Quill('#editor', {
  theme: 'snow',
  modules: {
    table: true,
    tableUI: true,
  }
});
document.getElementById('btnContent').addEventListener('click', function() {
  console.log(quill.getContents());
});
document.getElementById('btnTable').addEventListener('click', function() {
  let table = quill.getModule('table');
  console.log(table);
  table.insertTable(3, 3);
});

</script>
  </body>
</html>

メモ

2019/11/28時点の最終リリースはバージョン1.3.7です。
2.0の開発が進められていますが、そのマイルストーンは不透明なものとなっています。
https://github.com/quilljs/quill/issues/2435

moduleを実装することで拡張機能が作れる模様。

trix

Github
https://github.com/basecamp/trix

Demo
https://trix-editor.org/
image.png

対象ブラウザ
IE 11以降をサポートしているようです。
https://github.com/basecamp/trix/issues/173

ライセンス
MIT License

サンプル

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Trix</title>
    <link rel="stylesheet" type="text/css" href="trix.css">
    <script type="text/javascript" src="trix.js"></script>
  </head>
  <body>

<!-- Create the editor container -->
<trix-editor class="trix-content">サンプル</trix-editor>

<button id="btnContent">コンテンツ取得</button>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>

<script>
document.getElementById('btnContent').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  console.log(element.editor.getDocument());
  console.log(element.editor.getDocument().toString());
});

// エディタの内容はJSON化して保存と読み込みが可能
document.getElementById('btnSave').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  localStorage["editorState"] = JSON.stringify(element.editor);
});

document.getElementById('btnLoad').addEventListener('click', function() {
  var element =  document.querySelector("trix-editor");
  element.editor.loadJSON(JSON.parse(localStorage["editorState"]));
});

// イベントの確認
addEventListener("trix-attachment-add", function(event) {
  // 添付ファイルや画像を追加するとこのイベントが実行される
  // 以下のコードを参考にfileuploadとかができそう
  // https://trix-editor.org/js/attachments.js
  console.log('trix-attachment-add');
  console.log(event.attachment);
});
addEventListener("trix-attachment-remove", function(event) {
  // 添付ファイルや画像を削除するとこのイベントが実行される
  console.log('trix-attachment-remove');
  console.log(event.attachment);
});

addEventListener("trix-change", function(event) {
  // 内容が変化した場合実行
  console.log('trix-change');
  console.log(event);
});

</script>
  </body>
</html>

メモ

学習コストは低いと思われる。
※最低限の動作確認はtrix-editorタグを作ってtrix.jsを読み込むだけでいい。

テーブルをサポートする予定はない。
https://github.com/basecamp/trix/issues/539

コードブロックはあるが強調表示はサポートしていない。
image.png

拡張とかはできなさそう。

MediumEditor

medium.comインラインエディターツールバーのクローン

Github
https://github.com/yabwe/medium-editor

Demo
http : //yabwe.github.io/medium-editor/
image.png

画像の貼り付けやコードブロックはなさそう。

対象ブラウザ
image.png
IEをサポートしている

ドキュメント
https://github.com/yabwe/medium-editor/wiki

ライセンス
MIT

サンプル

単純な例

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Trix</title>
    <script src="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/js/medium-editor.min.js"></script>
    <link rel="stylesheet" href="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/css/medium-editor.min.css" type="text/css" media="screen" charset="utf-8">
  </head>
  <body>

<div class="editable"></div>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>
<script>
var editor = new MediumEditor('.editable', {
    placeholder: {
        text: 'テキストを入力してください',
        hideOnClick: true
    },
    toolbar: {
        /* These are the default options for the toolbar,
           if nothing is passed this is what is used */
        allowMultiParagraphSelection: true,
        buttons: ['bold', 'italic', 'underline', 'anchor', 'h2', 'h3', 'quote'],
        diffLeft: 0,
        diffTop: -10,
        firstButtonClass: 'medium-editor-button-first',
        lastButtonClass: 'medium-editor-button-last',
        relativeContainer: null,
        standardizeSelectionStart: false,
        static: false,
        /* options which only apply when static is true */
        align: 'center',
        sticky: false,
        updateOnEmptySelection: false
    }
});

document.getElementById('btnSave').addEventListener('click', function() {
  console.log(editor.getContent());
  localStorage["medium"] = editor.getContent();
});
document.getElementById('btnLoad').addEventListener('click', function() {
  console.log(editor.getContent());
  editor.setContent(localStorage["medium"]);
});
</script>
  </body>
</html>

MediumEditor Tables

テーブルの作成を行うプラグインです。
Jqueryに依存しています。

GitHub
https://github.com/yabwe/medium-editor-tables

demo
https://yabwe.github.io/medium-editor-tables/

tablemedium.gif

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Medium</title>
    <link rel="stylesheet" href="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/css/medium-editor.min.css" type="text/css" media="screen" charset="utf-8">

    <!-- medium-editor-tables.js が使用している -->
    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>

    <script src="http://cdn.jsdelivr.net/npm/medium-editor@latest/dist/js/medium-editor.min.js"></script>
    <script type="text/javascript" src="lib/js/medium-editor-tables.js"></script>

    <link rel="stylesheet" href="lib/css/medium-editor-tables.css" />

  </head>
  <body>

    <div class="editable"></div>

<script>
  var editor = new MediumEditor('.editable', {
    toolbar: {
      buttons: [
        'bold',
        'italic',
        'table'
      ]
    },
    extensions: {
      table: new MediumEditorTable()
    }
  });
</script>
  </body>
</html>

jQuery insert plugin for MediumEditor

画像やYoutubeやTwitterなどの埋め込みが可能なプラグインです。
Jqueryに依存します。

Github
https://github.com/orthes/medium-editor-insert-plugin

demo
https://linkesch.com/medium-editor-insert-plugin/

tablemedium3.gif

<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Test Medium</title>
    <link href="http://netdna.bootstrapcdn.com/font-awesome/4.0.3/css/font-awesome.css" rel="stylesheet">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/css/medium-editor-insert-plugin-frontend.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/css/medium-editor-insert-plugin.min.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/medium-editor/5.23.3/css/medium-editor.min.css" />

    <!-- medium-editor-tables.js が使用している -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.12/handlebars.runtime.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-sortable/0.9.13/jquery-sortable-min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/jquery.ui.widget@1.10.3/jquery.ui.widget.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.iframe-transport/1.0.1/jquery.iframe-transport.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-file-upload/9.28.0/js/jquery.fileupload.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/medium-editor/5.23.3/js/medium-editor.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/medium-editor-insert-plugin/2.5.0/js/medium-editor-insert-plugin.min.js"></script>

  </head>
  <body>

    <div class="editable"></div>
<button id="btnSave">セーブ</button>
<button id="btnLoad">ロード</button>
<script>
  var editor = new MediumEditor('.editable', {
    toolbar: {
      buttons: [
        'bold',
        'italic',
        'table'
      ]
    }
  });
  $('.editable').mediumInsert({
      editor: editor
  });
document.getElementById('btnSave').addEventListener('click', function() {
  console.log(editor.getContent());
  localStorage["medium"] = editor.getContent();
});
document.getElementById('btnLoad').addEventListener('click', function() {
  console.log(editor.getContent());
  editor.setContent(localStorage["medium"]);
});
</script>
  </body>
</html>

メモ

Medium Editorのライブラリ自体はJavaScriptのみで外部のライブラリに依存していない。
しかし、その拡張機能がJQueryに依存している。

任意の拡張機能が作成可能。
https://github.com/yabwe/medium-editor/blob/master/src/js/extensions/README.md

Pell

もっともサイズの小さいWYSIWYGライブラリで他のライブラリに依存しません。

GitHub
https://github.com/jaredreich/pell

Demo
https://jaredreich.com/pell/
画像はURL指定して表示。
Link等でダイアログを表示する際はブラウザのメッセージボックスを使用している

image.png

対象ブラウザ
image.png
かなり古いブラウザでも動作するようです。

ライセンス
MIT

メモ

軽量であるのが売り。
テーブル機能はなさそう。
また拡張機能等はなさそう。

Editor.js

GitHub
https://github.com/codex-team/editor.js

Demo
https://editorjs.io/

image.png

・表、画像のアップロードをサポートしている。
・ツールバーは表示されずに、必要な時にポップアップが出る

対象ブラウザ
image.png

IEは対象外の模様
※すくなくともデモサイトはIE11で動作しない

ドキュメント
https://github.com/codex-team/editor.js/tree/bcdfcdadbc444921aee62b38516329cda3c96a70/docs

ライセンス
Apache License 2.0
寄付を受け付けている
https://opencollective.com/editorjs

サンプル

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Editor.js example</title>
  <link href="https://fonts.googleapis.com/css?family=PT+Mono" rel="stylesheet">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
</head>
<body>
  <div class="ce-example">
    <div class="ce-example__content _ce-example__content--small">
      <div id="editorjs"></div>

      <button id="saveButton">
        editor.save()
      </button>
      <button id="loadButton">
        editor.load()
      </button>
    </div>
  </div>

  <!-- Load Tools -->
  <!--
   You can upload Tools to your project's directory and use as in example below.
   Also you can load each Tool from CDN or use NPM/Yarn packages.
   Read more in Tool's README file. For example:
   https://github.com/editor-js/header#installation
   -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/header@latest"></script><!-- Header -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/simple-image@latest"></script><!-- Image -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/delimiter@latest"></script><!-- Delimiter -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/list@latest"></script><!-- List -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/checklist@latest"></script><!-- Checklist -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@latest"></script><!-- Quote -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/code@latest"></script><!-- Code -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@latest"></script><!-- Embed -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/table@latest"></script><!-- Table -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/link@latest"></script><!-- Link -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/warning@latest"></script><!-- Warning -->

  <script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@latest"></script><!-- Marker -->
  <script src="https://cdn.jsdelivr.net/npm/@editorjs/inline-code@latest"></script><!-- Inline Code -->

  <!-- Load Editor.js's Core -->
  <script src="./dist/editor.js"></script>

  <!-- Initialization -->
  <script>
    /**
     * To initialize the Editor, create a new instance with configuration object
     * @see docs/installation.md for mode details
     */
    var initObj = {
      /**
       * Wrapper of Editor
       */
      holder: 'editorjs',
      /**
       * Tools list
       */
      tools: {
        /**
         * Each Tool is a Plugin. Pass them via 'class' option with necessary settings {@link docs/tools.md}
         */
        header: {
          class: Header,
          inlineToolbar: ['link'],
          config: {
            placeholder: 'Header'
          },
          shortcut: 'CMD+SHIFT+H'
        },
        /**
         * Or pass class directly without any configuration
         */
        image: {
          class: SimpleImage,
          inlineToolbar: ['link'],
        },
        list: {
          class: List,
          inlineToolbar: true,
          shortcut: 'CMD+SHIFT+L'
        },
        checklist: {
          class: Checklist,
          inlineToolbar: true,
        },
        quote: {
          class: Quote,
          inlineToolbar: true,
          config: {
            quotePlaceholder: 'Enter a quote',
            captionPlaceholder: 'Quote\'s author',
          },
          shortcut: 'CMD+SHIFT+O'
        },
        warning: Warning,
        marker: {
          class:  Marker,
          shortcut: 'CMD+SHIFT+M'
        },
        code: {
          class:  CodeTool,
          shortcut: 'CMD+SHIFT+C'
        },
        delimiter: Delimiter,
        inlineCode: {
          class: InlineCode,
          shortcut: 'CMD+SHIFT+C'
        },
        linkTool: LinkTool,
        embed: Embed,
        table: {
          class: Table,
          inlineToolbar: true,
          shortcut: 'CMD+ALT+T'
        },
      },
      /**
       * This Tool will be used as default
       */
      // initialBlock: 'paragraph',
      /**
       * Initial Editor data
       */
      data: {
      },
      onReady: function(){
      },
      onChange: function() {
        console.log('something changed');
      }
    };
    var editor = new EditorJS(initObj);
    /**
     * Saving example
     */
    const saveButton = document.getElementById('saveButton');
    const loadButton = document.getElementById('loadButton');

    saveButton.addEventListener('click', function () {
      editor.save().then((savedData) => {
        console.log(savedData);
        localStorage["editJs"] = JSON.stringify(savedData);
      });
    });
    loadButton.addEventListener('click', function () {
      let data = JSON.parse(localStorage["editJs"]);
      console.log(data);
      editor.render(data);
    });
  </script>
</body>
</html>

メモ

IEは動作しない
undo機能は2019/11/28時点では自前でやる必要があるっぽい。
https://github.com/codex-team/editor.js/issues/518

CKEditor5

Gitのスター順で並べると上位5位にでてきませんが、バージョンごとにプロジェクトが分かれているっぽいので累計すると、結構使われているように見えます。
公式ページを見ると累計で27.500.000+のダウンロードが行われているそうです。

GitHub
https://github.com/ckeditor/ckeditor5

Demo
https://ckeditor.com/ckeditor-5/demo/

ライセンス
GNU General Public License Version 2 or later.

商用ライセンスがある。
https://ckeditor.com/pricing/#null

メモ

この中ではデモが一番、使い易かったので、金が豊富にあるならコレがよさそう。

まとめ

色々調べましたが、結局、一長一短ある感じがします。
その上で個人的にはコードのハイライトが簡単に使えそうなQuillか、学習コストの低そうなtrixがよさそうに見えます。

復活のVisualStudioのマクロ

はじめに

VisualStudio2008あたりまではVisualStudioにマクロがついていました。
この機能は最近のVisualStudioからは削除されましたが、再び拡張機能として復活しています。

https://github.com/microsoft/VS-Macros

VisualStudio2019でもそれっぽく動作するようです。

導入手順

(1)拡張機能の管理より「Macros for Visual Studio」をインストールします
image.png

(2)インストール後、再起動をするとツールからMacrosメニューが使用できるようになります。
image.png

(3)Macro Exploreを選択すると、Macro Exploreウィンドウが表示されます。
image.png

(4)JSファイルを右クリックして開くを選択することでJSの中身が編集可能になります。
image.png

なお、JSファイルをダブルクリックすることで記載したJSを実行可能です。

サンプル

出力ウィンドウに文字列を出力する方法

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
// https://github.com/Microsoft/VS-Macros/issues/28
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();
outputWindowPane.OutputString("display this text in the output window panel\n");

image.png

出力ウィンドウのクリアを行う

dte.ExecuteCommand("Edit.ClearAll");

ソリューションのプロジェクトのアイテムをすべて列挙する方法

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();

function dumpProjectItems(projectItems, lv) {
    for (var i = 1; i <= projectItems.Count; i++) {
        var file = projectItems.Item(i);
        for (var j = 0; j < lv; ++j) {
            outputWindowPane.OutputString(' ');
        }
        outputWindowPane.OutputString(file + "\n");

        if (file.SubProject != null) {
            dumpProjectItems(file.ProjectItems, lv + 1);
        } else if (file.ProjectItems != null && file.ProjectItems.Count > 0) {
            dumpProjectItems(file.ProjectItems, lv + 1);
        }
    }
}

for (var i = 1; i <= dte.Solution.Projects.Count; i++) {
    outputWindowPane.OutputString(dte.Solution.Projects.Item(i).Name + "\n");
    dumpProjectItems(dte.Solution.Projects.Item(i).ProjectItems, 1);
}

関数の先頭に移動してその関数の情報を取得

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var outputWindowPane = dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Object.ActivePane;
dte.Windows.Item("{34E76E81-EE4A-11D0-AE2E-00A0C90FFFC3}").Activate();

var textSelection = dte.ActiveDocument.Selection;

// Define Visual Studio constants
var vsCMElementFunction = 2;
var vsCMPartHeader = 4;

var codeElement = textSelection.ActivePoint.CodeElement(vsCMElementFunction);

if (codeElement != null) {
    // 関数の先頭に移動
    textSelection.MoveToPoint(codeElement.GetStartPoint(vsCMPartHeader));
    dte.ActiveDocument.Activate();

    outputWindowPane.OutputString(codeElement.FullName + "\n");
    outputWindowPane.OutputString(codeElement.GetStartPoint(vsCMPartHeader).Line + "\n");
    outputWindowPane.OutputString(codeElement.GetEndPoint().Line + "\n");
    outputWindowPane.OutputString(codeElement.FullName + "\n");
    outputWindowPane.OutputString(codeElement.Parameters.Count + "\n");
    // Itemの列挙ができない模様
    /*
    for (var i = 0; i < codeElement.Parameters.Count; ++i) {
        // Itemが認識できない
        outputWindowPane.OutputString(codeElement.Parameters.Item(i) + "\n");
    }
    */
    outputWindowPane.OutputString(codeElement.DocComment + "\n");

}

時刻のインサート

/// <reference path="C:\Users\ユーザー名\AppData\Local\Microsoft\VisualStudio\16.0_ea48cace\Macros\dte.js" />
var date = new Date();

var day = date.getDate();
var month = date.getMonth() + 1;
var year = date.getYear();

var hours = date.getHours();
var minutes = date.getMinutes();

// Add a zero if single digit
if (day <= 9) day = "0" + day;
if (month <= 9) month = "0" + month;
if (minutes <= 9) minutes = "0" + minutes;
if (hours <= 9) hours = "0" + hours;

Macro.InsertText("//" + year + "/" + month + "/" + day + ", " + hours + ":" + minutes);

まとめ

VisualStudioのマクロは復活しましたが、デバッグ機能がない、すべての機能が使えるわけでもない、メンテナンスする気はないとうたっているので、使いどころは限られるようです。

20年物のC言語で作られたシステムのテスト工程を改善しようとした話

はじめに

ちょっと前に20年物のC言語で作られたシステムのテストを色々改善しようとしてみたので、この時に得たちょっとした知見を書いていこうと思います。

※注意
記事を書くために自分のパソコンで当時を思い出しながら環境を作っているので、実際、実務でやった環境やバージョンとは違います。
また、この記事にはいくつかコードがでてきますが、すべて記事を書くために考えた疑似的な例にすぎません。

単体テスト用のテストコードの作成

20年も動いているシステムだと、もはや誰にも意味はわからんが、既存の挙動を変えてはいけない箇所がいくつもあります。

そういう箇所に手を入れざるを得ないときに、有効な方法として以下のような方法があります。

まず、既存のコードに対するテストコードを記載します。そして全て合格することを確認してから、少しづつ機能を拡張していきます。
これにより、新規機能追加が既存の機能を壊していないことを確認しながら機能追加の作業を進めることが可能になります。

いわゆるテストファーストになりますが、こういう長い歴史をもつ保守的な環境で、XPで言っていたようなことをやると色々と歪みが出ます。
それについてはこの章の最後に記載します。

xUnitの導入

assert等で自力で頑張ってもいいですが、CUnitなどのxUnitのテストフレームワークを使用した方がいいです。

※今回はCUnitしか検証していませんが、最近のモノだと以下のようなC言語用のユニットテストのライブラリがあるようです。

Cutter(2019-09-13にリリースされた1.2.7が最新)
https://cutter.osdn.jp/index.html.ja

UNITY(2017/11にv2.4.3をリリース。コミット履歴は2019/10のものもある)
http://www.throwtheswitch.org/unity
https://github.com/ThrowTheSwitch/Unity

また、CPPUnitなどのC++用のxUnitのフレームワークからもテストが可能ですが、今回は外しています。

何故xUnitのテストフレームワークが必要か

有名どころのテストフレームワークを使用した方が、実現例とかが世の中で出回っていてトラブル時に助かります。
また、有名どころのテストフレームワークを使用するとJenkinsとの連携が楽になります。

たとえばJenkinsのxUnit Pluginは有名どころのxUnitで出力されたXMLを集計する機能を有しており、簡単に自動テストの結果を集計することが可能になっています。

CUnit

CUnitはC言語用のxUnitのフレームワークでgccやvisual cから使用することが可能です。

visual studio2019でビルドする方法

VS2008とか2005の時代のソリューションしかないのでいかんせ古くてワーニングが出まくります。

  1. CUnit-2.1-3/VC9/CUnit.slnを開く
  2. CUnit-2.1-3/CUnit/Headers/CUnit.h.inを基にCUnit.hを作成します。違いは以下の通りです。
    image.png
  3. libcunit->その他のプロジェクトの順番でビルドしていきます。
    ※一度にビルドしてエラーが出る場合は、プロジェクトの依存関係を見直してください。また、プロジェクトの同時ビルド数を1にすると正常にビルドできるようになります。

Visual Stduio2019でプロジェクトの同時ビルド数の確認方法
メニューの「ツール」→「オプション」でオプションダイアログを開き「プロジェクトおよびソリューション」→「ビルド/実行」で該当の画面が表示されます。
image.png

makeコマンドでビルドする方法

以下でWindows10 + Ubuntu 18.04の環境下でmakeコマンドを利用してビルドする例を示します。
基本的に以下の記事と同じ方法でビルドできますがバージョンは2.1-3にしています。

CUnitでCプログラムの単体テストをする
https://qiita.com/muniere/items/ff9a984ed7e51eee7112

wget https://jaist.dl.sourceforge.net/project/cunit/CUnit/2.1-3/CUnit-2.1-3.tar.bz2
tar xvf CUnit-2.1-3.tar.bz2
cd CUnit-2.1-3
aclocal
autoconf
automake
./configure
make
make install
トラブルシュート
automakeやautoconfコマンドがない

以下を実行してインストールする。

sudo apt-get install autoconf              
configureとか実行すると「configure: error: cannot find install-sh, install.sh, or shtool in "." "./.." "./../.."」というエラーがでる。

下記のコマンドを実行してautoconfからやり直す

autoreconf -i
configure時に「error: Libtool library used but 'LIBTOOL' is undefined」が発生した

以下のコマンドでlibtoolをインストールする。

sudo apt-get install libtool
実行時に「error while loading shared libraries: libcunit.so.1: cannot open shared object file: No such file or directory」が出る場合

環境変数LD_LIBRARY_PATHを設定する。

export LD_LIBRARY_PATH=/usr/local/lib

永続化したい場合は.bashrcにでも記載しておく

実行例

コンソールで実行する場合は以下のようになります。

# include <CUnit/CUnit.h>

int max(int x, int y) {
    if (x>y) {
        return x;
    } else {
        return y;
    }
}
int min(int x, int y) {
    /** bug  **/
    if (x>y) {
        return x;
    } else {
        return y;
    }
}

void test_max_001(void) {
    CU_ASSERT_EQUAL(max(5, 4) , 5);
}
void test_max_002(void) {
    CU_ASSERT_EQUAL(max(4, 5) , 5);
}
void test_max_003(void) {
    CU_ASSERT_EQUAL(max(5, 5) , 5);
}
void test_min_001(void) {
    CU_ASSERT_EQUAL(min(5, 4) , 4);
}
void test_min_002(void) {
    CU_ASSERT_EQUAL(min(4, 5) , 4);
}
void test_min_003(void) {
    CU_ASSERT_EQUAL(min(5, 5) , 5);
}

int main() {
    CU_pSuite max_suite, min_suite;

    CU_initialize_registry();
    max_suite = CU_add_suite("max", NULL, NULL);
    CU_add_test(max_suite, "test_001", test_max_001);
    CU_add_test(max_suite, "test_002", test_max_002);
    CU_add_test(max_suite, "test_003", test_max_003);

    min_suite = CU_add_suite("min", NULL, NULL);
    CU_add_test(min_suite, "test_001", test_min_001);
    CU_add_test(min_suite, "test_002", test_min_002);
    CU_add_test(min_suite, "test_003", test_min_003);
    CU_console_run_tests();
    CU_cleanup_registry();

    return(0);
}

他のASSERT用の関数については下記を参照してください。
http://cunit.sourceforge.net/doc/writing_tests.html

CUnitを実行するソースをコンパイルする際はlibcunit.aにリンクしておいてください。

gcc unit.c  -Wall -L/usr/local/lib -lcunit -o unit

コンソールで実行した場合は以下のようになります。
image.png

Jenkinsとの連携方法

CUnitの結果をJenkinsに取り込む方法について説明します。

テストコードでXMLを出力するようにする

Jenkinsに渡すXMLファイルを作成するようにコードを変更します。
XMLファイルに出力する場合、CU_console_run_testsの代わりにCU_automated_run_testsとCU_list_tests_to_fileを使用します。

/**略**/
    /** コンソール出力
    CU_console_run_tests();
    */
    /** XML の出力 **/
    CU_set_output_filename("./unit");
    CU_automated_run_tests();
    CU_list_tests_to_file();
    CU_cleanup_registry();
/**略**/

上記のファイルを変更してコンパイルして実行するとコンソールに出力する代わりに下記のXMLファイルが作成されます。

  • unit-Listing.xml
  • unit-Results.xml

CUnitの実行方法の詳細については下記を参照してください。
http://cunit.sourceforge.net/doc/running_tests.html#overview

Jenkinsでテスト結果のXMLを解析するようにする。

プラグインの管理でxUnit Pluginをインストールします。
image.png

ジョブの設定でビルド後の処理に「Publish xUnit test result report」を追加してCUnit-2.1を選択します。

image.png

Patternにはunit-Results.xmlを指定してください。

ビルドを実行すると以下のような結果が作成されます。
image.png

image.png

テスト用のスタブの作成

テストを自動化する場合、xUnitのテストフレームワークだけを用意して自動化をしようとすると、導入に失敗するケースが多いです。
単純なユーティリティー関数しかテストできずに廃れていくか、あるいは、依存するライブラリの整合性をとるために複雑なテストコードを記述する羽目になって破綻するかのどちらかです。

たとえば以下のようにライブラリ1に依存するライブラリ2があるとします。
image.png

ライブラリ2をテストする場合、ライブラリ1のテスト用のスタブを作成し、ライブラリ2のテストの都合のいいデータを返すようにします。
そうすることで、仮にライブラリ1がネットワークやデータベースに依存していたとしても、実際にはそれらを使用せずにテストに都合のいい状態にすることができます。
image.png

単純なスタブの作成例

単純な例でライブラリ1に依存しているライブラリ2のテストを書く方法について考えてみましょう。

ライブラリ1のサンプル

ライブラリ1は以下のような実装とします。
引数を2つ受け取り、それを加算した結果を返すだけのaddという関数を用意します。

static1.h

# ifndef STATIC1_H
# define STATIC1_H
extern int add(int x, int y);
# endif

static1.c

# include "static1.h"

int add(int x, int y) {
    return x + y;
}
ライブラリ2のサンプル

ライブラリ2は以下のような実装とします。
ライブラリ2ではproc関数でライブラリ1のadd関数を利用して処理をしています。

static2.h

# ifndef STATIC2_H
# define STATIC2_H

# include "static1.h"

extern int proc(int x, int y, int z);

# endif

static2.h

# include "static2.h"

int proc(int x, int y, int z) {
    int a = add(x,y);
    return add(a, z);
}
ライブラリ1のスタブ

本物のコードの代わりに、スタブを作成します。
このスタブの処理はadd関数が実行されるたびに以下の処理を行います。
・呼び出された回数の記録
・テストコードで事前に登録されたコールバック関数の実行

stub_static.h

# ifndef STUB_static1_H
# define STUB_static1_H
# include "static1.h"

typedef int (*pfunc_add_def) ( int x , int y );

typedef struct {

    int count_add;
    pfunc_add_def pfunc_add;
} stub_data_def_static1;
extern stub_data_def_static1 stub_data_static1;
# endif

stub_static.c

# include "stub_static1.h"
stub_data_def_static1 stub_data_static1;

int add ( int x , int y ) {
    ++stub_data_static1.count_add;

    return stub_data_static1.pfunc_add( x , y );

}
テストコードの例

add関数のスタブから実行されるコールバック関数をテストコードに定義して、proc関数を実行する例を以下に示します。

# include <CUnit/CUnit.h>
# include "static2.h"
# include "stub_static1.h"

int test001_stub_add(int x, int y) {
    /** add()のスタブ。CUNITでテストするならxとyを **/
    if (stub_data_static1.count_add == 1) {
        /** 1回目の呼び出し **/
        CU_ASSERT_EQUAL(x , 10);
        CU_ASSERT_EQUAL(y , 5);
        return 15;
    }
    if (stub_data_static1.count_add == 2) {
        CU_ASSERT_EQUAL(x , 15);
        CU_ASSERT_EQUAL(y , 4);
        return 19;
    }
    CU_FAIL("addの呼び出し回数不正");
    return -1;
}

void test001() {
    memset(&stub_data_static1, 0, sizeof(stub_data_static1));
    stub_data_static1.pfunc_add = test001_stub_add;
    int ret = proc(10, 5,4);

    /** 呼び出し回数の確認 **/
    CU_ASSERT_EQUAL(stub_data_static1.count_add , 2);

    CU_ASSERT_EQUAL(ret , 19);
}

int main() {
    CU_pSuite suite;

    CU_initialize_registry();
    suite = CU_add_suite("stubsample", NULL, NULL);
    CU_add_test(suite, "test001", test001);
    CU_console_run_tests();
    CU_cleanup_registry();

    return(0);
}

スタブ用関数が複数回呼び出される場合は、呼び出し回数を元に、スタブが受け取る予定の引数の値をチェックし、スタブが返すべき値を決定します。

スタブの自動作成

前項でスタブを利用した簡単なテストコードの例を紹介しました。
次に問題となるのが、スタブの作成コストです。

手で作成していくことも不可能ではないですが、ライブラリ1の関数が大量にある場合や、更新頻度が多い場合、手で修正していくのは辛いものがあります。
そこで、スタブを自動生成する方法について考えてみましょう。

基本的には単純な変換になります。
そのため、どんなプログラミング言語でもスタブの自動生成できますが、楽をしたいなら、テンプレートライブラリとC言語のパーサーが使えるプログラミング言語がよいでしょう。

Pythonでスタブを自動生成してみる

C言語のパーサーライブラリ

pycparserを使用することでC/C++のソースコードの解析が行えます。
https://github.com/eliben/pycparser

注意事項として、このライブラリはプリプロセッサで処理済みのコードに対してのみしか動作しません。そのため、実際、解析対象とするソースコードは以下のようにプリプロセッサを実行した結果を用いてください。

gcc -E static1.h

今回は以下のようなプリコンパイル済みのヘッダーファイルを解析するものとします。

解析対象のヘッダファイル

extern int add(int x, int y);
typedef struct {
 int x;
 int y;
} in_data_t;
typedef struct {
 int add;
 int minus;
} out_data_t;
extern void calc(in_data_t* i, out_data_t *o);
extern int max(int data[], int length);

ヘッダファイルを解析するサンプル

import sys
from pycparser import c_parser, c_ast, parse_file

# https://github.com/eliben/pycparser/blob/master/examples/cdecl.py
def _explain_type(decl):
    """ Recursively explains a type decl node
    """
    typ = type(decl)

    if typ == c_ast.TypeDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type)
    elif typ == c_ast.Typename or typ == c_ast.Decl:
        return _explain_type(decl.type)
    elif typ == c_ast.IdentifierType:
        return ' '.join(decl.names)
    elif typ == c_ast.PtrDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type) + '*'
    elif typ == c_ast.ArrayDecl:
        arr = 'array'
        if decl.dim: 
            arr = '[%s]' % decl.dim.value
        else:
            arr = '[]'

        return _explain_type(decl.type) + arr

    elif typ == c_ast.FuncDecl:
        if decl.args:
            params = [_explain_type(param) for param in decl.args.params]
            args = ', '.join(params)
        else:
            args = ''

        return ('function(%s) returning ' % (args) +
                _explain_type(decl.type))

    elif typ == c_ast.Struct:
        decls = [_explain_decl_node(mem_decl) for mem_decl in decl.decls]
        members = ', '.join(decls)

        return ('struct%s ' % (' ' + decl.name if decl.name else '') +
                ('containing {%s}' % members if members else ''))

def show_func_defs(filename):
    # Note that cpp is used. Provide a path to your own cpp or
    # make sure one exists in PATH.
    ast = parse_file(filename, use_cpp=False,
                     cpp_args=r'-Iutils/fake_libc_include')
    if not isinstance(ast, c_ast.FileAST):
        return

    for ext in ast.ext:
        if type(ext.type) == c_ast.FuncDecl:
            print(f"function name: {ext.name} -----------------------")
            args = ''
            print("parameters----------------------------------------")
            if ext.type.args:
                for arg in ext.type.args:
                    print(f"{_explain_type(arg)} {arg.name}")
            print("return----------------------------------------")
            print(_explain_type(ext.type.type))

if __name__ == "__main__":
    if len(sys.argv) > 1:
        filename  = sys.argv[1]
    else:
        exit(-1)

    show_func_defs(filename)

解析処理の簡単な流れとしては、まずparse_fileを実行結果としてFileASTが取得できます。
そこのextプロパティを解析することで、ファイルに定義している関数の情報を取得できます。
上記のプログラムを実行してヘッダファイル中の関数定義を調べた結果が以下のように出力されます。

C:\dev\python3\cparse>python test.py static1.h
function name: add -----------------------
parameters----------------------------------------
int x
int y
return----------------------------------------
int
function name: calc -----------------------
parameters----------------------------------------
in_data_t* i
out_data_t* o
return----------------------------------------
void
function name: max -----------------------
parameters----------------------------------------
int[] data
int length
return----------------------------------------
int

このようにpycparserを使用することでC言語のソースファイルの解析が容易に行えることが確認できました。

テンプレートライブラリ

スタブ用のヘッダファイルとソースファイルを作成する際に、テンプレートライブラリを使用すると作成が楽になります。
Pythonでのテンプレートライブラリについては下記を参考にしてください。

Pythonで久しぶりにHTMLを出力したくなったのでテンプレートについて調べる
https://needtec.sakura.ne.jp/wod07672/?p=9162

今回はJinja2を利用しています。

スタブ作成用コード

C言語のパーサーとテンプレートライブラリを使用して、スタブ作成ツールを実装した例が以下のようになります。

create_stub.py

import sys
import os
from pycparser import c_parser, c_ast, parse_file
from jinja2 import Template, Environment, FileSystemLoader

stub_header_tpl = """
# ifndef STUB_{{name}}_H
# define STUB_{{name}}_H
# include "{{name}}.h"
{% for func in function_list %}
typedef {{func.return_type}} (*pfunc_{{func.name}}_def) ({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.typedef}} {{arg.name}}{{arg.array}} {% endfor %});
{% endfor %}

typedef struct {
{% for func in function_list %}
    int count_{{func.name}};
    pfunc_{{func.name}}_def pfunc_{{func.name}};
{% endfor %}
} stub_data_def_{{name}};
extern stub_data_def_{{name}} stub_data_{{name}};
# endif
"""

stub_source_tpl = """
# include "stub_{{name}}.h"
stub_data_def_{{name}} stub_data_{{name}};

{% for func in function_list %}
{{func.return_type}} {{func.name}} ({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.typedef}} {{arg.name}}{{arg.array}} {% endfor %}) {
    ++stub_data_{{name}}.count_{{func.name}};
    {% if func.return_type != "void" %}
    return stub_data_{{name}}.pfunc_{{func.name}}({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.name}} {% endfor %});
    {% else %}
    stub_data_{{name}}.pfunc_{{func.name}}({% for arg in func.args %}{% if loop.index != 1 %},{% endif %} {{arg.name}} {% endfor %});
    {% endif %}
}
{% endfor %}
"""

class ParameterData():
    def __init__(self, name, typedef):
        self.__name = name
        self.__typedef = typedef
        self.__array = ""
        if '[' in typedef:
            self.__array = "[" + typedef[typedef.index("[")+1:typedef.rindex("]")] + "]"
            self.__typedef = typedef[0: typedef.index("[")]

    @property
    def name(self):
        return self.__name

    @property
    def typedef(self):
        return self.__typedef

    @property
    def array(self):
        return self.__array

class FunctionData:
    def __init__(self, name, return_type):
        self.__name = name
        self.__return_type = return_type
        self.__args = []

    @property
    def name(self):
        return self.__name

    @property
    def return_type(self):
        return self.__return_type

    @property
    def args(self):
        return self.__args

# https://github.com/eliben/pycparser/blob/master/examples/cdecl.py
def _explain_type(decl):
    """ Recursively explains a type decl node
    """
    typ = type(decl)

    if typ == c_ast.TypeDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type)
    elif typ == c_ast.Typename or typ == c_ast.Decl:
        return _explain_type(decl.type)
    elif typ == c_ast.IdentifierType:
        return ' '.join(decl.names)
    elif typ == c_ast.PtrDecl:
        quals = ' '.join(decl.quals) + ' ' if decl.quals else ''
        return quals + _explain_type(decl.type) + '*'
    elif typ == c_ast.ArrayDecl:
        arr = 'array'
        if decl.dim: 
            arr = '[%s]' % decl.dim.value
        else:
            arr = '[]'

        return _explain_type(decl.type) + arr

    elif typ == c_ast.FuncDecl:
        if decl.args:
            params = [_explain_type(param) for param in decl.args.params]
            args = ', '.join(params)
        else:
            args = ''

        return ('function(%s) returning ' % (args) +
                _explain_type(decl.type))

    elif typ == c_ast.Struct:
        decls = [_explain_decl_node(mem_decl) for mem_decl in decl.decls]
        members = ', '.join(decls)

        return ('struct%s ' % (' ' + decl.name if decl.name else '') +
                ('containing {%s}' % members if members else ''))

def analyze_func(filename):
    ast = parse_file(filename, use_cpp=False,
                     cpp_args=r'-Iutils/fake_libc_include')
    if not isinstance(ast, c_ast.FileAST):
        return []
    function_list = []
    for ext in ast.ext:
        if type(ext.type) != c_ast.FuncDecl:
            continue
        func = FunctionData(ext.name,  _explain_type(ext.type.type))
        if ext.type.args:
            for arg in ext.type.args:
                param = ParameterData(arg.name, _explain_type(arg))
                func.args.append(param)
        function_list.append(func)
    return function_list

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("python create_stub.py プリプロセス実行済みヘッダ 出力フォルダ")
        exit(-1)
    filename  = sys.argv[1]
    dst_folder  = sys.argv[2]
    function_list = analyze_func(filename)
    if len(function_list) == 0:
        print("関数を見つけることができませんでした")
        exit(-1)
    data = {
        'name' : os.path.splitext(filename)[0],
        'function_list' : function_list
    }
    # スタブのヘッダーファイルの作成
    with open(f"{dst_folder}/stub_{data['name']}.h", mode='w', encoding='utf8') as f:
        f.write(Template(stub_header_tpl).render(data))

    # スタブのソースファイルの作成
    with open(f"{dst_folder}/stub_{data['name']}.c", mode='w', encoding='utf8') as f:
        f.write(Template(stub_source_tpl).render(data))

このツールを利用して作成したスタブは以下の通りです。

stub_static1.h


# ifndef STUB_static1_H
# define STUB_static1_H
# include "static1.h"

typedef int (*pfunc_add_def) ( int x , int y );

typedef void (*pfunc_calc_def) ( in_data_t* i , out_data_t* o );

typedef int (*pfunc_max_def) ( int data[] , int length );

typedef struct {

    int count_add;
    pfunc_add_def pfunc_add;

    int count_calc;
    pfunc_calc_def pfunc_calc;

    int count_max;
    pfunc_max_def pfunc_max;

} stub_data_def_static1;
extern stub_data_def_static1 stub_data_static1;
# endif

stub_static.c


# include "stub_static1.h"
stub_data_def_static1 stub_data_static1;

int add ( int x , int y ) {
    ++stub_data_static1.count_add;

    return stub_data_static1.pfunc_add( x , y );

}

void calc ( in_data_t* i , out_data_t* o ) {
    ++stub_data_static1.count_calc;

    stub_data_static1.pfunc_calc( i , o );

}

int max ( int data[] , int length ) {
    ++stub_data_static1.count_max;

    return stub_data_static1.pfunc_max( data , length );

}

C言語のパーサーが利用できないとき

Pythonが使えなかったりC言語のパーサーが利用できなかったりする状況もよくあります。
そういう場合は、doxygenを利用します。

Doxygenはソースファイルを解析して関数の情報をXMLに出力することができます。このXMLをもとにスタブ用のファイルを作成することも可能です。CやC++をやっている現場の多くはdoxygenを使用しているケースが多いので、おそらくPython使うよりは導入の障壁が低いと考えられます。※

※なお、私はDoxyge+VBSでスタブを作っていました。

その他スタブやモックの作成方法について

実際には採用しませんでしたが、以下のような方法もあります。

CMock
https://github.com/ThrowTheSwitch/CMock
Rubyを使用してモック用のコードを作成する模様。当該環境でRubyが使えるならありかもしれません。

C言語のテストでスタブ関数を使うためのアイデア
https://qiita.com/HAMADA_Hiroshi/items/e1dd3257573ea466d169
「既存ソースに手を入れずに同一ソース内の関数をスタブにしてユニットテストを実施する方法」をまとめたもので、基本的な考えはマクロを駆使して既存関数を別名に置き換えるということをやっています。
幸い、同一ソース内の関数を呼ぶようなテストを書く必要がなかったので実際には採用していません。

網羅率の計測方法

テストコードを記述した場合、そのテストコードが網羅している割合を計測することで、もれなくテストを実行しているかを判断することが可能になります。
幸いなことにgccにはgcovという網羅率を計測するツールがついています。

10 gcov—a Test Coverage Program
https://gcc.gnu.org/onlinedocs/gcc/Gcov.html

gcov の使い方
https://mametter.hatenablog.com/entry/20090721/p1

カバレッジの計測例

先ほど使用したスタブの例でカバレッジを計測してみます。今回はわざと未到達なルートを作るため、ライブラリ2のstatic2.cを以下のように修正します。

static2.c

# include "static2.h"

int proc(int x, int y, int z) {
    int a = add(x,y);
    return add(a, z);
}

int proc2() {
    /** 未到達 **/
    return 0;
}

その後、カバレッジを計測するために-coverageオプションを付与してコンパイルをします。

gcc -c -Wall -Wextra static2.c -coverage
ar r libstatic2.a static2.o 
gcc -c -Wall -Wextra stub_static1.c
ar r libstub_static1.a stub_static1.o
gcc test.c -coverage  -Wall -Wextra -L. -lstatic2 -lstub_static1   -L/usr/local/lib -lcunit -o test

上記のコマンドを実行すると以下のファイルが作成されます。

  • static2.gcno
  • test.gcno
  • test

その後、testを実行することでさらに以下のファイルが作成されます。

  • static2.gcda
  • test.gcda

テスト対象のstatic2.cのカバレッジを調べるには以下のコマンドを実行します。

gcov static2.gcda 

このコマンドを実行すると下記のメッセージが表示されます。

File 'static2.c'
Lines executed:60.00% of 5
Creating 'static2.c.gcov'

ここで網羅率が60%であることが確認でき、さらにどこが網羅されていないかを調べるにはstatic2.c.gcovを表示します。
今回の例ではproc2が通っていないことが確認できます。
image.png

その他自動テストについて

効果について

以下の点で効果的と判断します。

・現状の機能の挙動を変えずに機能を拡張する際のリスクが抑えられる
・テストコードを記載することで、今まで闇の中だった機能を明確にできる。
・テストコードを書くと既存コードの中に眠り続けてきた不具合を検出できる
  →直すかどうかは別問題。発生頻度や発生時の影響度と修正コストの兼ね合い。

ただ、幾つの点で課題があるのでそれについては次項で考えていきましょう。

SQLの試験はどうするの?

レガシーな環境だと、ソースコードにSQLを書いていて、実際それをデータベースにつなげて動かさなければテストにならないことがあります。
この場合、開発者毎に使用するDBの領域を区切って単体テストを行うのが望ましいです。

しかしながら、大きな組織だとDBを専門に扱う部門があったりして、安易にデータベースをいじれなかったりします。
またローカル環境にデータベース自体を入れるとした場合、貧弱なPCがネックになったり、仮にそれを突破しても、個人の環境でデータベースの管理を任せた場合、開発者によっては古い環境を使い続けたりするリスクもあったりします。

こういう時は自動テストが終わったらロールバックするという意識の低い方法で対応することができます。

①自動テスト開始
②DBのトランザクションを開始する
③テスト用のデータをDBに格納
④自動テストを行う
⑤自動テストの結果でDBがどう変わったか確認
⑥DBのロールバック

この方法は完璧な案ではありませんが、実現が容易な案です。

大量の組み合わせテスト

構造体の配列で入力値と期待値を書いておいておけば大量のテストが行えます。
よく使う手としてはExcelで入力と出力の一覧を書いておいて、VBAのマクロでC言語の構造体の配列に変換してテストコードに書き込むやり方があります。

保守的なウォーターフォールな環境でテストファーストを行う歪み

保守的なウォーターフォールな環境で採用されている方法だと、実装が終わった後に、単体テストのテスト仕様書やチェックリストを作成して、テストを実施することになります。
しかし、テストファースト的なことをやると、実装完=単体テスト完になるので、ここで歪みが発生してしまいます。

計画が従来の線に乗らない

従来の方式だと実装、単体テスト用チェックリスト作成、単体テストの実施の3つの工程にわけてスケジュールの線を記述します。
そして、高度に管理された完全で完璧な組織だと、それぞれの工程の時間をきっちり計測します。

テストファースト的なことをやると、実装から単体テストがり分けることのできないものになるので、個別の作業時間が計測不能となり、適当に分けて報告することになります。

単体テスト仕様書

従来の方法で作成していた単体テスト仕様書やチェックリストの取り扱いをどうするか決める必要があります。
テストコードのコードレビューをもってOKとし、単体テストの成果物としてはテストコードとするという判断ができればいいのですが、レガシーな環境だと書類が大事です。
この場合、テストコードからテスト仕様書やチェックリストといったものを作成する必要があります。

1つ目の対応策としては、テストコードの内容を手でチェックリストに転記していくという方法です。
問題点は、テストコードとテスト仕様書の同期がとれなくなっていくことです。
また、この方法は自動でテストを実行する利点をなくす非常にまずい対応ですが、時間さえかければ誰でもできるのでよく採用されます。

2つ目の方法は、テストコードのコメントからテスト仕様書を起こす方法です。
テストコードのコメントにテストの内容を記載しておいてDoxygen等で抜き出した結果をチェックリストにまとめます。
この場合、コメントと実際の処理の食い違いが発生するリスクがあります。

3つ目の方法は、テストコード中に指定するテスト名にチェックリストに出力しても違和感のない日本語を書いてしまう方法です。

    CU_add_test(max_suite, "max(a,b)を実行してa=5,b=4の場合、aの値が取得されることを確認する", test_max_001);

上記のように記載してテストを実行するとテスト結果のXMLには以下のように出力されます。
image.png

あとは、XMLをチェックリストの出力してしまえば完了です。
この方法の問題点は、CU_ASSERT_EQUALなどで行うチェック内容はXMLに出力されないので、チェックリストの粒度が大きくなってしまう点です。

4つ目の方法としては、CU_ASSERT_EQUALなどをラップする関数を作成して、その関数内で、チェックリストに出力しやすい形で検査内容をファイル出力してしまう方法です。

いずれの方法をとるにせよ、書面にこだわられると面倒臭いので注意しましょう。
 →書面にこだわる文化のところでは、そういう作業分もちゃんと見積もりに乗せるようにしましょう。

単体テストでの障害票の取り扱い

実装を終了した時点で単体テストが終わっているので、単体テストでの障害を上げるのは不可能になっています。
とはいえ、高度に管理された完全で完璧な組織だと単体テストの障害件数を重視する場合があります。
この場合は、実装中に出た問題を、それっぽいバグとして適当にでっちあげることになります。

本来あるべき論としては説得して新しいルールを作ることですが、文化がちがうので無理だと思います。

テスト件数の桁が変わるので従来の指標が役に立たなくなる

自動テストを導入すると組み合わせ系のテストを機械的に大量に処理できるようになります。
結果、従来の方法で行ったテストの件数より自動で行ったテストの件数が多くなります。(それこそ桁違い)

いままで精々数百のチェックリストの項目数だったものが、普通に千とか万とかになります。
「テストを大量にやってなにが悪いのかね」という話なんですが、高度に管理された完全で完璧な組織だと異常値扱いされるので、粒度を適当に調整して、報告用のそれっぽい件数に調整したりする必要があったりします。

なお、まっとうな対応をするのであるならば、自動テストは別集計として新しい指標を作っていくのが筋でしょう。

結合テスト以降の障害が従来より減るので異常値扱いされる

単体テストを網羅的にやると、結合テストでもなかなか不具合がでなくなります。

これはこれで問題になります。
たとえば、従来、何十件も障害が発生していたところ、不具合件数がほぼでなくなったとしたとしましょう。
数値しか見てない場合、結合テストの観点が間違っているんじゃないだろうかという疑念を抱く人がいてもしょうがないでしょう。

テストコード書きたがらない人が多い

以下でも書きましたが、そういうもんです。あきらめましょう。

意識が高くないVisualStudioを使用した単体テストの自動化
https://needtec.sakura.ne.jp/wod07672/?p=9213

自動テストの効果を工数や品質で利を説いたり、金額に換算して危機感あおったり、教育用の資料を用意することはできますが、正直、そんな話をいくら積んだところで通じない文化には全く通じません。

ただし、どんな組織においても、志のある人間というのが少数ながらいるモノなので、そういう人間に期待しましょう。

静的解析

前項で自動テストの話をしました。
これは結局、テストコードを書いた範囲を実際に動かしてチェックしているにすぎません。
この項では、実際にテストコードを書いて動かすことなく、怪しげな実装箇所を見つける方法について説明します。

静的解析ツールで怪しい箇所を見つける

静的解析ツールを使用することで怪しげな実装をしている箇所を検出可能です。
お高いツールを使うと効果的な発見をしてくれますが、オープンソースでも十分効果的です。

C言語の場合だとCppCheckを使用することができます。
http://cppcheck.sourceforge.net/

cppcheckはWindowsからGUIで動かすこともできますし、linuxでCUIベースに動かすこともできます。

コマンドラインから使用する方法

Windows10 + Ubuntu 18.04の環境下でcppcheckを使用する方法を説明します。

まず下記のコマンドでインストールします。

sudo apt-get install cppcheck

インストールが完了したら以下のようなコマンドを実行することでcppcheckが実行されます。

$ cppcheck --enable=all .
Checking cov.c ...
1/7 files checked 14% done
Checking main.c ...
2/7 files checked 28% done
Checking static1.c ...
3/7 files checked 42% done
Checking static2.c ...
4/7 files checked 57% done
Checking stub_static1.c ...
5/7 files checked 71% done
Checking test.c ...
6/7 files checked 85% done
Checking unit.c ...
7/7 files checked 100% done
[cov.c:7]: (style) The function 'bar' is never used.
[static1.c:7]: (style) The function 'calc' is never used.
[static2.c:8]: (style) The function 'proc2' is never used.
(information) Cppcheck cannot find all the include files (use --check-config for details)

WindowsのGUIでcppcheckを使用してみる

(1)以下のページからInstallerをダウンロードしてください。
http://cppcheck.sourceforge.net/

(2)インストール後、CppCheckを起動すると以下のような画面が開きます。
image.png

(3)メニューから「ファイル」→「新規プロジェクト」を選択します。
image.png

(4)プロジェクトファイルを保存する任意のパスを設定して「保存」を押下します。
image.png

(5)「プロジェクトファイル」ダイアログが開くので「追加」ボタンを押下してC言語のソースファイルがあるパスを追加してください。
image.png

image.png

image.png

(6)「プロジェクトファイル」ダイアログでOKを押下すると、ビルドディレクトリを作成するか聞かれるので「Yes」を選択します。
image.png

(7)指定したディレクトリ以下のCファイルが列挙されます。
image.png

(8)列挙されたファイルのツリー構造を展開すると警告の詳細が表示されます。
image.png

Jenkinsとの連携

JenkinsのCppcheck Plulginを使用することでCppCheckの結果をJenkinsで集計させることが可能です。
https://plugins.jenkins.io/cppcheck

(1)JenkinsのプラグインマネージャーでCppCheckを選択してインストールします。
image.png

(2)cppcheckを以下のように実行してxmlで結果を取得します。

cppcheck --enable=all --xml --xml-version=2 ~/libsample/. 2>cppcheck.xml

(3)ビルド後の処理にPublsh Cppcheck resultsを追加します。
image.png

image.png

(4)ビルドを実行すると、結果にCppCheckの項目が表示されます。
image.png

image.png

メトリクスを計測する

ソースコードのステップ数や、循環的複雑度はソースコードの品質を予測するための一つの指標になります。

循環的複雑度(Cyclomatic complexity)
https://ja.wikipedia.org/wiki/%E5%BE%AA%E7%92%B0%E7%9A%84%E8%A4%87%E9%9B%91%E5%BA%A6

循環的複雑度は簡単にいうと、ループや分岐が多いほどこの複雑度は高くなります。
複雑度が高ければ高いほどバグを含む可能性が高くなるので、この指標をもとにテストを重点的に行うべき場所にあたりをつけることができます。

Source Monitor

Source MonitorはC ++、C、C#、VB.NET、Java、Delphi、Visual Basic(VB6)で書かれたソースコードのメトリクスを計測することが可能なフリーソフトです。

(1)下記のページからダウンロードして任意のフォルダに展開してください。
http://www.campwoodsw.com/sourcemonitor.html

(2)展開されたSourceMonitor.exeを実行すると以下のような画面が表示されるので「Start SourceMonitor」を選択してください。
image.png

(3)メトリクスを集計するプロジェクトを作成するのでメニューから[File]->[New Project]を選択します。
image.png

(4)「Select Language」ダイアログが開くので「C」を選択して「次へ」を押下します。
image.png

(5)プロジェクトファイルのファイル名と格納するフォルダを指定して「次へ」を押下します。
image.png

(6)解析対象のソースコードを含むフォルダを指定して「次へ」を押下します。
image.png

(7)オプションを選択し「次へ」を押下します。
image.png

オプション 説明
Use Modified Complexity Metrix デフォルトの複雑度メトリックは、各switchステートメント内のcaseをカウントして計算していますが、チェックをONにすることで各switchブロックは1回だけカウントされるようになり、個々のcaseステートメントはModified Complexityに寄与しなくなります。
Do not count blank line 行数を数える際に、空行を無視します。
Ignore Continuous Header and Footer Comments CVSやVisual SourceSafeなどのバージョン管理ユーティリティによって自動的に生成されたファイル履歴などの情報を含む連続したコメント行を無視したい場合にチェックをつけます。

(8)「次へ」を押下します。
image.png

(9)UTF8のファイルを含むなら、「Allow parsing of UTF-8 files」にチェックを付けて「次へ」を押下します。
image.png

(10)「完了」を押下します。
image.png

(11)計測対象のファイルの一覧が表示されるので、確認後、OKを押下します。
image.png

(12)計測結果が表示されます。
image.png

リストをクリックするとファイルの一覧が表示されます。
image.png
image.png

リスト中のファイルのアイテムをクリックすると詳細が表示されます。
image.png

リスト中のファイルのアイテムを右クリックしてコンテキストメニューを表示して「View Source File」を選択することでファイルの内容が確認できます。
image.png
image.png

よく利用する数値

名前 説明
Lines ソースファイル内の物理行の数。プロジェクト作成に「Do not count blank line」をチェックした場合は空白行を含まない。ざっくりした規模をみるのに使用する
Max Complexity 循環的複雑度。プロジェクト作成時の「Use Modified Complexity Metrix」のチェックの有無によりswitch句のcaseの取り扱いが変わる。この数値が多いほどテストしずらい。
Max Depth ネストの深さの最大。ネストが深いコードも複雑になる傾向があるので注視する
% Comment コメントの割合。0%とか逆に大きすぎる場合、チェックした方がいい。

類似コードを探す

以下で紹介したPMD-CPDを使用することでコピペされたコードを検出することが可能です。

僕にそのコピペを直せというのか
https://needtec.sakura.ne.jp/wod07672/?p=9264

開発時と違い、類似のコードを探し出して共通化することが目的で使用はしていません。
すでに稼働してしまったシステムは、開発中と違い、コードがコピペされていようが、共通化などのリファクタリングを安易にすることはできません。

こういう状況でコードクローンを検出する目的は、例えば障害対応や機能追加が発生して、あるソースコードを修正する時、他に修正が必要な類似箇所があるかを調べるために使用します。

例えば、障害を出すと当然、「別の箇所は大丈夫?」とか問われることになります。
この際、「コードクローン検出ツールを使用して類似箇所を網羅的に調べて問題ないことを確認しました」とか、それっぽい事を言ってごまかすことができます。

静的解析ツール関係まとめ

実際にコードを動かさずに、コードの評価を行い修正すべき箇所を見つけることができます。

保守的な環境でテストコードを導入するコストは滅茶苦茶かかりますが、静的解析ツールの採用は低コストで行えるので、多くの組織で活用できると思います。

テスト環境まわりの話

テスト時の環境についての話をいくつかさせていただきます。
なお、この章から結合テストの話も出てくるので「C言語で作られたシステム」のタイトル詐欺になりつつあります。

単体テスト環境の分離

別システムが関係したりしている結合テスト時ならばともかく、単体テスト時にも下記のような構成でテストを行う現場があります。
image.png

この構成のまずい点は以下の通りです。
・お互いのテストが影響を与えて、本来合格するケースが不合格になったり、その逆のケースがあったりすることが頻発する
・単体テストという不安定な状況のため頻繁にテスト環境を入れ替える必要があり、そのたびに他の開発者の作業が止まる。

つまりテスト容易性が極めて悪くなります。このため、単体テストあたりまでは以下のように開発者毎にテスト環境を用意するのが望ましいです。

image.png

開発者毎にテスト環境をあたえるため、ひと昔前はLinuxで動くプログラムを無理やりWindowsのVisualC++でビルドして動かしたりしてました。
しかしながら、昨今はVMWareやVirtualBoxで仮想環境を構築できるのでそれを利用すべきでしょう。

なお、開発者に与えられたPCのメモリが4GBだったときは、仮想環境で云々はあきらめることになると思います。

テスト環境へのリリースの自動化の話

複数人が触るテスト環境にリリースする際は、なるべく自動で行えるようにしたほうがいいです。
リリース時の影響を最小限におさえるために、テスト環境が止まっている時間を短くしたり、だれも使っていないときにリリースする必要があります。これはテスト環境へのリリース作業を自動化しておくことで対応可能になります。

また、テスト環境へのリリースを記録として残すことで、どの時点で、どのリビジョンの資材が使われていたかを追跡可能になります。
Jenkinsとかの特定のツールにこだわる必要はなく、シェルスクリプトやバッチファイルでいいので自動でできるようにするといいでしょう。

なお、以下の記事のテクニックを使うことでWindowsから資材をWinSCPでアップしたあとにTeraTerm経由でスクリプトを実行して結果をメールで送信するということも可能になると思います。

WinSCPを自動化しても別にかまわんのだろ
https://needtec.sakura.ne.jp/wod07672/?p=9178

TeraTermのマクロをためしてみる
https://needtec.sakura.ne.jp/wod07672/?p=9177

RedmineをあきらめたオレたちのPowerShellでのOutlookの自動操作
https://needtec.sakura.ne.jp/wod07672/?p=9188

結合テスト時の大量データ作成の自動化

結合テスト時に、大量のデータを作成が必要な場合があります。
この際、結合テストの観点で「使用するデータはシステムで作成できるものとする」とあると、データベースに対してSQLでデータを流し込むという方法ができません。

システムが用意している画面を操作して大量のデータを登録するには、以下の「Windowsの画面操作を自動化しよう」で紹介しているテクニックが使用できます。

自称IT企業があまりにITを使わずに嫌になって野に下った俺が紹介するWindowsの自動化の方法
https://needtec.sakura.ne.jp/wod07672/?p=9163

ただし、採用するツールや操作対象のアプリケーションの特性上、入力エラー等をすり抜けるケースがあるので、そこは気をつけて採用する必要があります。

結合テストの画面操作を自動化できない?

以下の理由から対象を絞ったほうがいいと思います。

・採用するツールや操作対象のアプリケーションの特性上、自動操作が手動操作とまったく一緒にならないケースがある。
・画面操作の自動化は基本的に安定しない
・画面操作の自動操作は作成コストがかかる。

私がやるなら、スモークテストのための画面操作テストや、大量の繰り返し処理(大量データの作成や負荷試験)に絞って行います。

敗北の記録

理想的だが無駄だった話

世の中には理想的ではあるが、実際問題として導入できない無駄な話がいくつかあります。
ここでは、そういった無駄だった話を紹介したいと思います。

テスト管理ツールの導入の話

image.png

image.png

テスト工程の管理をするツール、TestLinkについて
https://needtec.sakura.ne.jp/wod07672/?p=9198

…ってなことを10年来いろんなとこで提案してますが、基本、SIerのような環境では厳しいと思います。

こういうツールに興味がある組織はすでに使っていますし、興味のない組織はテスト管理自体に本当に興味ないので、勝算の薄いツールの導入に色々なコストをかけるくらいなら、やるべきことは他にあると思います。

バグトラッキングシステムの導入の話

バグトラキングシステムを導入することでバグの履歴を管理できたり、だれがどれだけバグを抱えているかの状況判断ができたり、ソースコードとの紐づけなどが管理できるようになります。

残念ながら、ある種の状況では、そういうあるべき論に時間かけるより、「バグ票の書き方」といった基本的な教育に力を使った方がいい状況があるのだと思い知りました。

Wikiによる検索可能なナレッジの蓄積

検索性や履歴の保持を考えた場合、Office文章でナレッジを蓄えるよりWikiを採用した方が望ましいのです。

しかしながら、ある種の環境にいるIT技術者の多くは、文章を書くのにExcelしか使ったことがないケースがあるので、無理な導入はやめましょう。

無駄な話の次善策

上記の理想的な話が全て無駄話になっている現場においては、テスト仕様書、バグ管理票、ナレッジ文章はExcelやWordで記述されて共有フォルダで管理されていることでしょう。

この問題点は2点です。
1つ目は検索性の問題
2つ目は構成管理の問題です。

これらの問題にどう対応すべきか考えましょう。

検索性の問題

Office文章の検索については、いくつかのフリーソフトを使えば行えます。
なんなら、VBSなどで作成することができます。

なお、Officeの検索には時間がかかると思うので、可能であればキャッシュを使っているソフトを採用した方がストレスが溜まらないと思います。

構成管理の問題

共有フォルダに無造作に置かれたファイルは、いつだれが、どんな修正したかが不明になります。
また、誤操作かなにかで稀によく消えたりします。

この対応として共有フォルダでなく構成管理ツールを使用してドキュメントを管理しましょう。

なお、構成管理ツール導入が不可能だった場合は下記の記事を参考にプランBを考えてください。

共有フォルダ教が支配する世界に転生した場合の対応案
https://needtec.sakura.ne.jp/wod07672/?p=9173

さいごに

長い年月を経た保守的な環境でテスト工程を改善してみようとして私が得た知見を紹介しました。
以下の記事でも書きましたが、特に長い年月を経た保守的な環境を改善しようとする場合、とりあえず現状を受け入れたうえで現実的な着地点を探す必要があります。

ぼくのかんがえたさいきょーの提案方法
https://needtec.sakura.ne.jp/wod07672/?p=9223

なお、そもそも「テスト工程を改善しよう」とかいうセリフ自体が

image.png

という反応をされてもしょうがないものなので、たまたま受け入れられたら喜べばいい程度のもんだと思います。

jmockit 1.48を使ってみる

目的

本記事の目的はjmockit 1.48について調査した際のメモです。

ドキュメント
http://jmockit.github.io/index.html

ソースコード
https://github.com/jmockit/jmockit1

JavaDoc
https://repo1.maven.org/maven2/org/jmockit/jmockit/1.48/jmockit-1.48-javadoc.jar

検証環境
java version "1.8.0_202"
Eclipse IDE for Enterprise Java Developers.
Version: 2019-03 (4.11.0)
Build id: 20190314-1200
JUnit4

jmockitとは

xUnitを使用して単体テストを行う場合、依存する部品が問題になってテストが困難な場合があります。
image.png

たとえば、以下のようなケースです。
・依存する部品で任意の内容をテスト対象に返すのが困難な場合
 ※たとえばHDDの容量不足というエラーを出力する必要がある試験の場合
・依存する部品を利用すると別の試験で副作用が発生する場合
 ※たとえばデーターベースの特定のテーブルを全て削除するような試験を行う場合
・依存する部品がまだ完成していない場合
 ※たとえばテスト対象のプログラムと依存する部品が並行で開発されている場合。

こういったケースの場合に、依存する部品の代わりにjmockitで作成したメソッドを利用することで単体テストを容易にします。

image.png

jmockitを使用することで、依存する部品の代わりにテストに都合のいい値をテスト対象に渡したり、依存する部品がどのようにテスト対象から呼び出されたかを記録し、検証することが可能になります。

簡単な使用方法

(1)以下からJarをダウンロードしてプロジェクトから参照する。
https://mvnrepository.com/artifact/org.jmockit/jmockit

あるいはMavenの場合は以下をpom.xmlに追加する

<!-- https://mvnrepository.com/artifact/org.jmockit/jmockit -->
<dependency>
    <groupId>org.jmockit</groupId>
    <artifactId>jmockit</artifactId>
    <version>1.48</version>
    <scope>test</scope>
</dependency>

(2)JUnitのテストケースを追加する

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import mockit.Mock;
import mockit.MockUp;

public class SimpleTest {

    @Test
    public void test() {
        new MockUp<java.lang.Math>() {
            @Mock
            public double random() {
                // 常に2.5を返すrandom()メソッド
                return 2.5;
            }
        };
        assertEquals(2.5, Math.random(), 0.1);
        assertEquals(2.5, Math.random(), 0.1);
    }

}

(3)junit実行時の実行構成にて、VM引数に「-javaagent:jmockit-1.48.jar」を付与して実行する。
image.png

image.png

実行方法の詳細は下記を参照:
http://jmockit.github.io/tutorial/Introduction.html#runningTests

トラブルシュート

initializationErrorが発生する場合

事象
image.png

エラートレース

java.lang.Exception: Method testVerifications should have no parameters
    at org.junit.runners.model.FrameworkMethod.validatePublicVoidNoArg(FrameworkMethod.java:76)
    at org.junit.runners.ParentRunner.validatePublicVoidNoArgMethods(ParentRunner.java:155)
// 略
    at org.eclipse.jdt.internal.junit.runner.RemoteTestRunner.main(RemoteTestRunner.java:209)

原因
VM引数に「-javaagent:jmockit-1.48.jar」が付与されていない。

クラスパスの順番が重要?あるいはRunWithを使用する必要があるのか?

ビルドのクラスパスをjmockit→junitの順番で行うことが重要であるという記載がたまにあります。
https://stackoverflow.com/questions/32817982/jmockit-wasnt-properly-initialized?rq=1

おそらく、これは現バージョンでは問題にならない、あるいはVM引数に「-javaagent:jmockit-X.XX.jar」が付与されていないことが原因と考えられます。

また、ビルドのクラスパスの順番の別解に、「@RunWith(JMockit.class)」を使用するという方法があるらしいですが、少なくとも1.48時点では、この属性は存在しません。

https://github.com/jmockit/jmockit1/issues/554

jmockitの使い方

Mocking

Mocking はテスト対象のクラスをその依存関係(の一部)から分離するメカニズムを提供します。
モック化されたインスタンスを作成するには@Mocked/@Injectable/@Capturingアノテーションを使用します。
モック化されたインスタンスはExpectationsで期待する動作を設定したり、Verificationsでモック化されたインスタンスがどのように実行されたかを検証可能です。

@Mockedアノテーション

テストケースのメソッドのパラメータまたはテストケースのクラスのフィールドとして@Mockedアノテーションを使用してモック化を行うことが可能です。@Mockedアノテーションを使用した場合、それを使用するテストの期間は同じ型のインスタンスは全てモック化されます。

なお、プリミティブ型と配列型を除き、任意の型をモック化可能です。

では以下のクラスをモックオブジェクトとして使用する方法を考えてみます。

テスト対象

package SampleProject;

public class Hoge001 {
    public int hoge(int x, int y) {
        return x + y;
    }
    public String hoge(String x) {
        return "test" + x;
    }
}

Mockedの使用例

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Hoge001;
import mockit.Expectations;
import mockit.Mocked;

public class Test001 {
    // モックを使用しない場合...
    @Test
    public void test0() {
        Hoge001 hoge = new Hoge001();
        assertEquals(11, hoge.hoge(5,6));
        assertEquals("testxxx", hoge.hoge("xxx"));
    }

    // テストメソッドのパラメータとして指定することで、モック化されたインスタンスを作成できます
    @Test
    public void test1(@Mocked Hoge001 mock) {
        new Expectations() {{
            // hogeがx=5, y = 6で呼ばれたら1回目は99を返す
            mock.hoge(5,6);
            result  = 99;
        }};
        // Expectationsでメソッドのresultを指定した場合は、その値が取得される
        assertEquals(99, mock.hoge(5,6));
        // Expectationsでメソッドのresultを指定されていない場合は、初期値(null)となる
        assertEquals(null, mock.hoge("xxx"));

        // @Mockedを使用した場合、そのテストの期間は、すべての該当のインスタンスがモック化される
        Hoge001 hoge = new Hoge001();
        assertEquals(99, hoge.hoge(5,6));
        assertEquals(null, hoge.hoge("xxx"));

    }
}

test0()はモック化しない場合のテストケースになっており、test1()はパラメータに@Mockedを使用したテストケースになっています。
test1()のテストケースの間は、Hoge001のインスタンスは全てモック化されてたインスタンスとなります。

テストケースで直接作成されていない場合もモックになることを確認

テストケース内で直接インスタンスを作成していない場合もモック化されることを以下のテストで確認します。

テスト対象
Hoge001のインスタンスを作成して利用するHoge002をテスト対象とします。

package SampleProject;

public class Hoge002 {
    public int test(int x , int y) {
        Hoge001 hoge1 = new Hoge001();
        return hoge1.hoge(x*2, y*2);
    }
}

テストコード

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Hoge001;
import SampleProject.Hoge002;
import mockit.Expectations;
import mockit.Mocked;

public class Test001_3 {

    // テストメソッドのパラメータとして指定することで、モック化されたインスタンスを作成できます
    @Test
    public void test1(@Mocked Hoge001 mock) {
        new Expectations() {{
            // hogeがx=10, y = 12で呼ばれたら1回目は99を返す
            mock.hoge(10,12);
            result  = 99;
        }};
        Hoge002 hoge2 = new Hoge002();
        assertEquals(99, hoge2.test(5,6));
    }
}

このテストケースを実行すると、Hoge002で作成したHoge001がモック化されているものであると確認できます。

クラスのフィールドに@Mockedを使用したケース

クラスのフィールドに@Mockedを使用した場合、クラスのテスト全てで対象のクラスがモック化されます。

テストコード

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Hoge001;
import SampleProject.Hoge002;
import mockit.Expectations;
import mockit.Mocked;

public class Test001_2 {

    // テストクラスのフィールドとして指定することで、モック化されたインスタンスをそれぞれのテストケースで利用できます
    @Mocked
    private Hoge001 fieldMocked;
    @Test
    public void test1() {
        new Expectations() {{
            fieldMocked.hoge(anyInt, anyInt);
            result = 100;
        }};
        assertEquals(100, fieldMocked.hoge(1,2));
    }
    @Test
    public void test2() {
        new Expectations() {{
            // hogeがx=10, y = 12で呼ばれたら1回目は99を返す
            fieldMocked.hoge(10,12);
            result  = 99;
        }};
        Hoge002 hoge2 = new Hoge002();
        assertEquals(99, hoge2.test(5,6));
    }
}

カスケードされたモック

多くの異なるオブジェクトを使用して提供される機能があるとします。たとえば「obj1.getObj2(...).getYetAnotherObj().doSomething(...)」のような呼び出しがあることは珍しくありません。
この場合のモックの例を見てみましょう。

以下の例ではmock.getDepend1().output()といったオブジェクトを返すメソッドにおいてモック化がされるか確認するコードになっています。

テスト対象のクラス

package SampleProject;

public class Depend001 {
    private String prefix;
    public Depend001(String p) {
        this.prefix = p;
    }
    public String output(String msg) {
        return this.prefix + msg;
    }
}
package SampleProject;

public class Hoge003 {
    private Depend001 d1;
    public Depend001 d2;
    public Hoge003() {

    }

    public Hoge003(Depend001 depend1, Depend001 depend2) {
        this.d1 = depend1;
        this.d2 = depend2;
    }

    public String output() {
        String ret = "";
        ret = ret + this.d1.output("test1") + "\n";
        ret = ret + this.d2.output("test2") + "\n";
        return ret;
    }
    public Depend001 getDepend1() {
        return this.d1;
    }
}

テストコード

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Hoge003;
import mockit.Expectations;
import mockit.Mocked;

public class Test002 {
    @Test
    public void test1(@Mocked Hoge003 mock) {
        new Expectations() {{
            mock.getDepend1().output(anyString);
            result  = "abcde";
        }};
        assertEquals("abcde", mock.getDepend1().output("abc"));
    }

}

上記のサンプルのように、枝葉のDepend001を明示的にモック化しなくても、大元のHoge003クラスをモック化することで目的のメソッドの期待する動作を変更することが確認できました。

@Injectableアノテーション

@Mockedアノテーションと同様にモック化を行うためのアノテーションですが、@Mockedアノテーションとの違いはモックを1つのインスタンスに制限することです。
また、@Testedアノテーションとと組み合わせることで、テスト対象オブジェクトへの自動注入に使用することができます。

@Mockedアノテーションとの違い

@Mockedアノテーションと@Injectableアノテーションの違いを確認するために、@Mockedアノテーションで使用したテストコードを@Injectableに変更して確認をしてみます。

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Hoge001;
import mockit.Expectations;
import mockit.Injectable;

public class Test004 {

    // テストメソッドのパラメータとして指定することで、モック化されたインスタンスを作成できます
    @Test
    public void test1(@Injectable Hoge001 mock) {
        new Expectations() {{
            // hogeがx=5, y = 6で呼ばれたら1回目は99を返す
            mock.hoge(5,6);
            result  = 99;
        }};
        // Expectationsでメソッドのresultを指定した場合は、その値が取得される
        assertEquals(99, mock.hoge(5,6));
        // Expectationsでメソッドのresultを指定されていない場合は、初期値(null)となる
        assertEquals(null, mock.hoge("xxx"));

        // @Mockedを使用した場合とことなり、すべての該当のインスタンスがモック化されるわけではない。
        Hoge001 hoge = new Hoge001();
        assertEquals(11, hoge.hoge(5,6));
        assertEquals("testxxx", hoge.hoge("xxx"));

    }
}

@Mockedアノテーションを使用した場合、テストの期間中は対象のクラスのインスタンスを作成するたびにモック化されたものとなりましたが、@Injectableを使用することでモック化されるインスタンスを1つに制限していることが確認できます。

@Testedアノテーションに対する注入

@Testedアノテーションで指定したテスト対象のオブジェクトにモックを注入するサンプルを確認してみます。
@Testedで指定したテスト対象のオブジェクトのコンストラクタの引数に注入する方法と、テスト対象のフィールドに注入する方法があります。

コンストラクタの引数に注入する方法
以下はHoge003(Depend001 depend1, Depend001 depend2) のコンストラクタの引数であるdepend1とdepend2を指定する例です。

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Depend001;
import SampleProject.Hoge003;
import mockit.Expectations;
import mockit.Injectable;
import mockit.Tested;

public class Test003 {
    @Tested
    Hoge003 target;

    @Injectable
    Depend001 depend1;

    @Injectable
    Depend001 depend2;

    @Test
    public void test1() {
        new Expectations() {{
            depend1.output(anyString);
            result  = "abcde";
            depend2.output(anyString);
            result  = "xxxxx";
        }};
        assertEquals("abcde\nxxxxx\n", target.output());
    }

}

フィールドに注入する方法
以下はHoge003オブジェクトのd1,d2フィールドに注入するサンプルになります。

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Depend001;
import SampleProject.Hoge003;
import mockit.Expectations;
import mockit.Injectable;
import mockit.Tested;

public class Test003B {
    @Tested
    Hoge003 target;

    @Injectable
    Depend001 d1;

    @Injectable
    Depend001 d2;

    @Test
    public void test1() {
        new Expectations() {{
            d1.output(anyString);
            result  = "abcde";
            d2.output(anyString);
            result  = "xxxxx";
        }};
        assertEquals("abcde\nxxxxx\n", target.output());
    }
}
プリミティブ型のフィールドやコンストラクトに注入を行う方法

@Injectableアノテーションのvalue要素を使用することで、@Testedアノテーションで指定したテスト対象のプリミティブ型のフィールドやコンストラクトに注入を行うことが可能です。

package jmockittest;

import org.junit.Test;

import SampleProject.Depend001;
import mockit.Injectable;
import mockit.Tested;

public class Test005 {
    @Tested
    Depend001 tested;

    @Test
    public void test1(@Injectable("abc") String p) {
        // 以下を出力
        // abcaaa
        System.out.println(tested.output("aaa"));
    }
    @Test
    public void test2(@Injectable("abc") String prefix) {
        // 以下を出力
        // abcbbb
        System.out.println(tested.output("bbb"));
    }
}

test1はテスト対象のオブジェクトのコンストラクタ引数pに指定して注入をおこなっており、test2はテスト対象のオブジェクトのフィールドprefixを指定して注入をしています。

@Testedアノテーションのオプション要素
タイプ 名前 オプションの要素と説明 規定値
boolean availableDuringSetup テストされたクラスが、テストセットアップメソッド(つまり、@ Beforeまたは@BeforeMethodとして注釈が付けられたメソッド)の実行前にインスタンス化および初期化されるか、それらの後に初期化されるかを示します。 false
boolean fullyInitialized 注入に適格なテスト済みオブジェクトの各非最終フィールドに値を割り当てる必要があることを示します。Springを使用している場合での使いどころは次のページで記載されています。https://stackoverflow.com/questions/25856210/injecting-only-some-properties-mocking-others false
boolean global テスト対象クラスの単一の名前付きインスタンスを作成して、テスト実行全体で使用するかどうかを示します。 false
String value テストするフィールド/パラメーターのタイプがストリング、プリミティブまたはラッパータイプ、数値タイプ、または列挙タイプの場合、リテラル値を指定します。 ""

availableDuringSetupとglobalを検証するテストコード

package jmockittest;

import org.junit.Before;
import org.junit.Test;

import SampleProject.Hoge001;
import mockit.Tested;

public class Test007 {
    @Tested(availableDuringSetup=true, global=true)
    Hoge001 tested;

    @Before
    public void before()
    {
        // null以外 availableDuringSetupがfalseだとnullになる
        System.out.println("before:" + tested);
    }

    @Test
    public void test1() {
        // null以外
        System.out.println("test1:" + tested);
    }
    @Test
    public void test2() {
        // null以外 test1と同じオブジェクトが使われていることが確認できる
        System.out.println("test2:" + tested);
    }
}

@Capturingアノテーション

@Capturingアノテーションを使用することで既定クラスやインターフェイスに対してモック化を行いうことが可能です。
下記のサンプルは、個々の実装クラスではなく、インターフェイスに対してモック化されたメソッドを作成するサンプルになっています。

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import mockit.Capturing;
import mockit.Expectations;

public class Test006 {
    public interface Service { int doSomething(); }
    final class ServiceImpl implements Service { public int doSomething() { return 1; } }

    public final class TestedUnit {
       private final Service service1 = new ServiceImpl();
       private final Service service2 = new Service() { public int doSomething() { return 2; } };

       public int businessOperation() {
          return service1.doSomething() + service2.doSomething();
       }
    }

    // インターフェイスや既定クラスに対してモックを作成する
    @Test
    public void test1(@Capturing Service anyService) {
          new Expectations() {{ anyService.doSomething(); returns(3, 4); }};

          int result = new TestedUnit().businessOperation();

          assertEquals(7, result);
    }
}

Expectations

Expectationsは特定のテストに関連するモックオブジェクトに対して期待する動作を設定します。

期待値を設定する

Expectations中にはモックオブジェクトのメソッドをどのパラメータを指定したら、どの値を返すかを指定できます。
下記の例では「String hoge(String)」と「int hoge(int, int)」メソッドを実行した際にどのような値を返すかを設定した例になります。

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import SampleProject.Hoge001;
import mockit.Delegate;
import mockit.Expectations;
import mockit.Mocked;

public class Test008 {

    // Expectationsでメソッドのresultを指定した場合は、その値が取得されることを確認
    @Test
    public void test1(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge("test");
            result = "abcde";

            mock.hoge(5,6);
            result  = 99;
            result = 100;
            result = 101;

        }};
        // mock.hoge("test")を実行した際の期待値を取得
        assertEquals("abcde", mock.hoge("test"));

        // mock.hoge(5,6)を実行した際の期待値を取得
        // Expectationsで設定した1つめの値が取得
        assertEquals(99, mock.hoge(5,6));
        // Expectationsで設定した2つめの値が取得
        assertEquals(100, mock.hoge(5,6));
        // Expectationsで設定した3つめの値が取得
        assertEquals(101, mock.hoge(5,6));
        // Expectationsで設定した最後の値が取得
        assertEquals(101, mock.hoge(5,6));
        // Expectationsで設定した最後の値が取得
        assertEquals(101, mock.hoge(5,6));

        // 引数が異なる場合は初期値となる
        assertEquals(0, mock.hoge(7,6));
    }
}
returnsで記載する例

複数のresultはreturnsで以下のように、まとめて記載することも可能です。

        new Expectations() {{
            mock.hoge("test");
            result = "abcde";

            mock.hoge(5,6);
            returns(99, 100, 101);

        }};

引数の柔軟な指定方法

先の例では特定の引数の値を受け付けたときのみ戻り値を返すようにしていましたが、any~やwith~を引数に指定することで引数の値を柔軟に設定することができます。

anyフィールドの使用

Expectationsには任意のあたいをあらわすいくつかのanyフィールドが存在します。

type name
Object any
Boolean anyBoolean
Byte anyByte
Character anyChar
Double anyDouble
Float anyFloat
Integer anyInt
Long anyLong
Short anyShort
String anyString

anyフィールドを利用した例は以下のようになります。

    @Test
    public void test1_1(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyString);
            result = "abcde";

            mock.hoge(anyInt, anyInt);
            result  = 99;

        }};
        // mock.hoge("test")を実行した際の期待値を取得
        assertEquals("abcde", mock.hoge("test"));
        assertEquals("abcde", mock.hoge("hogehoget"));

        // mock.hoge(5,6)を実行した際の期待値を取得
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
    }
固定の引数の値と任意の引数の値を組み合わせる

固定の引数の値と任意の引数の値を組み合わせることもできます。下記の例ではhoge(5,6)の場合は10を返して、それ以外の場合は99を返すモックメソッドを作ります。

    @Test
    public void test1_2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(5,6);
            result = 10;

            mock.hoge(anyInt, anyInt);
            result  = 99;

        }};

        // mock.hoge(5,6)を実行した際の期待値を取得
        assertEquals(10, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
    }

任意の引数の値と組み合わせる場合は、固定値を先に記載してください。

withメソッドの利用

with~メソッドを使用することで、Expectationsで指定したモックメソッドとして一致するかどうかの判断を柔軟におこなうことが可能です。

メソッド 説明
with(Delegate<? super T> objectWithDelegateMethod) 引数が一致したかどうかをデリゲートメソッドを用いて判定します。デリゲートメソッドの戻り値がtrueである場合、一致したことを意味します
withEqual(T arg) 指定された値がモック実行時の引数に一致するか確認します。通常は、この方法は使用しないで、目的の引数の値を渡す方法を使用してください。
withEqual(double value, double delta) deltaで指定した値に近い場合に一致したものとします
withEqual(float value, double delta) deltaで指定した値に近い場合に一致したものとします
withAny(T arg) anyBoolean、anyByte、anyChar、anyDouble、anyFloat、anyInt、anyLong、anyShort、anyString、anyを使用することを検討してください。
withNotEqual(T arg) 指定した値以外の場合、一致したものとします
withNotNull() 指定した値がNULL以外の場合、一致したものとします
withNull() 指定した値がNULLの場合、一致したものとします
withInstanceOf(Class argClass) 指定されたクラスのインスタンスであることを確認します。
withInstanceLike(T object) 指定されたオブジェクトと同じクラスのインスタンスであることを確認します。withInstanceOf(object.getClass()) と同等になります
withSameInstance(T object) まったく同じインスタンスであることを確認します
withPrefix(T text) 特定の文字が含まれた場合、一致したものとみなします
withSubstring(T text) 先頭が指定した文字に一致した場合、一致したものとみなします
withSuffix(T text) 末尾が指定した文字に一致した場合、一致したものとみなします
withMatch(T regex) 正規表現で一致するかどうかを指定できます
withでデリゲートメソッドを使用した例

withでデリゲートメソッドを使用することでメソッドでモックの引数が一致するかどうかの判定を行うことが可能です。

    @Test
    public void test1_4(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(with(new Delegate<Integer>() {
                @Mock boolean validate(int value) {
                    return value >= 0;
                }
            }),anyInt);
            result = 99;
        }};

        // xがプラスなのでモックで設定した値に一致する
        assertEquals(99, mock.hoge(1,2));
        // xがマイナスなのでモックで設定した値に一致しない
        assertEquals(0, mock.hoge(-1,2));
    }
withEqualを使用した例

基本的にwithEqualを使用するよりリテラルをそのまま使用した方がいいです。しかし、浮動小数点を使用する場合はwithEqualを使用した方がいいでしょう。

    class testWithEqual {
        int test1(double v) {
            return 1000;
        }
        int test2(int v) {
            return 2000;
        }
    }

    @Test
    public void test_withEqual1(@Mocked testWithEqual mock) {
        new Expectations() {{
            mock.test2(withEqual(100));
            result = 99;
        }};
        // 一致する mock.test2(100)と同じ
        assertEquals(99, mock.test2(100));
        // 一致しない
        assertEquals(0, mock.test2(101));
    }
    @Test
    public void test_withEqual2(@Mocked testWithEqual mock) {
        new Expectations() {{
            mock.test1(withEqual(100, 1));
            result = 99;
        }};
        // 一致する
        assertEquals(99, mock.test1(100.0));
        assertEquals(99, mock.test1(101.0));
        assertEquals(99, mock.test1(99.0));
        // 一致しない
        assertEquals(0, mock.test1(101.1));
        assertEquals(0, mock.test1(98.99));
    }
withInstanceOf,withInstanceOf,withSameInstanceの例

withInstanceOf,withInstanceOf,withSameInstanceを使用することで特定のインスタンスと一致するかどうかを確認することが可能です。

    class classA {
    }
    class classB {
    }
    class classX  {
        public int method1(Object obj) {
            return 999;
        }
    }

    @Test
    public void test_withInst1(@Mocked classX mock) {
        new Expectations() {{
            mock.method1(withInstanceOf(classA.class));
            result = 99;
        }};
        // 一致する
        {
            classA obj = new classA();
            assertEquals(99, mock.method1(obj));
        }

        // 一致しない
        {
            classB obj = new classB();
            assertEquals(0, mock.method1(obj));
        }
    }

    @Test
    public void test_withInst2(@Mocked classX mock) {
        new Expectations() {{
            classA objA = new classA();
            mock.method1(withInstanceLike(objA));
            result = 99;
        }};
        // 一致する
        {
            classA obj = new classA();
            assertEquals(99, mock.method1(obj));
        }

        // 一致しない
        {
            classB obj = new classB();
            assertEquals(0, mock.method1(obj));
        }
    }
    @Test
    public void test_withInst3(@Mocked classX mock) {
        classA obj1 = new classA();
        new Expectations() {{
            mock.method1(withSameInstance(obj1));
            result = 99;
        }};
        // 一致する
        {
            assertEquals(99, mock.method1(obj1));
        }

        // 一致しない
        {
            classA obj2 = new classA();
            assertEquals(0, mock.method1(obj2));
        }
    }
withPrefix,withSubstring,withSuffix,withMatchの例

withPrefix,withSubstring,withSuffix,withMatchを用いることで文字列の一部が一致するかどうかを調べることが可能です。

    @Test
    public void test_withString1(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(withPrefix("abc"));
            result = "test";
        }};

        // 以下は一致する
        assertEquals("test", mock.hoge("abc"));
        assertEquals("test", mock.hoge("abcAA"));

        // 以下は一致しない
        assertEquals(null, mock.hoge("AAabc"));
        assertEquals(null, mock.hoge("AabcA"));
        assertEquals(null, mock.hoge("xx"));
    }

    @Test
    public void test_withString2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(withSuffix("abc"));
            result = "test";
        }};

        // 以下は一致する
        assertEquals("test", mock.hoge("abc"));
        assertEquals("test", mock.hoge("AAabc"));

        // 以下は一致しない
        assertEquals(null, mock.hoge("abcAA"));
        assertEquals(null, mock.hoge("AabcA"));
        assertEquals(null, mock.hoge("xx"));
    }
    @Test
    public void test_withString3(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(withSubstring("abc"));
            result = "test";
        }};

        // 以下は一致する
        assertEquals("test", mock.hoge("abc"));
        assertEquals("test", mock.hoge("abcAA"));
        assertEquals("test", mock.hoge("AAabc"));
        assertEquals("test", mock.hoge("AabcA"));

        // 以下は一致しない
        assertEquals(null, mock.hoge("xx"));
    }
    @Test
    public void test_withString4(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(withMatch("[0-9]+"));
            result = "test";
        }};

        // 以下は一致する
        assertEquals("test", mock.hoge("1234"));

        // 以下は一致しない
        assertEquals(null, mock.hoge("xxx"));
    }

インスタンスの作成のされ方でモックメソッドを分ける方法

Expectationsにてインスタンスの作成のされ方でモックメソッドを分けることが可能です。
以下の例では「new TestA(10)」を実行して作成したインスタンスにのみモックメソッドを適用するサンプルを示します。

    class TestA {
        public TestA(int x) {

        }
        public int hoge() {
            return 99999;
        }
    }

    @Test
    public void test8(@Mocked TestA mock) {
        new Expectations() {{
            TestA t1 = new TestA(10);
            t1.hoge();
            result = 10;

        }};

        {
            TestA obj = new TestA(10);
            assertEquals(10, obj.hoge());
        }
        {
            TestA obj = new TestA(99);
            assertEquals(0, obj.hoge());
        }
    }

例外を発生させる方法

モックメソッドの処理中に例外を発生させることができます。
以下の例ではhoge()メソッド実行中にIllegalArgumentExceptionを発生させます。

    // Expectationsでメソッドの例外を返す例
    @Test
    public void test2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(5,6);
            result = 99;
            result = new IllegalArgumentException("test");
        }};
        // Expectationsで設定した1つめの値が取得
        assertEquals(99, mock.hoge(5,6));
        try {
            // Expectationsで設定した2つめの値が取得
            mock.hoge(5,6);
            fail();

        } catch (IllegalArgumentException ex) {
            assertEquals("test", ex.getMessage());
        }
    }

実行回数を確認する

Expectationsでtimes,maxTImes,minTimesを指定することでメソッドの実行回数を指定することが可能です。

Field Description
tiems 実行中に何回メソッドが呼び出されるかを指定します。これと異なる回数、呼び出された場合、エラーとなります。
maxTimes 呼び出されるメソッドの最大回数を指定します。これを上回る回数、呼び出された場合エラーとなります。
minTimes 呼び出されるメソッドの最小回数を指定します。これを下回る回数しか呼び出されない場合エラーとなります。
    @Test
    public void test4_1(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            times = 3;

        }};
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
        assertEquals(99, mock.hoge(3,6));
    }
    // この試験はMissing 2 invocations  が発生してエラーとなります
    @Test
    public void test4_2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            times = 3;

        }};
        assertEquals(99, mock.hoge(3,6));
    }

    // この試験はUnexpected invocation が発生してエラーとなります
    @Test
    public void test4_3(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            times = 3;

        }};
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
        assertEquals(99, mock.hoge(3,6));
        assertEquals(99, mock.hoge(3,6));
    }

    @Test
    public void test5_1(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            minTimes = 3;

        }};
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
        assertEquals(99, mock.hoge(3,6));
    }

    // この試験はMissing 2 invocations  が発生してエラーとなります
    @Test
    public void test5_2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            minTimes = 3;

        }};
        assertEquals(99, mock.hoge(3,6));
    }
    @Test
    public void test5_3(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            minTimes = 3;

        }};
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
        assertEquals(99, mock.hoge(3,6));
        assertEquals(99, mock.hoge(3,6));
    }
    @Test
    public void test6_1(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            maxTimes = 3;

        }};
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
        assertEquals(99, mock.hoge(3,6));
    }
    @Test
    public void test6_2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            maxTimes = 3;

        }};
        assertEquals(99, mock.hoge(3,6));
    }

    // この試験はUnexpected invocation が発生してエラーとなります
    @Test
    public void test6_3(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt, anyInt);
            result  = 99;
            maxTimes = 3;

        }};
        assertEquals(99, mock.hoge(5,6));
        assertEquals(99, mock.hoge(99,1234));
        assertEquals(99, mock.hoge(3,6));
        assertEquals(99, mock.hoge(3,6));
    }

Delegateを利用したresultのカスタム指定

モックメソッド実行時に引数に基づいて、モックで返す結果を変更したい場合はDeglegateを使用します。
下記の例では入力引数の2倍を加えた値を返すモックメソッドを作成しています。

    @Test
    public void test7(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt,anyInt);
            result= new Delegate<Integer>() {
                @SuppressWarnings("unused")
                int aDelegateMethod(int x, int y) {
                    return x * 2 + y * 2;

                }
            };
        }};
        // Expectationsで設定した1つめの値が取得
        assertEquals(22, mock.hoge(5,6));
    }
Invocationの使用

Delegateのメソッドの最初のパラメータとしてInvocationを使用することが可能です。
Invocationは下記のゲッターを提供しています。

メソッド 説明
getInvocationCount() 呼び出し回数
getInvocationIndex() 現在の呼び出しIndexを取得
getInvokedArguments() 呼び出しに使用した引数を取得
getInvokedInstance() 現在の呼び出しのインスタンス。staticメソッドの場合はnullとなる
getInvokedMember() 呼び出しのメソッド/コンストラクタを取得
proceed(Object... replacementArguments) 実際のメソッド/コンストラクタを実行します
    @Test
    public void testDelegate2(@Mocked Hoge001 mock) {
        new Expectations() {{
            mock.hoge(anyInt,anyInt);
            result= new Delegate<Integer>() {
                @SuppressWarnings("unused")
                int aDelegateMethod(Invocation inv ,int x, int y) {
                    System.out.println("--------------------------------");
                    // 呼び出し回数
                    System.out.format("Invocation getInvocationCount %d \n", inv.getInvocationCount());
                    // 現在の呼び出しのインデックス
                    System.out.format("Invocation getInvocationIndex() %d \n", inv.getInvocationIndex());
                    // 引数を取得
                    System.out.println("getInvokedArguments");
                    for(Object obj : inv.getInvokedArguments()) {
                        System.out.println(obj);
                    }
                    // インスタンスを取得
                    System.out.format("Invocation getInvokedInstance() %s \n", inv.getInvokedInstance().toString());
                    // 実際のメソッドを取得
                    System.out.format("Invocation getInvokedMember() %s \n", inv.getInvokedMember().toString());
                    // 実際のメソッドの実行が可能。
                    System.out.format("Invocation  proceed %s \n", inv.proceed().toString());
                    // 引数を改ざんして実際のメソッドを実行可能
                    System.out.format("Invocation  proceed %s \n", inv.proceed(5,6).toString());
                    return 0;
                }
            };
        }};
        // Expectationsで設定した1つめの値が取得
        Hoge001 a = new Hoge001();
        Hoge001 b = new Hoge001();
        a.hoge(5,6);
        a.hoge(45,63);
        b.hoge(99,100);
    }

上記を実行したコンソールログは以下のようになります。

--------------------------------
Invocation getInvocationCount 1 
Invocation getInvocationIndex() 0 
getInvokedArguments
5
6
Invocation getInvokedInstance() SampleProject.Hoge001@2a2d45ba 
Invocation getInvokedMember() public int SampleProject.Hoge001.hoge(int,int) 
Invocation  proceed 11 
Invocation  proceed 11 
--------------------------------
Invocation getInvocationCount 2 
Invocation getInvocationIndex() 1 
getInvokedArguments
45
63
Invocation getInvokedInstance() SampleProject.Hoge001@2a2d45ba 
Invocation getInvokedMember() public int SampleProject.Hoge001.hoge(int,int) 
Invocation  proceed 108 
Invocation  proceed 11 
--------------------------------
Invocation getInvocationCount 3 
Invocation getInvocationIndex() 2 
getInvokedArguments
99
100
Invocation getInvokedInstance() SampleProject.Hoge001@675d3402 
Invocation getInvokedMember() public int SampleProject.Hoge001.hoge(int,int) 
Invocation  proceed 199 
Invocation  proceed 11 

オブジェクトの一部をモック化する

すべてのメソッドでなく一部のみをモック化するには以下のようにExpectationsにオブジェクトを渡します。

    @Test
    public void test10() {
        Hoge001 hoge = new Hoge001();
        new Expectations(hoge) {{
            hoge.hoge(5,6);
            result = 99;
        }};
        // モックの結果を返す
        assertEquals(99, hoge.hoge(5,6));

        // 実際のメソッドを実行する
        assertEquals(3, hoge.hoge(1,2));
        assertEquals("testabc", hoge.hoge("abc"));

    }

Verifications

Verifications、VerificationsInOrder、FullVerificationsを使用することでモックオブジェクトがどのように呼び出されたかを明示的に検証することが可能です。

    @Test
    public void test_v1(@Mocked Hoge001 mock) {
        mock.hoge(1,2);
        mock.hoge(2,3);
        mock.hoge(4,5);

        //
        new Verifications() {{
            mock.hoge(anyInt,anyInt);
            times = 3;
            mock.hoge(anyString);
            times = 0;
        }};
        // Verificationsは順番の違いや余計な呼び出しについては合格と見なします
        new Verifications() {{
            mock.hoge(4,5);
            mock.hoge(1,2);
        }};
    }
    @Test
    public void test_v2(@Mocked Hoge001 mock) {
        mock.hoge(1,2);
        mock.hoge(2,3);
        mock.hoge(4,5);

        // VerificationsInOrderは順番が異なるとエラーになります
        /*
        new VerificationsInOrder() {{
            mock.hoge(4,5);
            mock.hoge(1,2);
        }};
        */
        new VerificationsInOrder() {{
            mock.hoge(1,2);
            mock.hoge(4,5);
        }};
    }
    @Test
    public void test_v3(@Mocked Hoge001 mock) {
        mock.hoge(1,2);
        mock.hoge(2,3);
        mock.hoge(4,5);

        // FullVerificationsでは余計な呼び出しがされているとエラーになります
        /*
        new FullVerifications() {{
            mock.hoge(1,2);
            mock.hoge(4,5);
        }};
        */
        new FullVerifications() {{
            mock.hoge(1,2);
            mock.hoge(2,3);
            mock.hoge(4,5);
        }};
        // 順番はことなっていても合格となります
        new FullVerifications() {{
            mock.hoge(4,5);
            mock.hoge(2,3);
            mock.hoge(1,2);
        }};
    }
withCaptureを使用した検証の例

withCaptureでどのようなパラメータが与えられたインスタンスをListで取得できます。

    // withCaptureでパラメータを確認する例
    @Test
    public void test_v4(@Mocked Hoge001 mock) {
        mock.hoge(1,2);
        mock.hoge(2,3);
        mock.hoge(4,5);

        //
        new Verifications() {{
            List<Integer> argXList = new ArrayList<Integer>();
            List<Integer> argYList = new ArrayList<Integer>();
            mock.hoge(withCapture(argXList),withCapture(argYList));
            assertEquals(3, argXList.size());
            assertEquals(3, argYList.size());

            assertEquals(1, (int)argXList.get(0));
            assertEquals(2, (int)argXList.get(1));
            assertEquals(4, (int)argXList.get(2));

            assertEquals(2, (int)argYList.get(0));
            assertEquals(3, (int)argYList.get(1));
            assertEquals(5, (int)argYList.get(2));

        }};
    }

    // withCaptureでインスタンスの作成を確認する例
    class Person {
        public Person(String name , int age) {
        }
    }
    @Test
    public void test_v5(@Mocked Person mockPerson) {
        new Person("Joe", 10);
        new Person("Sara", 15);
        new Person("Jack", 99);

        //
        new Verifications() {{
            List<Person> created = withCapture(new Person(anyString, anyInt));
            assertEquals(3, created.size());

        }};
    }

Faking API

Faking APIはFakeの実装の作成のサポートを提供します。 通常、Fakeは、Fakeされるクラス内のいくつかのメソッドやコンストラクタをターゲットにし、他のほとんどのメソッドやコンストラクタは変更されません。

public/protectedメソッドのFake

以下の例ではProc1とProc2が存在するクラスのProc1のみFakeしている例です。

package jmockittest;

import static org.junit.Assert.*;

import org.junit.Test;

import mockit.Mock;
import mockit.MockUp;

public class FakeTest {
    class ClassA {
        protected String Proc1() {
            return "...Proc1";
        }
        public String Proc2() {
            return  "Proc2:" + this.Proc1();
        }

    }
    @Test
    public void test1() {
        new MockUp<ClassA>() {

            @Mock
            String Proc1() {
                System.out.print("Proc1");
                return "xxx";
            }
        };
        ClassA obj = new ClassA();
        assertEquals("Proc2:xxx", obj.Proc2());
    }
}

Private メソッドのFake

1.48では無理。以下のようなエラーが出る。

java.lang.IllegalArgumentException: Unsupported fake for private method ClassA#Proc1()Ljava/lang/String; found
    at jmockittest.FakeTest$1.<init>(FakeTest.java:22)
    at jmockittest.FakeTest.test1(FakeTest.java:22)

おそらく、以前はできていてできなくなった模様。
https://github.com/jmockit/jmockit1/issues/605

Staticメソッドの例

StaticメソッドのFakeは可能です。
下記の例はjava.lang.Math.randomで常に固定値を返す例になります。

    @Test
    public void test() {
        new MockUp<java.lang.Math>() {
            @Mock
            public double random() {
                // 常に2.5を返すrandom()メソッド
                return 2.5;
            }
        };
        assertEquals(2.5, Math.random(), 0.1);
        assertEquals(2.5, Math.random(), 0.1);
    }

finailが指定されているメソッドのFakeは作成できるか?

作成可能でした。

    class ClassB {
        final protected String Proc1() {
            return "...Proc1";
        }
        public String Proc2() {
            return  "Proc2:" + this.Proc1();
        }

    }
    @Test
    public void test3() {
        new MockUp<ClassB>() {

            @Mock
            String Proc1() {
                System.out.print("Proc1");
                return "xxx";
            }
        };
        ClassB obj = new ClassB();
        assertEquals("Proc2:xxx", obj.Proc2());
    }

Fakeクラス内の特別なメソッド

Fakeクラス内で特別なメソッドとして\$init,\$clinit,\$adviceが存在します。
\$initはコンストラクタをターゲットとしています。
\$clinitは静的初期化子を対象としています。
\$adviceはターゲットのクラスの全てのメソッドをあらわします。

テスト対象

ClassC.java

package SampleProject;

public class ClassC {
    public static int sx;
    private int x;
    static {
        sx = 999;
    }
    public ClassC(int x) {
        this.x = x;
    }

    public String Proc1() {
        System.out.format("ClassC Proc1 %d %d\n", sx, this.x);
        return "...Proc1";
    }

}

テストコード

    @Test
    public void test4() {
        new MockUp<ClassC>() {
            @Mock
            void $clinit() {
                // ClassCのスタティック初期化が動いていないことを確認
                assertEquals(0, ClassC.sx);
            }

            @Mock
            void $init(int x) {
                assertEquals(100, x);
            }

            @Mock
            Object $advice(Invocation inv) {
                return "test";
            }
        };
        ClassC obj = new ClassC(100);
        assertEquals("test", obj.Proc1());

    }

Fakeメソッドの特別なパラメータ

Fakeメソッドの最初のパラメータにInvocationを使用することが可能です。
これを使用して現在時刻にたいして固定値を返すサンプルを下記に示します。

    @Test
    public void testTime() {
        Calendar nowCalendar = Calendar.getInstance();
        System.out.println("現在日時 : " + nowCalendar.getTime());
        new MockUp<Calendar>() {
            @Mock
            Calendar getInstance(Invocation inv) {
                Calendar cal = inv.proceed();
                cal.set(Calendar.YEAR, 2018);
                cal.set(Calendar.MONTH, 0);
                cal.set(Calendar.DAY_OF_MONTH, 1);
                cal.set(Calendar.HOUR, 22);
                cal.set(Calendar.MINUTE, 32);
                cal.set(Calendar.SECOND, 12);
                cal.set(Calendar.MILLISECOND, 512);
                return cal;
            }
            @Mock
            Calendar getInstance(Invocation inv, TimeZone zone, Locale aLocale) {
                Calendar cal = inv.proceed();
                cal.set(Calendar.YEAR, 2018);
                cal.set(Calendar.MONTH, 0);
                cal.set(Calendar.DAY_OF_MONTH, 1);
                cal.set(Calendar.HOUR, 22);
                cal.set(Calendar.MINUTE, 32);
                cal.set(Calendar.SECOND, 12);
                cal.set(Calendar.MILLISECOND, 512);
                return cal;
            }
        };
        final Calendar c = Calendar.getInstance();
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmssSSS");
        assertEquals("20180102103212512", sdf.format(c.getTime()));

    }

カバレッジの計測

実行構成でVMの引数を与えることでカバレッジの計測結果を出力することができます。

-Dcoverage-output=html -Dcoverage-srcDirs=..\SampleProject\src

image.png

image.png

image.png

その他引数は下記を参照してください。
http://jmockit.github.io/tutorial/CodeCoverage.html

アンドキュメントな動作として「-Dcoverage-output=xml」とするとXMLを出力するようです。

まとめ

ここまで調べてなんですが、GitHub上のprivate methodまわりの議論や更新履歴の廃止履歴を観るに、完全で完璧な理想的なテスト環境にいない場合、ちょっと使うのにリスクがある感じがします。

なお、以下でpowermock+mockitoも調べてみました。

powermock-mockito2-2.0.2を使ってみる
https://needtec.sakura.ne.jp/wod07672/?p=9156

自動テストを撲滅させる方法

はじめに

過去に色々と自動テストを導入する方法とかを書きました。とはいえ、なかなか進めるのが面倒な場合がおおいです。実際、2019年11月現在、Googleで「TDD テストファースト」と日本語検索をすると、ネガティブな記事がトップの方に来てしまうのが現実です。

そこで、今回は視点を変えて、大勢側に迎合して自動テストをぶっ潰す方法を考えてみることにしたいとおもいます。

自動テストをやりたいとかいう反逆者は以下の記事でも読んでください。

20年物のC言語で作られたシステムのテスト工程を改善しようとした話
https://needtec.sakura.ne.jp/wod07672/?p=9159

意識が高くないVisualStudioを使用した単体テストの自動化
https://needtec.sakura.ne.jp/wod07672/?p=9213

一度、痛い目にあわせる戦略

実は所属組織でテストの自動化の文化をつぶすのに一番いいやり方は、自動テストをさせないことではありません。一度テストを自動化させるプロジェクトを行って大炎上させることです。

人間というのは不思議なもので、可能性があるとそれにすがってしまいます。
それにすがらせないためには、一度、最悪の現実を体験させることです。
一度、テスト自動化を試みたプロジェクトを大炎上させれば、二度とやりたいと言い出すやつは出てきませんし、出てきたとしても、拒否する大義名分ができます。

是非、自動テストを叩き潰したい皆さんは、この記事で紹介するテスト自動化の叩き潰し方を学んでいただいて、テストの自動化を失敗に導きましょう。

人員の選定

ソフトウェア開発は人が重要です。
テストの自動化を叩き潰すのに重要な人員の分類方法は以下の通りになります。

image.png

分類ごとの解説

自動化したくない-経験なし

なにも考えずに採用活動した場合、この層が一番厚くなると思います。
ここの層にたいして自動化にたいするネガティブな感情を植え付けていくのが肝要です。
おそらく本記事のやり方を参考にプロジェクトを進めていただければ、それは簡単に果たすことが可能でしょう。

自動化したくない-経験あり

その経験は脅威になりうる可能性もあります。
しかしながら、この層の多くは我々の先駆者たる失敗したテストの自動化プロジェクトの生き残りであるため、我々の最大の味方となりうる層です。

会議の場では積極的に過去のできない話から「難しいです」「無理です」「できません」と言った言葉を引き出して、周りのテストの自動化に対するモチベーションを下げていくのに利用しましょう。

この際、「自動化したい―経験あり」の人間は会議に出さないように全力で阻止します。
あいつらがいると「やり方がわるかったんじゃないっすか?こうやってこうすればうまくいきますよ」とか空気の読めないことを言い出しやがります。

自動化したい-経験なし

作業者として配置した場合、そのモチベーションが脅威になることがあります。
しかしながら、テストの自動化は本で読んだり、セミナー受けたりしただけでは、基本的にできません。

この層の中の一番有効な使い方は、経験と考えは浅いが、意識ライジングな人員を選抜して、リーダー層に任命することです。経験がなく意識だけが高い場合、この記事で書いているようなことを無意識でやってくれるので、それだけでテスト自動化撲滅のためのキーマンになりえます。

また、この層と「自動化したい-経験あり」が接触すると化学反応を起こして計画の障害になりうるので、なんとしても接触を防ぎましょう。

自動化したい-経験あり

なんとしても排除しましょう。
やむえずプロジェクトメンバーにいる場合は、発言権をあたえてはいけません。また、自由に動く裁量を極限まで削りましょう。与えるマシンのメモリは4Gを上限として、ソフトウェアのダウンロードは禁止にしてください。
これを怠ると、自分のマシンで継続的インテグレーションの環境つくり始めたりするのでなんとしても防ぎましょう。

なんとしても、この層の「自動化したい」という心をへし折ってやりましょう。

自動テストのつぶし方

計画について

正直、どんな技術者を雇っても計画さえ適切に自動テストを潰す方向に向いていれば確実につぶすことが可能です。

無理な目標を立てる

テストの自動化を導入するセオリーが「有効そうなところの、できるとこから」なので、ここでは、いきなり完璧を目指しましょう。

単体テストのカバレッジは100%を必須とし、やるのが難しいGUIの自動テストやスレッドの自動テストを積極的にやりましょう。重要度の高いところだけとかいう甘えを捨てさせるために優先度は全てが最優先でトリアージをさせないように心がけましょう。

是非、完全で完璧な自動テストを目指しましょう。きっと自滅してくれます。

準備期間は与えない。

準備期間は与えずに自動テストを始めるようにしましょう。
当該プロジェクトにあった自動テストの方法や、共通的なテスト用のユーティリティの作成、参考となるテンプレート的なテストの作成、要員の教育を実施する余裕を削ります。

この際、毎日、何件テストを作って何件消化したかを報告させるようにして遅延が発生したら詰めるようにしましょう。これはコッソリ目先の進捗を犠牲にして共通的なテスト用ユーティリティを作る不届きものを排除するためです。

全員が同時に始める

テストコードの書き方のパターンがなるべくバラバラになって、制御が困難になるように
テストコードを書くタイミングがなるべく同時になるようにします。可能であれば、経験のない人間が最初にテストコードを書き始めるようなスケジュールをひきましょう。そして、経験のある人間がテストコードに着手できるタイミングを可能な限り遅らせます。

これにより、効果的なテストコードの書き方のテンプレート作成を防止し、仮に、作成できたとしても状況を手遅れにすることが可能になります。

新しいぶどう酒は古い革袋に入れる

テストの自動化を行うからといって、従来の管理方法を変えてはいけません。

実装→単体テストのチェックリストの作成→単体テストの実施…これらの順番は重要で、実装しながらテストを書くなどという不届きなことをさせないために、この順番は守らせましょう。

また、単体テストのチェックリストは必ず作成を義務付けて、バグの発生件数の指標やステップ辺りのテスト件数も従来のものから変えてはいけません。

新しいぶどう酒は古い革袋に入れて、ぶどう酒も手袋も無駄にしてやりましょう。

動かないテストコードを作成する

正しいかどうかよくわからない大量のテストコードは、確実に自動テストをしない方がマシという状況に我々にもたらしてくれます。自動テストの文化を潰すには動かない、あるいは動いているふりをするよくわからないテストコードを大量に作成することです。

テストコードのレビューを防ぐ

テストコードの作成の経験のない人は自分の環境でないと動かないテストや、特定の秒数じゃないとうごかないテストコードを書いたり、そもそもxUnitの検証にあたる箇所を目視でおこなったりします。

これらはテストコードを読むと簡単に検知できますが、そういう工数を削りましょう。
これにより動かないテストコードあるいは動いているふりをしているテストコードを大量に作成できます。

GUIのテストケースで不安定にさせる方法

そもそも、GUI操作でのテストの自動化が不安定になる傾向があります。
さらに不安定にさせるには「Windowsの画面操作を自動化しよう」で書いたことを参考に不安定な操作方法を模索しましょう。

  • 自動操作ツールが言っている「だれでも簡単に自動操作ができます」という営業文句を真に受けよう!
    • この手のツールはGUI操作をレコーディングし、操作内容のスクリプトが自動生成されることがあります。これは一見それっぽく動きますが、よく確認しないと不安定な動作の原因となります。
  • コントロールの特定には座標を積極的に使う
    • 解像度の違いなどで簡単に不安定にできる
      • オブジェクトで特定すると安定するので、その機能がないツールを選定する
  • 画面の切り替えにはSleepを積極的に使うようにする

定期的にテスト実行をしない

デイリービルドなどの定期処理にテストの実行を組み込まないようにしましょう。
組み込んでも何が合格して何が失敗したかわからないようにしましょう。

これにより、動かないテストコードが作成されても検知できないくなり、動かないテストが大量に増殖していきます。

定期的にテストが実行できない状況をつくる

定期的にテストは実行しないとか決めても、勝手に定期的にテストを実行しだす奴はあらわれます。
彼らの暴挙を防ぐには定期的にテストが実行できない状況を積極的に作りましょう。
定期的にテストが実行できない状況とは以下のような状況です。

  • 特定の環境でないと動作しないテストを作る
  • 時間のかかるテストを作る
  • リソースを大量に消費するテストを作る

これらは、通常回すテストと、頻繁に実行しないテストを分けるというような優先順位をつけて自動テストを実行することで回避されてしまいます。

これを防ぐには、テストコードを実行する場合は全て実行しなければならないとか、全てが優先度が高いとかいうトリアージができないルールでも作ってしまえばいいでしょう。

完全で完璧を求めるほど、逆にテストコードは実行されなくなっていきます。理想的な自動テスト推進派の顔を演じて、自動テストを叩き潰してやりましょう。

テストコードのメンテナンスにコストをかけない。

テストコードは簡単に動かなくなります。
たとえば、共通処理が変わっただけで動かなくなったり、WindowsUpdateを実行したりして動かなくなったりすることがあります。

是非、目先の作業を優先してテストコードのメンテナンスを行わないようにしましょう。
これにより、動かないテストコードが増殖します。

プロジェクト終了後

どんなに旨く失敗させた後も油断はしないでください。
実は失敗した際の情報というのは、次の成功につながる情報が含まれていることがあります。

ふりかえりなどのプロジェクトの反省はもってのほかですし、失敗したテストコードなどの資産などは削除するか、圧縮して他の人間が簡単に閲覧できないところに置いて別チームが活用できないようにしましょう。

俺たちは、一分前の俺たちより進化する、
一回転すればほんの少しだけ前に進む、
それがドリルなんだよ!!

…のように穴堀シモンのような事を言い出す奴がいるので、確実にリセットして何度でも同じ失敗をするように導きましょう。

そもそも自動テストをさせない戦略

xUnitの自動テストを防ぐ方法

ツールをダウンロードさせない

EclipseやVisualStudioに最初っから、単体テスト用のフレームワークが入っていることが多いのでxUnitのフレームワーク自体を使用不可にするのは難しいです。
しかしながら、モックやスタブ用のライブラリはダウンロードしないと入手できないので、これを防ぎましょう。
しかし、ダウンロードを防いでも自力で作り出す奴がいたりするので油断はしてはいけません。

xUnitでテストしづらいルールにする

xUnitのフレームワークでテストができなくなるルールとしては以下の通りです。

  • ローカル変数を含めてステップごとに変数の変化をテスト結果に求める
    • 入出力だけ確認するのは「ゆとり」。こころをこめてローカル変数の内容をエビデンスとしてのこす
  • IDEのステップごとの実行中のスクリーンキャプチャを取るようにする
    • 実際、パスが通ったかどうかを心を込めたスクリーンキャプチャで確認する

GUIで自動テストさせない方法

そもそもGUIの自動テストは難しいので手を下す必要はないですが、確実に根絶したい場合は以下を参考にしてください。

Webアプリケーション

マルチブラウザ対応の場合、Seleniumをダウンロードを禁止すれば、たいてい旨く防止できます。
InternetExploreが対応の場合、VBAやWSH,PowerShellで書けてしまいます。
基本これを防ぐ方法は難しいですが、Officeのマクロ、WSH,Powershellについてはセキュリティー方面で攻めてみればワンチャンあるかもしれません。

Windowsアプリケーション

基本的にUIAutomationを使用してVBAやPowerShellでなんとかなってしまうので、UIAutomationを使用するためのオブジェクトの構造をしらべるinspectのダウンロードを禁止することでなんとかしましょう。
inspectがなくても自作してくる狂人がいるので、これはOfficeのマクロ、WSH,Powershellのセキュリティー方面で攻めてみればワンチャンあるかもしれません。

さいごに

今回は自動テストをさせない方法を考えてみました。
正直、どうやって実現するかというのを考えるよりは、頭を使わずに済んで簡単ですね。なお、本記事の内容はフィクションで、実践している組織は存在しないはずです。ないですよね?

なお、私は基本的に自動テストを導入したりテストコードを書いた方が楽と考える立場ですが、定期的にテストを実行したり、回帰テストをするという文化や能力がないところで、テストコードを書くのを義務づけてはいけない。
これだけははっきりと真実を伝えたかった。
いや、マジでその状況はテストコードが負債になるので、やんない方がマシです。