SSブログ
YokohamaUnit ブログトップ

YokohamaUnit 0.3.0 の新機能 [YokohamaUnit]

YokohamaUnit の 0.3.0 をリリースした。

https://github.com/tkob/yokohamaunit/releases/tag/v0.3.0

このリリースには次の2つの新機能が含まれる。

リソース式

Javaのユニットテストではテストデータをクラスパス上に配置して(リソース)読み込むことがよくある。

Let url = `getClass().getResource("text1.txt")`.

この書き方は面倒くさい。そこでリソースのための特別な文法を用意した。

Let url = resource "text1.txt".

次のようにも書ける。それぞれリソースをInputStream, File, URIとして返す。

Let ins = resource "text1.txt" as `java.io.InputStream`.
Let file = resource "text1.txt" as `java.io.File`.
Let uri = resource "text1.txt" as `java.net.URI`.

一時ファイル式

一時ファイルを作成するための式を追加した。

Let tempFile = a temporary file.
Let tempFile = a temp file.

作られたファイルに対しては自動的にdeleteOnExitが呼ばれる。

その他

内部的にJavaslangを使うことにした。 Javaでタプルや関数的リストなどを使うのにこれまでは独自に実装したものを使っていて、自分で書くのも何だなと思っていた。 Functional Javaを検討したこともあったが、 いまいちJavaの中になじんでくれないAPIだったのでやめていた。 Javaslangのほうが比較的使いやすいように感じる。

使用例

nbmtoolsのテストで、上述の新機能を使っているので参考にしてほしい。

https://github.com/tkob/nbmtools/tree/f0cd916a7fa5d28228d6e05a37144413ea808c18/src/test/docy/nbmtools


YokohamaUnit 0.2.0 の新機能 [YokohamaUnit]

YokohamaUnit の 0.2.0 をリリースした。

https://github.com/tkob/yokohamaunit/releases/tag/v0.2.1 (バグフィックスの0.2.1に更新)

このリリースには次の2つの新機能が含まれる。

データ変換

テストを書いていると「フィクスチャをテスト可能なデータ型まで変換する」といった、 テストそのものには本質的でない処理がテストコード中にいくつも紛れ込んでしまうことがある。 例えば resources ディレクトリに配置したデータを InputStream から何らかのデータに変換したりといったようなことだ。

そうするとユーティリティメソッドを使って IOUtils.toString(is) のように書くことになるのだが、 あまり美しくない。

0.2.0 導入された機能を使うと is as InputStream と書けるようになる。 まずユーティリティメソッドを Java か何かで書いて、次のようにアノテーションをつける。

@As
public class DataConverters {
    @As
    @SneakyThrows(IOException.class)
    public static String inputStreamAsString(InputStream is) {
        return IOUtils.toString(is, "UTF-8");
    }
}

そうすると YokohamaUnit のテストからは次のように使えるようになる。

Assert `bais as String` is "hello"
where bais = `new ByteArrayInputStream("hello".getBytes("UTF-8"))`.

これを使うためには YokohamaUnit のオプションで -converter <base-package> と指定すると、指定したパッケージ以下(カンマ区切りで複数指定できる)から変換メソッドを見つけてくれる。

不変条件チェック

テスト対象に下記のように Invariant アノテーションをつける。

@lombok.AllArgsConstructor
@Invariant("({ it.count >= 0 })")
@Invariant("({ it.count < 10})")
public class DummyCounter {
    @Getter
    private int count;
 
    public void increment() {
        count++;
    }
}

そうすると YokohamaUnit の4フェーズテストの Setup と Exercise の後に、 テスト内容にかからわず常にこの不変条件をチェックし、満たされないとテストが失敗するようになる。

この機能を有効にするには -contract のオプションを指定する。


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サービスをテストするのに使うクライアント側の言語の選択肢も、 サービス側の実装言語にほとんど左右されないだろう。


YokohamaUnit ブログトップ

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