So-net無料ブログ作成

Proglrの最近の進展 [SML]

SML用のパーサジェネレータProglrの最近の進展について。

字句解析の自動生成

Proglrはml-ulexで生成された字句解析器と結合するようになっていて、 これまでは字句解析は自分で書く必要があった。

しかし、凝った字句解析をしない場合はやはり自動で生成してくれるのが便利だ。 というわけで文法定義からml-ulexのソースを自動生成する機能をつけた。

具体的にはtoken Add "+";のようなトークン定義があった場合は 指定された文字列のトークンを字句解析にも追加する。

token Integer of int;, token Double of real;, token Char of char;, token String of string;, token Ident of string; のような定義があった場合はそれぞれのリテラルのトークンを追加する。

これにより簡単な言語を作る場合はml-ulexのソースをまったく書かなくてもよくなる。

example/calc/calc.cfというサンプルがあるのでそれを参照してほしい。

コメント定義の自動生成

同様にコメントについても字句解析に自動追加できるようになった。 下記のような定義でそれぞれブロックコメントと行コメントを追加できる。 これはBNFCと同様である。

comment "/*" */";
comment "//";

各処理系のための雛形の自動生成

各ML処理系でビルドするためのドライバ、Makefileなども自動生成するようにした。 ml-lptランタイムが同梱されていない処理系のために、 ml-lptランタイムのソースも展開されるようにした。

これにより基本的には文法定義ファイルだけから実行可能ファイルを作るまでのすべてが生成可能となった。

MLton, Poly/ML, Alice ML, MLKit, Moscow ML では数行のコマンドラインでサンプルプロジェクトがビルドできるようになっている。 起動方法はREADMEを参照してほしい。

一方でProglr自身をビルドするための処理系としては、 当面MLtonとPoly/MLに注力することにした。 SML#向けのファイルも残っているが、動かないものと思ってほしい。

ちなみにAlice MLのビルドの作法を調べていたのだが、 リンクに関してはまだあまりわかっていない。 コンパイルをすると.alcというオブジェクトファイルができ、 これは依存する.alcがしかるべき位置に配置されていればそのまま実行できる。 依存するものも含めて1ファイルにするためにalicelinkを使う…と思っているのだが、 alicelinkを使ってリンクしたつもりのファイルだけで実行しようとすると やはり依存性のエラーが出てしまう。 このためREADMEではリンクせずに実行するような手順を書いている。

Graphvizファイルの生成

文法定義から作られるオートマトンを視覚化するためのdotファイルを生成できるようにした。 -a 出力ファイル名 というオプションを与えると生成できる。

依存するツール

Proglrが依存している外部ツールを挙げる。 基本的には昔ながらのUnixのツールであるため、 利用できないということはないと思う。

  • m4: ファイル生成時にテンプレート機能として使う
  • Perl: ml-ulexの出力結果にパッチを当てるのに使っている(Alice ML互換にするためのパッチ)
  • Expect: Poly/MLでビルドするときにインタラクティブシェルを制御するのに使う
  • DejaGnu: Proglrのテスト(make check)で使う

JCUnitのテストケースジェネレータを切り出して使う [Java]

JCUnit はJUnitの(サードパーティーの)テストランナーの1つで、 テストの入力となるフィールドにアノテーションをつけておくと、 そのアノテーションを元にしてPairwise法を使ってテストデータを自動生成して実行してくれる。

それはそれで便利なのだが、 今回はJCUnitのPairwiseのアルゴリズムを JCUnitのテストランナー経由ではなくて直接切り出して使いたいと思ったので、 その方法を調べた。

import com.github.dakusui.jcunit.constraint.ConstraintManager;
import com.github.dakusui.jcunit.constraint.constraintmanagers.NullConstraintManager;
import com.github.dakusui.jcunit.core.Param;
import com.github.dakusui.jcunit.core.factor.Factor;
import com.github.dakusui.jcunit.core.factor.Factors;
import com.github.dakusui.jcunit.generators.IPO2TupleGenerator;
import com.github.dakusui.jcunit.generators.TupleGenerator;
 
public class Main {
    public static void main(String[] args) {
        ConstraintManager cm = new NullConstraintManager();
        Factor os = new Factor.Builder().setName("OS")
                .addLevel("Windows")
                .addLevel("Linux")
                .build();
        Factor browser = new Factor.Builder().setName("Browser")
                .addLevel("Chrome")
                .addLevel("Firefox")
                .build();
        Factor bits = new Factor.Builder().setName("Bits")
                .addLevel("32")
                .addLevel("64")
                .build();
        Factors factors = new Factors.Builder().add(os).add(browser).add(bits).build();
        TupleGenerator tg = new TupleGenerator.Builder()
                .setTupleGeneratorClass(IPO2TupleGenerator.class)
                .setConstraintManager(cm)
                .setFactors(factors)
                .setParameters(new Param[0])
                .build();
        tg.forEach(tuple -> System.out.println(tuple));
    }
}

テストケースジェネレータとなるのはTupleGenerator型のオブジェクトである。 TupleGenerator.Builderのfluentなコンストラクタでオブジェクトを作る。

IPO2TupleGeneratorは生成されるTupleGeneratorの実装で、 IPOというのはPairwiseのアルゴリズムの一種のようだ。

ContaraintManagerは変数の組み合わせの制約を指定するためのもののようだが、 今回は何も制約をつけないのでNullConstraintManagerを与えている。

Factorsはテストケースの元となる一連の変数(Factor)の集まりである。 Factorは変数名と変数がとりうる値(level)から成る。

ParamはTupleGeneratorの実装固有のパラメタである。 ここでは何も指定しない(デフォルト)ので空の配列を与えている。

このParamというクラスは実際にはアノテーションクラスで、 普通はJUnitテストクラスに書かれたアノテーションがそのまま来るようだ。 もし今回のような使用方法でParamを指定するとしたら、 ちょっとまどろっこしい書き方をすることになると思う。

TupleGeneratorはIterable<Tuple>を継承しているので、 ここではforEachメソッドで生成結果を取得している。 TupleはMap<String, Object>を継承していて、キーが変数名、値が変数の値となる。

上記のコードの実行結果は次のようになる。

{Bits=32, Browser=Chrome, OS=Windows}
{Bits=64, Browser=Firefox, OS=Windows}
{Bits=64, Browser=Chrome, OS=Linux}
{Bits=32, Browser=Firefox, OS=Linux}

2つの値を持つ変数3つの組み合わせは単純に積を取ると8通りとなるが、 ここでは4通りのみ生成されている。 その一方で、任意の2つの変数の組み合わせはすべて登場するようになっている。


Tcl 8.6 のインラインアセンブラを使って簡易言語を作る [Tcl]

Tcl 8.6から::tcl::unsupported::assembleというコマンドでインラインアセンブラが使えるようになっていた。 これを使うとTclの仮想マシンのバイトコードに対するアセンブラをTclコード中に書くことができる。

unsupportedという記載からもわかるように公式なドキュメントはないが、 故意にエラーを出すことによってエラーメッセージから使い方を忖度することができる。

例えば ::tcl::unsupported::assemble help とすると命令の一覧が出てくる。 (helpというサブコマンドがあるわけではなくて、存在しない命令を与えた時のエラー)

% ::tcl::unsupported::assemble help
bad instruction "help": must be push, add, append, appendArray, appendArrayStk, appendStk, arrayExistsImm, arrayExistsStk, arrayMakeImm, arrayMakeStk, beginCatch, bitand, bitnot, bitor, bitxor, concat, coroName, currentNamespace, dictAppend, dictExists, dictExpand, dictGet, dictIncrImm, dictLappend, dictRecombineStk, dictRecombineImm, dictSet, dictUnset, div, dup, endCatch, eq, eval, evalStk, exist, existArray, existArrayStk, existStk, expon, expr, exprStk, ge, gt, incr, incrArray, incrArrayImm, incrArrayStk, incrArrayStkImm, incrImm, incrStk, incrStkImm, infoLevelArgs, infoLevelNumber, invokeStk, jump, jump4, jumpFalse, jumpFalse4, jumpTable, jumpTrue, jumpTrue4, label, land, lappend, lappendArray, lappendArrayStk, lappendStk, le, lindexMulti, list, listConcat, listIn, listIndex, listIndexImm, listLength, listNotIn, load, loadArray, loadArrayStk, loadStk, lor, lsetFlat, lsetList, lshift, lt, mod, mult, neq, nop, not, nsupvar, over, pop, pushReturnCode, pushReturnOpts, pushResult, regexp, resolveCmd, reverse, rshift, store, storeArray, storeArrayStk, storeStk, strcmp, streq, strfind, strindex, strlen, strmap, strmatch, strneq, strrange, strrfind, sub, tclooClass, tclooIsObject, tclooNamespace, tclooSelf, tryCvtToNumeric, uminus, unset, unsetArray, unsetArrayStk, unsetStk, uplus, upvar, variable, verifyDict, or yield

pushという命令について知りたければ同様にエラーを出してみる。

% ::tcl::unsupported::assemble push
wrong # args: should be "push value"

この調子で調べていくと基本的な命令については何となくわかってくる。 そこでTclバイトコードをターゲットとした簡単な言語を作ってみた。

コンパイラはSMLで、パーサジェネレータとしてProglrを使って作る。

以前の記事 をOCaml+BNFC+JavaからSML+Proglr+Tclに変えて行ったものと思えばよい。 ソースコードの全体はGistにアップロードした。

文法定義

文法定義は下記の通り。

token Add "+" ;
token Sub "-" ;
token Mul "*" ;
token Div "/" ;
token LParen "(" ;
token RParen ")" ;
token Eq "=" ;
token Comma "," ;
token Semi ";" ;
token FunKw "fun" ;
token LetKw "let" ;
token InKw "in" ;
token IfKw "if" ;
token ThenKw "then" ;
token ElseKw "else" ;
token Integer of int;
token Ident of string;
token String of string;
 
Grm. Grm ::= [Top] ;
separator Top ";" ;
 
Fun. Top ::= "fun" Ident "(" [Param] ")" "=" Exp ;
Exp. Top ::= Exp ;
 
separator Param "," ;
Param. Param ::= Ident ;
 
Let. Exp ::= "let" Ident "=" Exp "in" Exp ;
Cnd. Exp ::= "if" Exp "then" Exp "else" Exp ;
 
separator Exp "," ;
 
Add. Exp1 ::= Exp1 "+" Exp2 ;
Sub. Exp1 ::= Exp1 "-" Exp2 ;
 
Mul. Exp2 ::= Exp2 "*" Exp3 ;
Div. Exp2 ::= Exp2 "/" Exp3 ;
 
App. Exp3 ::= Ident "(" [Exp] ")" ;
Int. Exp3 ::= Integer ;
Str. Exp3 ::= String ;
Var. Exp3 ::= Ident ;
 
coercions Exp 3;

言語は関数定義と式の連続であり、式中には条件分岐とローカル変数と四則演算と関数呼び出しが書ける。

ProglrはGLRだが、この文法はLALR(1)になっているはずである。 これを確認するには、最初のtokenの行を省くとBNFCの文法定義と互換性があるので、 BNFCに食べさせてocamlyaccに通す。衝突が報告されないことでLALR(1)であることを確認できる。

Proglrでは字句解析についてはml-ulexで行うのだが、割と自明なので掲載を省く。

これをProglrに通すとパーサーと抽象構文木のデータ型のSMLソースコードが生成される。

コンパイラ

メインとなるProglrのドライバは次のように書く。

fun main () =
  let
    val strm = Lexer.streamifyInstream TextIO.stdIn
    val sourcemap = AntlrStreamPos.mkSourcemap ()
    val ast = hd (Parse.parse sourcemap strm)
  in
    check ast;
    compile ast
  end

何故Parse.parseのhdを取っているのかといえば、Parse.parseが構文木のリストを返すからだ。 これはProglrが一般にCFGを扱うからで、CFGの文法は多義的でありうる。

checkは未定義のローカル変数の使用と、ローカル変数の名前の衝突をチェックする。 後者についてはシャドウイングされるものとみなして名前の付け替えをしてもいいと思うが、 今回は単にチェックするだけにした。

fun nameOf (Param (_, name)) = name
 
fun mem (x, []) = false
  | mem (x, y::ys) = x = y orelse mem (x, ys)
 
fun check (Grm (span, tops)) = List.app (fn top => checkTop (top, [])) tops
and checkTop (Fun (span, name, params, body), env) =
      checkExp (body, map nameOf params)
  | checkTop (Exp (span, exp), env) = checkExp (exp, env)
and checkExp (Let (span, name, value, body), env) = (
      checkExp (value, env);
      if mem (name, env) then raise Fail ("dup var: " ^ name)
      else checkExp (body, name::env))
  | checkExp (Cnd (span, cond, t, f), env) =
      (checkExp (cond, env); checkExp (t, env); checkExp (f, env))
  | checkExp (App (span, rator, rands), env) =
      List.app (fn rand => checkExp (rand, env)) rands
  | checkExp (Add (span, e1, e2), env) =
      (checkExp (e1, env); checkExp (e2, env))
  | checkExp (Sub (span, e1, e2), env) =
      (checkExp (e1, env); checkExp (e2, env))
  | checkExp (Mul (span, e1, e2), env) =
      (checkExp (e1, env); checkExp (e2, env))
  | checkExp (Div (span, e1, e2), env) =
      (checkExp (e1, env); checkExp (e2, env))
  | checkExp (Int (span, int), env) = ()
  | checkExp (Str (span, str), env) = ()
  | checkExp (Var (span, var), env) =
      if mem (var, env) then () else raise Fail ("unknown var: " ^ var)

Tclの仮想マシンはJavaVMと同様のスタックマシンである。 したがってコンパイルは前回の記事と大体同様である。

local
    val n = ref 0
in
    fun newLabel () = "label" ^ Int.toString (!n) before n := !n + 1
end
 
fun println s = (print s; print "\n")
 
fun compile (Grm (span, tops)) = List.app compileTop tops
and compileTop (Fun (span, name, params, body)) = (
      println ("proc " ^ name ^ " {" ^ String.concatWith " " (map nameOf params) ^ "} {");
      println ("::tcl::unsupported::assemble {");
      compileExp body;
      println ("}");
      println ("}"))
  | compileTop (Exp (span, exp)) = (
      println ("::tcl::unsupported::assemble {");
      compileExp exp;
      println ("}"))
and compileExp (Let (span, name, value, body)) = (
      compileExp value;
      println ("store " ^ name);
      println "pop";
      compileExp body)
  | compileExp (Cnd (span, cond, t, f)) =
      let
        val falseLabel = newLabel ()
        val trueLabel = newLabel ()
      in
        compileExp cond;
        println ("jumpFalse " ^ falseLabel);
        compileExp t;
        println ("jump " ^ trueLabel);
        println ("label " ^ falseLabel);
        compileExp f;
        println ("label " ^ trueLabel)
      end
  | compileExp (App (span, rator, rands)) = (
       println ("push " ^ rator);
       List.app compileExp rands;
       println ("invokeStk " ^ Int.toString (length rands + 1)))
  | compileExp (Add (span, e1, e2)) = (
      compileExp e1;
      compileExp e2;
      println "add")
  | compileExp (Sub (span, e1, e2)) = (
      compileExp e1;
      compileExp e2;
      println "sub")
  | compileExp (Mul (span, e1, e2)) = (
      compileExp e1;
      compileExp e2;
      println "mult")
  | compileExp (Div (span, e1, e2)) = (
      compileExp e1;
      compileExp e2;
      println "div")
  | compileExp (Int (span, int)) = println ("push " ^ Int.toString int)
  | compileExp (Str (span, str)) = println ("push {" ^ str ^ "}")
  | compileExp (Var (span, var)) = println ("load " ^ var)

実行

コンパイラはtalという実行ファイルになるようにした。 Tcl上での実行を簡単にするためにシェルスクリプトtalexecを書く。

#/bin/sh
 
TEMP=$(mktemp)
tal < $1 > $TEMP
tclsh $TEMP
rm -f $TEMP

ソースファイルを次のように書くと、

fun f(x) =
  if x then x * f(x - 1)
  else 1;
 
puts(f(10))

実行結果はこうなる。

$ talexec fact.tal
3628800

ここでf関数はTclのプロシージャとして定義される。 putsはTclの組み込みのコマンドである。 関数呼び出しの構文で任意のTclコマンドを呼び出すことができる。

使用した命令

命令 説明
push value valueをスタックに積む
pop スタックから1つ取り出す
store varname スタック最上位の値を変数varnameに格納する。スタックは変更されないので注意。
load varname 変数varnameの値をスタックにロードする
jumpFalse label スタック最上位の値が偽のときlabelにジャンプする
jump label 無条件ジャンプ
label name ラベルの定義
invokeStk count スタックの内容でプロシージャ(コマンド)を呼び出す。countは引数の数+1
add スタックの2要素を取り出し、和をスタックに置く
sub スタックの2要素を取り出し、差をスタックに置く
mult スタックの2要素を取り出し、積をスタックに置く
div スタックの2要素を取り出し、商をスタックに置く

感想

今のところTclのVMに関してあまり特殊なことや優位性があるようには思われないので、 一般的な言語処理系のターゲットとする価値があるかというとなさそうである。 アセンブリにオリジナルソースファイルの行番号を埋め込む方法が無いようである点も不利である。

しかしTclの中でDSLをコンパイルして使いたかったり、 Tclと何らかの密な連携を必要とする場合はインラインアセンブラを使う手もあるだろう。


ScalaCheckへの疑念 [Scala]

「プロパティベースのテスト」について初めて知ったのは Fun of Programmingという本で、 それを読んだ当時ScalaにもScalaCheckがあることを知って少しさわっていたんだけど 結果的には「なんか微妙」という感想を持ってやめてしまった。[^1]

その後特にScalaCheckをさわってはおらず、Scalaからもかなり離れてしまったので感想も変わっていないんだけど Scalaのユーザーが増えるとともにScalaCheckも使われているようで、よく聞くようになった。

それで思い出したのでScalaCheckへの疑念を言語化することにした。 タイトルはScalaCheckとしたけれどもQuickCheckやその他の同様のフレームワークにも該当すると思う。

普通にテストケースを書いたほうがいいのではないか

ScalaCheckでは例えば「任意の1024以上の整数nについてP(n)が成立する」のような性質から、 nを例えば100通り自動生成してテストケースを実行してくれる。 人間がテストケースを書く場合、おそらく2,3個のケースを書くにとどまるだろう。

ここで「50倍ものケースを自動実行するのだからより大きい確証が得られるだろう」 ということが言えるだろうか。

人間が書くテストケースにおけるnはおそらく1024と、MAX_INTと、ひょっとしたらその間の適当な値である。[^2]

自動生成されるケースは1024以上MAX_INT以下のランダムな100個で、 1024とMAX_INTはそこに含まれるかもしれないし含まれないかもしれない。

この例で1024とMAX_INTはバグを見つけてくれそうなテストケースである。それは仕様の境界値だから。[^3] プログラムは「以上」と「より大きい」を勘違いしているかもしれないし、オーバーフローを考慮していないかもしれない。

これに対して1025からMAX_INT-1までの値はせいぜい1つ選べばよく、残りは無駄なテストである。 無駄なテストケースは100個あっても10000個あっても品質の向上に貢献しない。 ランダムに100回実行したから1回実行したよりも99回分多くの確信が得られると思うのは偽の確信である。 偽の確信はテストにおける害悪だ。

これらに対して「それはジェネレーターの定義次第だ」という反論があるかもしれない。 でもそれを意識してジェネレータを作るならば、 それは人間がテストケースを決めるのを遠回りにしただけではないだろうか。

ランダムな組み合わせはどうか

複数の変数に対する組み合わせテストを自動生成してくれる点が ScalaCheckなどの方法の有利な点だという考えがあるかもしれない。 これは確かに手で書くのは煩雑だ。

でも同じ自動生成するのでも直交表やPairwise法などの、経験的な研究に裏付けられた、 バグ検出に効率的な組み合わせの自動生成方法があり、 ランダムな生成が有利だという根拠はない。

確率的なものは難しい

以前ScalaCheckをさわっていた時の記事で、 確率的な分布を意識していないと有効なテストが生成されないことがあるという注意点を取り上げていた。

テストの中に確率的なものが現れるのは悪いことだと思う。 おそらく多くの開発者にとって確率はプログラムよりずっと理解しにくいからだ。 プログラムコードの結果を予測するよりもテストコードの結果を予測するほうが難しいというのは、 明らかに望ましい状況ではない。

再帰的なデータ構造についてはどうか

再帰的なデータ構造に対するテストデータの生成はScalaCheckのような方法が活躍できる領域かもしれない。 再帰的なデータ構造では変数の数自体が固定的でなく、 そのような問題に対して既存の組み合わせ技法をどう適用すればいいかはあまりはっきりしないからだ。

これについてもしかし、ランダムである必要があるのかという点についてはやはり疑問が残る。 (有界モデル検査のようにある決まった範囲を全網羅するような方法が代わりに考えられる)


以上ScalaCheckや類似の手法についての疑念を書いた。

大体においては 「同値分割や境界値分析やPairwiseなどの既存のテスト技法でテスト設計したほうがいいのでは」 と感じているということだ。

一方で「ScalaCheckは駄目だと思っている」かというとあまりそこは断言できない。

1つにはこれだけ皆使っているので自分の理解が何か間違っているのではないかという不安がある。 (上記に書いたようなことを補填するような洗練されたやり方が考案されているのかもしれない)

もう1つはテスト手法の良しあしは経験的に決まるものだという点で、 例えば「実際にプロパティベースのテストのほうが既存のテスト手法より良くバグを見つけるのだ」 という経験的研究結果があればなるほどそういうものかと思って納得するかもしれない。 (読んでいる人でご存知の人がいたら教えてください)

  • [^1] 左下の「ScalaCheckを試す」のリンク集を参照
  • [^2] n=1023でP(n)が成立しないこともテストするかもしれないが、比較のためここでは取り上げない
  • [^3] MAX_INTは「暗黙の」境界値であるともいえる

YokohamaUnitの紹介 (5) スタブ [YokohamaUnit]

YokohamaUnit はスタブ作成のための文法を提供する。

一般的には次の形式をとり、式が現れる場所に置かれる。

a stub of `クラス名` such that method `メソッドシグニチャ` returns ...

例えばMapクラスのスタブであり、 getメソッドに対して42を返却するスタブは次のように書く。

a stub of `java.util.Map` such that method `get(java.lang.Object)` returns 42

テスト全体としてはたとえばこのように書ける。

# Test: Collections.unmodifiableMap preserves lookup
 
Assert `unmodifiableMap.get("answer")` is 42
 where map is a stub of `java.util.Map`
              such that method `get(java.lang.Object)` returns 42
   and unmodifiableMap is `java.util.Collections.unmodifiableMap(map)`.

こうした記述はMockitoのメソッドを呼ぶようなバイトコードにコンパイルされる。

スタブ/モック機能を言語機能としてどのように取り入れるかのバランスは難しいところであり、 どのように発展させるか(させないか)は模索している段階にある。

ある程度高機能なスタブを作るための表記は結局のところある種のプログラミング言語になる。 JavaやGroovyで書くのでなく、 スタブ/モック用の言語を使う利点があるスイートスポットは(もしあるとしたら)どの位の用途なのか。 自然言語として読み下せることはスタブ/モックにとってどの程度重要なのだろうか。

またYokohamaUnitについて言えば、バイトコードを自分で生成できる前提なので、 あえてMociktoを使用する必然性も実はあまりないかもしれない。


YokohamaUnitの紹介 (4) 4フェーズテスト [YokohamaUnit]

YokohamaUnit で4フェーズテストを書く方法を取り上げる。

4フェーズテストはテスト対象のメソッドや関数を呼んだ後のオブジェクト (やその他の)状態の変化を確かめるテストの一般的なパターンで、 事前準備(setup)、実行(exercise)、検証(verify)、事後処理(teardown)からなる。

YokohamaUnitでは見出しをつかってそれぞれのフェーズを表す。

# Test: AtomicInteger.incrementAndGet increments the content
## Setup
Let i be `new java.util.concurrent.atomic.AtomicInteger(0)`.
 
## Exercise
Do `i.incrementAndGet()`.
 
## Verify
Assert `i.get()` is `1`.

Setupでは主にLet文を使って変数の束縛を行う。 Let文は複数並べてもよいし、andでつなげてもよい。

Let x be 1 and y be 2.

beでなく=でもよい。

Let x = 1 and y = 2.

ExerciseではDo文を使ってテスト対象のメソッドの実行を行う。 Do文は式言語の(副作用を起こすことが期待される)式をとる。

これも複数並べてもよいし、andでつなげてもよい。

## Exercise
Do `i.incrementAndGet()` and `i.incrementAndGet()`.
Do `i.incrementAndGet()`.

Verifyフェーズは4フェーズでない関数的なテストの場合と同様だが、 複数並べた場合やfor allを使った場合の実行単位が異なる。

Assertを複数並べたり、テーブルを参照した場合、 関数的なテストでは複数のJUnitテストメソッドにコンパイルされたが、 4フェーズテストの中でそのように書いた場合は、 1つのメソッドの中で続けて実行されるようなコードにコンパイルされる。

4フェーズテストをパラメタ化する方法については別の記事で紹介する。

次の例はTeardownフェーズを含むテストの例である。 Teardownフェーズの中でもExerciseフェーズと同様、Do文を使う。

# Test: The size of a new temporary file is zero
## Setup
Let temp be `java.io.File.createTempFile("prefix", null)`.
 
## Verify
Assert `temp.length()` is `0L`.
 
## Teardown
Do `temp.delete()`.

Teardownフェーズはテストの成否にかかわらず必ず実行される。


YokohamaUnitの紹介 (3) リテラル [YokohamaUnit]

YokohamaUnit のGroovy式の中では当然Groovyのリテラルが使える。 リストのリテラルなどはJavaにはないリテラルだ。

Assert that `Arrays.asList("a", "b", "c")` is `["a", "b", "c"]`.

文字列などの基本的なリテラルもGroovyのものを利用できる。

Assert that `sut.toString()` is `"hello"`.

しかしこのようにバッククォートとダブルクォートを重ねて書くのはうるさいと感じるかもしれない。 そこでYokohamaUnitは基本型については独自にリテラルを用意している。

Assert that `sut.toString()` is "hello".

YokohamaUnitの基本型リテラルはほとんど厳密にJavaの基本型リテラルを踏襲している。 GroovyはおおむねJavaを踏襲しているが、違いに注意しなければならない点もある。 例えばシングルクォートはGroovyでは文字ではなく文字列として解釈される。

Assert that `'c'` is "c".
Assert that `'c' as char` is 'c'.

他にも浮動小数点リテラルの16進数表記などで違いがあるが、 普通に使う範囲で注意するのは文字リテラルくらいだろう。

Javaになく、YokohamaUnitにあるリテラルとして複数行文字列リテラルがある。 複数行文字列リテラルは見出しの後にバッククォート3つ(から5つ)で囲んで定義する。 使用する際はその見出しを使って参照する。

# Test: Test Multi-line literal
 
Assert `"cheer\n".multiply(3).denormalize()` is [Three cheers].
 
### Three cheers
 
```
cheer
cheer
cheer
```

バッククォートの後にスペース区切りで任意の識別子を書くことができるが、 この識別子を使って改行コードや文字列末尾に改行が付くかどうかを制御することができる。

lfという識別子を置くと改行コードがLFになる。

# Test: Code block with lf
 
Assert `s.length()` is 2 where s is [lf].
 
### lf
 
```text lf
a
```

この例でtextという識別子に特に意味はなく、無視される。

crlfという識別子を置くと改行コードがCRLFになる。

# Test: Code block with crlf
 
Assert `s.length()` is 3 where s is [crlf].
 
### crlf
 
```text crlf
a
```

chopという識別子を置くと末尾の改行コードが付かなくなる。

# Test: Code block with chop
 
Assert `s.length()` is 1 where s is [chop].
 
### chop
 
```text crlf chop
a
```

文字列リテラルや複数行文字列リテラルの後に "as クラス名" を書くことで 任意のオブジェクトに変換できる。 これは複雑なテストデータの準備に活用できる。

YokohamaUnit自身のテストコードから例を示す。

文字列Sourceが次のように定義されているとする。

### Source
```
# Test: test
Assert `x < 4` is true where x = any of 1, 2 or 3.
 
# Test: test 2
## Setup
Let y = any of 4, 5 or 6.
 
## Verify
Assert `y < 4` is true.
```

テストデータとしてはこれを文字列ではなくASTデータ型であるGroupオブジェクトに変換したい。 そこでSourceを参照する箇所では次のように書く。

[Source] as `yokohama.unit.ast.Group`

このようにするとYokohamaUnitはクラスパス中からyokohama.unit.annotations.As というアノテーションがついたクラスを検索し、 そのクラスの中から「文字列を引数として取り、Groupを戻すメソッド」を探す。

@As
public class DataConverters {
    public static Group asAst(String input) {
      ...
    }
}

そしてそのメソッドを使ってデータ変換を行うようなバイトコードを生成する。 変換を行うメソッドが定義されたクラスはテストコンパイル時とテスト実行時の両方でクラスパス中になければならない。


YokohamaUnitの紹介 (2) パラメタ化テスト [YokohamaUnit]

YokohamaUnit で入力値と出力値だけが異なるようなテストケースをたくさん作りたい場合、 テストデータを表に抽出することができる。

# Test: String.endsWith
 
Assert `sut.endsWith(suffix)` is `expected`
for all sut, suffix and expected in Table [Test data for endsWith].
 
| sut           | suffix  | expected |
| ------------- | ------- | -------- |
| "hello world" | ""      | true     |
| "hello world" | "world" | true     |
| "hello world" | "hello" | false    |
[Test data for endsWith]

表のキャプションは表の前でもよい。

[Test data for endsWith]
| sut           | suffix  | expected |
| ------------- | ------- | -------- |
| "hello world" | ""      | true     |
| "hello world" | "world" | true     |
| "hello world" | "hello" | false    |

JUnitのTheoryとの大きな違いだが、表の1行が1つのテストメソッドとなる。 つまり、上記のような記述の場合、テストメソッドは3つできる。 したがって、表の一部のテストが失敗しても他のテストは実行されるし、 テストケース数も3件と数えられる。

テストデータをCSVファイルやExcelに書くこともできる。

# Test: String.startsWith
Assert `sut.startsWith(prefix)` is `expected`
for all sut, prefix and expected in Excel 'TestExcel.xlsx'.
# Test: String.startsWith
Assert `sut.startsWith(prefix)` is `expected`
for all sut, prefix and expected in CSV 'TestCSV.csv'.

この場合、CSVファイルやExcelファイル読み込みはテスト実行時ではなくて テストコードのコンパイル時に行われ、やはり1行が1つのテストメソッドとなる。


YokohamaUnitの紹介 (1) 関数的なメソッドのテスト [YokohamaUnit]

YokohamaUnitでは1つのソースファイルが1つのJUnitテストクラスにコンパイルされる。) ソースファイルの標準的な拡張子はdocyである。

テストケースを書くには # Test: という文字列に続けて、 そのテストの名前を示す見出しを書く。

# Test: This is my first test

この見出しはJUnitテストクラスにおけるメソッド名の元になるが、 特にJavaのメソッド名の規約の制約を受けない。

最初のハッシュ記号は上の例では1つだが、1つから6つまでの任意の数でよい。

YokohamaUnitのテストケースの書き方は「関数的なメソッドのテスト」と 「4フェーズテスト」の2つに分けられる。今回は前者を取り上げる。

関数的なメソッドとはその結果が引数の値のみによって決定されるメソッドである。[^1] そのようなメソッドはもっともテストがしやすい。

関数的なメソッドをテストするためのAssert文は見出しに続けて直接書くことができる。

# Test: This is my first test
 
Assert that `Integer.valueOf("123")` is 123.

thatは省略してもよい。 ここでは Integer.valueOf がテスト対象のメソッドである。 バッククォートに囲まれた部分 Integer.valueOf("123") はGroovyの式として解釈される。

Assert文は1つの見出しの下に複数続けて記述しても構わない。

# Test: This is my first test
 
Assert `Integer.valueOf("123")` is 123.
 
Assert `Integer.valueOf("456")` is not 123.

この場合それぞれのAssert文に対して別々のJUnitテストメソッドが生成される。 したがって一方が失敗してももう一方のテストは実行されるし、2ケースとカウントされる。

次のように書いた場合は1つのメソッドのみが生成される。

Assert `Integer.valueOf("123")` is 123 and `Integer.valueOf("456")` is not 123.

例外の送出をテストしたい場合は次のように書く。

Assert that `Integer.valueOf("xyz")` throws an instance of `NumberFormatException`.

例外を送出しないということをあえてテストとして書きたい場合は次のように書く。

Assert that `Integer.valueOf("123")` throws nothing.

Assert文の副文の主語にあたる部分が長くなると読みにくくなることがある。 そのような場合、where句を使って変数を導入することができる。

Assert that `sut.length()` is 27 where sut is "honorificabilitudinitatibus".

isの代わりに=を使ってもよい。

Assert that `sut.length()` is 27 where sut = "honorificabilitudinitatibus".

複数の変数を導入することもできる。

Assert that `sut.startsWith(prefix)` is true
where sut = "honorificabilitudinitatibus" and prefix = "honor".

[^1] Javaのような言語では「引数と不変なフィールドの値のみによって」 といったほうがいいかもしれない。 そのように定義するならば、メソッドの引数だけでなく、 不変なフィールドを初期化するコンストラクタ引数にも依存してよい。


単体テストのための外部DSL [YokohamaUnit]

プログラムの単体テスト(自動実行されるテストコード)は普通テスト対象となるプログラムと同じ言語で書かれる。 当たり前のようだが必然的にそうでなければならないということではない。

単体テストを専ら関数やメソッドに対するテストであると定義した場合、 言語Aのプログラムの単体テストを記述する言語に求められる最低限の要件は、 「その言語Aの関数やメソッドの呼び出し規約に従ってリンクできる」ということだけだ。 実際Javaの単体テストをGroovyで書くということはおこなわれているようだし、 理論上はC言語の単体テストをSML#で書いたって構わないはずだ。[^1]

それでもテスト対象と同じ言語で単体テストが書かれるのが一般的なのは、

  1. 自明にリンクできる
  2. 開発者自身が単体テストを書くことが多い

といった理由によるだろう。

また、こうした単体テストはある種の内部DSLとして書かれる。 私の理解では、これは単体テストのコードが 単に「言語Aで書かれた別のプログラム」ように見えるのではなく、 「テストの意図をそれにふさわしい見え方で表現したもの」として見えてほしいからだ。

例えばJavaのJUnitであれば、下記のような書き方をする。

assertThat(sut.get(0), is(123));

この書き方は "Assert that sut.get(0) is 123." という英語の文として読み下すことができるので可読性が高いと言われている。 このような考え方はしばしば「ドキュメントとしてのテスト」といわれる。

ところで上記のJUnitの文は詳しく見てみると「2つのレベル」がある。 「テスト対象の呼び出し」に属する部分と「ドキュメント」に属する部分だ。

さっきJUnitの文を英文にパラフレーズするときに sut.get(0) の部分だけは(いわば直接話法のように)そのまま残していた。 この箇所は英文としてそのまま読み下すことができない。 しかしそれはテスト対象の呼び出しの部分だから、 ドキュメントの文法(疑似英語)でなく対象プログラムの文法に属するとみなすことは自然である。

sut.get(0) 以外の部分は「ドキュメント」に属するレベルである。 (123がどちらに属するかについては微妙な点だ)

このドキュメントの部分は「自然」だろうか。 たとえば何故assertとThatの間に空白がなく、ThatのTだけが大文字なのだろうか。 isの前にカンマが来て後に括弧が来るのはなぜだろうか。 つまり結局のところなぜ "Assert that sut.get(0) is 123." と書けないのだろうか。

もちろんその答えはドキュメントのレベルをJavaの内部DSLで記述しているからだ。 しかし、もともとなぜJavaの単体テストをJavaで書いているのかを振り返ってみよう。 それはテスト対象とのリンク要件を容易に満たせるからだった。 しかし先に分割した2つのレベルのうち、 リンク要件を満たす必要があるのは「テスト対象の呼び出し」のレベルだけで、 「ドキュメント」のレベルは別にそうではない。

以上のような考察から、「単体テストのための外部DSL」があってもいいのではないか、 という考えが浮かび上がってくる。 その外部DSLは上述の2つのレベルからなる文法を持ち、 テスト対象とリンクするバイナリにコンパイルされるだろう。

YokohamaUnit はそのようなJava向けの単体テストフレームワークである。 「ドキュメント」部分には独自の文法を持ち、 「テスト対象の呼び出し」の部分にはGroovyを使う。 テストコードは直接JUnitのクラスファイルにコンパイルされる。

次のコードはYokohamaUnitのテストコードとして妥当なものの例である。

*[FizzBuzz]: yokohama.unit.example.FizzBuzz
 
# Test: FizzBuzz test 
 
Assert that `new FizzBuzz().generate(16)` is
`[ "1", "2", "Fizz", "4", "Buzz", "Fizz", "7", "8",
   "Fizz", "Buzz", "11", "Fizz", "13", "14", "FizzBuzz", "16" ]`.
*[StringUtils]: yokohama.unit.example.StringUtils
 
# Test: Test cases for `toSnakeCase`
 
Assert that `StringUtils.toSnakeCase(input)` is `expected`
for all input and expected in Table [1].
 
Assert that `StringUtils.toSnakeCase(null)` throws
an instance of `NullPointerException`.
 
| input           | expected          |
| --------------- | ----------------- |
| ""              | ""                |
| "aaa"           | "aaa"             |
| "HelloWorld"    | "hello_world"     |
| "practiceJunit" | "practice_junit"  |
| "practiceJUnit" | "practice_j_unit" |
| "hello_world"   | "hello_world"     |
[1]

外部DSLの選択は文法の不自然さ以外にもいくつかの内部DSLの制約を解消しうる。 例えば(JUnitにおける)例外のテストやパラメタ化テストの問題などである。

次の記事からYokohamaUnitの機能を紹介していきたい。

[^1] 脱線になるが、結合のレベルが上がるほどこの要件は緩和される。 コマンドのテストで求められるのは、コマンドライン呼び出しができることだけだ。 たとえばDejaGnuはTclベースだが、 テスト対象がTclで書かれていなければならないということはない。 またRESTサービスをテストするのに使うクライアント側の言語の選択肢も、 サービス側の実装言語にほとんど左右されないだろう。


この広告は前回の更新から一定期間経過したブログに表示されています。更新すると自動で解除されます。