xUnit Test Patterns : Chapter 27 Value Patterns - Derived Value(導出された値)

xUTP読書会で発表するためにまとめた。

はじめに

xUnit Test Patterns : Chapter27 Value Patterns は、テストで使用する「値」に関するパターンについてまとめた章である。
この項では、Derived Value(導出された値)というパターンについて解説している。

――テストで使用する値をどう記述しますか
――他の値から計算によって導出します

BigDecimal expectedTotal = itemPrice.multiply(QUANTITY);

テスト内で使われる値の中には、他の値から導出可能なものがある。それらの値を直接(リテラルとして)書くのではなく、適切な式の形で記述することにより「ドキュメントとしてのテスト」としての価値が高まる。

How it works

他の値から導出可能な値は、式として記述することで手計算せずに済ますことができる。このパターンはアサーションのexpected resultやfixtureの生成パラメータ、exercise時にSUTへ渡すパラメータなどに使用する値を生成するのに使える。
Derived Valueを定数や変数に保持しておくことができる。この値は、コンパイル時(定数の場合)やTestcase Objectの初期化時、fixtureのセットアップ時、あるいはTest Methodの本体で計算される。

When to Use It

ある値が他の値から決定的な方法で導出可能なら、いつでもDerived Valueパターンを使用できる。
このパターンを使うことの主な問題としては、SUT内部とDerived Valueで同じ(そして間違った)ロジックを使ってしまうことでエラーを検出できないという可能性が挙げられる(たとえば浮動小数点の丸め処理など)。安全のためには、そのようなエラーが起きそうな場所にはLiteral Valueを使用したテストも書いておいたほうがいい。
単にユニークな値が欲しい場合や値がSUTに影響を及ぼさない場合、Generated Valuesを使用したほうがいい。
このパターンはfixtureのセットアップ時に使用したり(Derived Input, One Bad Attribute:後述)、SUTが生成した値を検証するために使用したり(Derived Expectation:後述)できる。

Variation: Derived Input

テスト内に、(SUTへの入力として)似た値の組み合わせが登場することがある(たとえば「開始日」と「終了日」など)。これらの値は、SUT内で比較されたり差分を計算されたりする。
この値を直接書くのではなく、Derived Valueパターンを使って値の関連性を明示することができる。
また、値を導出する際の差分をIntent-Revealingな名前の定数にすることができる。たとえばMAXIMUM_ALLOWABLE_TIME_DIFFERENCE(許容される最大の時間差)のような。

要は

int start=100;
int end=123;

sut.execute(start,end);
// ...

みたいなのを、

int start=100;
int end=start+MAX_LENGTH;

sut.execute(start,end);
// ...

みたいにするってことだとおもいます。

Variation: One Bad Attribute

Derived Valueパターンは、引数として複雑なオブジェクトを取るメソッドをテストするのに使える。たとえばそのようなメソッドにおいて引数チェックをテストする場合。

class ComplexParameterClass {
	public int a;
	public String b;
	public double c;
	// ...
}

class SUT {
	public void testTargetMethod(ComplexParameterClass arg) {
		if(arg.a < 0) throw new IllegalArgumentException("arg.a is bad");
		// たくさんの検証
	}
}

この例だと、テストの対象とするフィールドに間違った値を格納し、それ以外に正しい値を格納したComplexParameterClassオブジェクトが必要となる。ちなみにSingle-Condition Testとするため、ひとつのテストメソッドではひとつの検証をすべき。
このような検証に必要なオブジェクトを生成するためには、まずすべてのフィールドに正しい値を格納したオブジェクトを作成し、その後検証対象となるフィールドに異常値をセットすればよい(これがOne Bad Attributeパターン)。コードの重複を避けるため、正しいオブジェクトの生成はCreation Methodで行うのがよい。

Variation: Derived Expectation

SUTが生成する値が引数の値やfixtureにある値から計算可能な場合、その期待値をリテラルとして書くのではなくDerived Valueとして書くことができる!!!そしてその値はEquality Assertionのexpected valueとして使える!!!!!

Motivating Example

まずはDerived Value使用前のコードを見てみましょう。

public void testAddItemQuantity_2a() throws Exception {
	BigDecimal widgetPrice = new BigDecimal("19.99");
	Product product = new Product("Widget", widgetPrice);
	Invoice invoice = new Invoice();
	// Exercise
	invoice.addItemQuantity(product, 5);
	// Verify
	List lineItems = invoice.getLineItems();
	LineItem actualItem = (LineItem)lineItems.get(0);
	assertEquals(new BigDecimal("99.95"),
					actualItem.getExtendedPrice());
}

なんで99.95がexpectedなのか、計算しないとよくわからない。

Refactoring Notes

テストの可読性を上げるため、導出可能なのにLiteral Valueで書かれている値をDerived Valueで置き換える。

Example: Derived Expectation

productの価格と個数、actualItem.getExtendedPrice()の関係をDerived Expectationで明示的に書く。

public void testAddItemQuantity_2a() throws Exception {
	BigDecimal widgetPrice = new BigDecimal("19.99");
	BigDecimal numberOfUnits = new BigDecimal("5");
	Product product = new Product("Widget", widgetPrice);
	Invoice invoice = new Invoice();
	// Exercise
	invoice.addItemQuantity(product, numberOfUnits);
	// Verify
	List lineItems = invoice.getLineItems();
	LineItem actualItem = (LineItem)lineItems.get(0);
	BigDecimal totalPrice = widgetPrice.multiply(numberOfUnits);
	assertEquals(totalPrice, actualItem.getExtendedPrice());
}

こうすれば何がexpectedなのかわかりやすいですね。
今のリファクタリングでは、値の関係を明示するだけでなく、それぞれの値をわかりやすい名前の変数に格納しました。こうすればよりわかりやすくなるし後で値を変更するときも便利。

Example: One Bad Attribute

CustomerDtoからCustomerを作成するようなFactory Methodの引数検証部分をテストしたい。CustomerDtoのそれぞれの値について、異常値を入れた場合に正しくエラーとなるかをチェックする必要がある。というわけでCustomerDtoのそれぞれの値についてテストを書くと以下のようなかんじになった。

public void testCreateCustomerFromDto_BadCredit() {
	// fixture setup:
	CustomerDto customerDto = new CustomerDto();
	customerDto.firstName = "xxx";
	customerDto.lastName = "yyy";
	// etc.
	customerDto.address = createValidAddress();
	customerDto.creditRating = CreditRating.JUNK;
	// exercise the SUT:
	try {
		sut.createCustomerFromDto(customerDto);
		fail("Expected an exception");
	} catch (InvalidInputException e) {
		assertEquals( "Field", "Credit", e.field );
	}
}

public void testCreateCustomerFromDto_NullAddress() {
	// fixture setup:
	CustomerDto customerDto = new CustomerDto();
	customerDto.firstName = "xxx";
	customerDto.lastName = "yyy";
	// etc.
	customerDto.address = null;
	customerDto.creditRating = CreditRating.AAA;
	// exercise the SUT:
	try {
		sut.createCustomerFromDto(customerDto);
		fail("Expected an exception");
	} catch (InvalidInputException e) {
		assertEquals( "Field", "Address", e.field );
	}
}
// CustomerDtoのそれぞれのフィールドについて、同じようなテストが続く

これは明らかに大量のTest Code Duplicationを含み、しかもCustomerDtoに新しいフィールドが追加されるたびにあらゆるテストメソッドを修正せねばならない!!

この状況を解決するため、正しいCustomerDtoオブジェクトを作成するCreation Methodを導入する。そして、それぞれのテストでは対象とするフィールドを異常値で上書きするようにする。これでOne Bad Attributeパターンとなった。

public void testCreateCustomerFromDto_BadCredit_OBA() {
	CustomerDto customerDto = createValidCustomerDto();
	customerDto.creditRating = CreditRating.JUNK;
	try {
		sut.createCustomerFromDto(customerDto);
		fail("Expected an exception");
	} catch (InvalidInputException e) {
		assertEquals( "Field", "Credit", e.field );
	}
}

public void testCreateCustomerFromDto_NullAddress_OBA() {
	CustomerDto customerDto = createValidCustomerDto();
	customerDto.address = null;
	try {
		sut.createCustomerFromDto(customerDto);
		fail("Expected an exception");
	} catch (InvalidInputException e) {
		assertEquals( "Field", "Address", e.field );
	}
}

感想

当たり前のことをきちんと執拗に文書化する根性がすごい!!!!!!