APIコンプライアンステスト

概念モデルと実装の分離

おいらがオブジェクト指向で設計する際は、概念モデルレベルと実装を分けるように意識的に注意しています。

概念モデルを言語に変換

概念モデルの出発点は脳内でイメージしたモデルですが、このままでは共有できません。そのため、何らかの言語に変換する必要があります。変換先の言語としては、自然言語UMLプログラミング言語などが候補に挙がります。

概念モデルを表現する言語の選択基準

実装の細部を表現したくても不可能という縛りがある言語のほうが、ムダに実装を意識する必要がないのでいいですね。また、プログラミング言語と離れすぎていると、モデルを実装に落とし込むときに多くの変換が必要なるので、それは避けたいですね。

java の intarface と javadoc 記述による概念モデル表現

上記の選択基準を満たすという理由から、おいらは java の interface だけ(実際にはEnumやExceptionクラス等も)で構成されたパッケージ(実際は、同一パッケージだけどmavenレベルで別アーティファクトにしてます)という形で概念モデルを表現することが多いです。社内開発など、顧客としての立場の人物に対するトレーニングが可能な状況であれば、interface と javadoc だけのソースをユビキタスランゲージとして使えるかも。(実際にそうしようと画策中なのは秘密だw)

振る舞いに関する仕様の曖昧さをどうする?

java の intarface と javadoc 記述だけでも、構造的な部分についてはほとんど十分に設計できます。多くの必要な情報は盛り込むことができますし、多くの不必要な情報は盛り込みたくても盛り込めないのでいい感じです。しかし、振る舞いについては、シグニチャjavadoc だけでは曖昧さが残りますし、実装時には仕様に準拠しているかの確認が難しいという問題が残ります。

ユニットテストはどうだろうか?

テストファースト開発が注目され、よい点も悪い点もいろいろ見えてきた今日この頃。テストケースが、仕様を表現し、仕様に準拠しているかを確認するためのツールとなることは、現場レベルで実証されてきています。

APIコンプライアンステスト

そこでおいらは、APIパッケージと実装パッケージを分離するだけではなく、さらにAPIコンプライアンステストのパッケージを作成するようにしています。このパッケージは、以下のような特徴を持っています。

  • コンポーネントテストとしてのテストケースの集合。(ユニットテストのように "ユニット" を意識しないのに注意)
  • テストはすべてインタフェースに対して行われる。
  • テストクラスは抽象クラスで、テスト対象やテストに必要な情報を取得する抽象メソッドを用意。

このようにすることにより、振る舞いに関する仕様をテストケースという厳密な形で仕様化できます。

APIコンプライアンステストの例

以下は、TestNGを用いた API コンプライアンステストの例です。

package jp.objectfanatics.ofcache.store;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.fail;

import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

/**
 * {@link Store} インタフェース仕様準拠テスト。 {@link Store}
 * インタフェースの実装クラスに対するインタフェース仕様準拠テストです。 このテストは、{@link Store}
 * の基本的な動作を前提としており、実装側の上限や退避ポリシー等の設定によってはエラーになることがありますので、
 * このテストでエラーにならないような設定の実装を用いてテストを行ってください。各実装依存のテストについては、
 * 各実装毎に個別に用意してください。
 * 
 * @param <K>
 *            キーの型
 * @param <V>
 *            値の型
 * @author beyondseeker
 * @version $Id$
 */

abstract public class StoreTest<K, V> {
	
	/**
	 * テスト用の新規の {@link Store} を作成します。
	 * 
	 * @return 新規の {@link Store}
	 */
	abstract protected Store<K, V> createNewTestStore();
	
	/**
	 * テスト用の新規のキーを作成します。
	 * 
	 * @return テスト用の新規のキー
	 */
	abstract protected K createNewTestKey();
	
	/**
	 * テスト用の新規の値を作成します。
	 * 
	 * @return テスト用の新規の値
	 */
	abstract protected V createNewTestValue();
	
	/**
	 * テスト用の新規の {@link Store}。各テストメソッド毎に新規に作成されます。
	 */
	protected Store<K, V> newTestStore;
	
	/**
	 * テスト用の新規のキー。各テストメソッド毎に新規に作成されます。
	 */
	protected K newTestKey;
	
	/**
	 * テスト用の新規の値。各テストメソッド毎に新規に作成されます。
	 */
	protected V newTestValue;
	
	/**
	 * {@link #newTestStore}, {@link #newTestKey},
	 * {@link #newTestValue} を初期化します。
	 */
	@BeforeMethod
	public void beforeMethod() {
		this.newTestKey = createNewTestKey();
		this.newTestValue = createNewTestValue();
		this.newTestStore = createNewTestStore();
	}
	
	/**
	 * {@link Store#put(Object, Object)} の呼び出し時に、引数 key が
	 * <code>null</code> の場合、{@link IllegalArgumentException}
	 * が throw されます。
	 */
	@Test
	public void test_put_putWithNullKey() {
		// test
		try {
			newTestStore.put(null, newTestValue);
		} catch (IllegalArgumentException e) {
			return;
		}
		// assert
		fail();
	}
	
	/**
	 * {@link Store#put(Object, Object)} の呼び出し時に、引数 value が
	 * <code>null</code> の場合、{@link IllegalArgumentException}
	 * が throw されます。
	 */
	@Test
	public void test_put_putWithNullValue() {
		// test
		try {
			newTestStore.put(newTestKey, null);
		} catch (IllegalArgumentException e) {
			return;
		}
		// assert
		fail();
	}
	
	/**
	 * {@link Store#put(Object, Object)} の呼び出し時に、{@link Store}
	 * 内にキーに該当するエントリが存在しない場合、指定されたキーと値はエントリとして格納され、戻り値として
	 * <code>null</code> が返されます。その際、エントリ数は 1 増加します。
	 */
	@Test
	public void test_put_insert() {
		// test
		V actual = newTestStore.put(newTestKey, newTestValue);
		// assert
		V expected = null;
		assertEquals(actual, expected);
		
		// test
		actual = newTestStore.get(newTestKey);
		// assert
		expected = newTestValue;
		assertEquals(actual, expected);
		
		// test
		int actualSize = newTestStore.size();
		int expectedSize = 1;
		assertEquals(expectedSize, actualSize);
	}
	
	/**
	 * {@link Store#put(Object, Object)} の呼び出し時に、{@link Store}
	 * 内にキーに該当するエントリが存在する場合、指定されたキーと値はエントリとして格納され、戻り値として以前の値が返されます。その際、エントリ数は変わりません。
	 */
	@Test
	public void test_put_override() {
		// prepare
		newTestStore.put(newTestKey, newTestValue);
		V nextTestValue = createNewTestValue();
		
		// test
		V actual = newTestStore.put(newTestKey, nextTestValue);
		V expected = newTestValue;
		// assert
		assertEquals(actual, expected);
		
		// test
		actual = newTestStore.get(newTestKey);
		// assert
		expected = nextTestValue;
		assertEquals(actual, expected);
		
		// test
		int actualSize = newTestStore.size();
		int expectedSize = 1;
		assertEquals(expectedSize, actualSize);
	}
	
	/**
	 * {@link Store#get(Object)} の呼び出し時に、引数 key が
	 * <code>null</code> の場合、{@link IllegalArgumentException}
	 * が throw されます。
	 */
	@Test
	public void test_get_getWithNullKey() {
		// test
		try {
			newTestStore.get(null);
		} catch (IllegalArgumentException e) {
			return;
		}
		// assert
		fail();
	}
	
	/**
	 * {@link Store#get(Object)} の呼び出し時に、{@link Store}
	 * 内にキーに該当するエントリが存在しない場合、戻り値として <code>null</code>
	 * が返されます。
	 */
	@Test
	public void test_get_miss() {
		// test
		V actual = newTestStore.get(newTestKey);
		// assert
		V expected = null;
		assertEquals(actual, expected);
	}
	
	/**
	 * {@link Store#get(Object)} の呼び出し時に、{@link Store}
	 * 内にキーに該当するエントリが存在する場合、戻り値として該当エントリの値が返されます。
	 */
	@Test
	public void test_get_hit() {
		// prepare
		newTestStore.put(newTestKey, newTestValue);
		// test
		V actual = newTestStore.get(newTestKey);
		// assert
		V expected = newTestValue;
		assertEquals(actual, expected);
	}
	
	/**
	 * {@link Store#clear()} の呼び出し時に、{@link Store} のエントリ数が
	 * 0 の場合、エントリ数は変化しません。
	 */
	@Test
	public void test_clear_clearEmptyStore() {
		// test
		newTestStore.clear();
		// assert
		int actual = newTestStore.size();
		int expected = 0;
		assertEquals(actual, expected);
	}
	
	/**
	 * {@link Store#clear()} の呼び出し時に、{@link Store}
	 * 内にエントリが存在する場合、 全エントリは削除され、エントリ数は 0 になります。
	 */
	@Test
	public void test_clear_clearNotEmptyStore() {
		// prepare
		K nextTestKey = this.createNewTestKey();
		V nextTestValue = this.createNewTestValue();
		newTestStore.put(newTestKey, newTestValue);
		newTestStore.put(nextTestKey, nextTestValue);
		
		// test
		newTestStore.clear();
		
		// assert
		V actualValue1 = newTestStore.get(newTestKey);
		V actualValue2 = newTestStore.get(nextTestKey);
		V expectedValue = null;
		assertEquals(actualValue1, expectedValue);
		assertEquals(actualValue2, expectedValue);
		
		// assert
		int actual = newTestStore.size();
		int expected = 0;
		assertEquals(actual, expected);
	}
	
	/**
	 * 空の {@link Store} から {@link Store#size()}
	 * を呼び出した場合、結果として 0 が返ります。
	 */
	@Test
	public void test_size_getSizeFromEmptyStore() {
		// test
		int actual = newTestStore.size();
		// assert
		int expected = 0;
		assertEquals(actual, expected);
	}
	
	/**
	 * 空ではない {@link Store} から {@link Store#size()}
	 * を呼び出した場合、結果としてエントリ数が返ります。
	 */
	@Test
	public void test_size_getSizeFromNotEmptyStore() {
		// prepare
		newTestStore.put(newTestKey, newTestValue);
		// test
		int actual = newTestStore.size();
		// assert
		int expected = 1;
		assertEquals(actual, expected);
	}
	
	/**
	 * {@link Store#remove(Object)} の呼び出し時に、引数 key が
	 * <code>null</code> の場合、{@link IllegalArgumentException}
	 * が throw されます。
	 */
	@Test
	public void test_remove_removeWithNullKey() {
		// test
		try {
			newTestStore.remove(null);
		} catch (IllegalArgumentException e) {
			return;
		}
		// assert
		fail();
	}
	
	/**
	 * {@link Store#remove(Object)} の呼び出し時に、{@link Store}
	 * 内にキーに該当するエントリが存在しない場合、戻り値として <code>null</code>
	 * が返されます。その際、エントリ数は変化しません。
	 */
	@Test
	public void test_remove_miss() {
		// prepare
		newTestStore.put(newTestKey, newTestValue);
		
		// test
		V actual = newTestStore.remove(createNewTestKey());
		// assert
		V expected = null;
		assertEquals(actual, expected);
		
		// test
		int actualSize = this.newTestStore.size();
		int expectedSize = 1;
		assertEquals(actualSize, expectedSize);
	}
	
	/**
	 * {@link Store#remove(Object)} の呼び出し時に、{@link Store}
	 * 内にキーに該当するエントリが存在しない場合、該当エントリが削除され、戻り値として以前の値が返されます。その際、エントリ数は
	 * 1 減少します。
	 */
	@Test
	public void test_remove_hit() {
		// prepare
		newTestStore.put(newTestKey, newTestValue);
		
		// test
		V actual = newTestStore.remove(newTestKey);
		// assert
		V expected = newTestValue;
		assertEquals(actual, expected);
		
		// test
		V actualValue = newTestStore.get(newTestKey);
		// assert
		V expectedValue = null;
		assertEquals(actualValue, expectedValue);
		
		// test
		int actualSize = newTestStore.size();
		// assert
		int expectedSize = 0;
		assertEquals(actualSize, expectedSize);
	}
	
}

以下は、上記テストの対象となるインタフェースです。

package jp.objectfanatics.ofcache.store;

/**
 * データを格納する入れ物です。データを識別するための「キー」と、キーに対応する「値」の組み合わせでデータを管理します。キーと値の組み合わせは「エントリ」と呼ばれます。
 * <p>
 * キーの同一性の判断は実装に依存しますが、多くの実装では {@link java.lang.Object#equals(Object)}
 * を用いて判断されます。
 * </p>
 * <strong>実装上の注意点</strong>
 * <ul>
 * <li>キーの検索にハッシュ関数を用いている実装では、キーの {@link java.lang.Object#hashCode()}
 * メソッドの実装がパフォーマンスに大きな影響を与えることがあります。</li>
 * <li>{@link Store#size()} は long を返しますが、{@link java.util.Map#size()} は int
 * 型を返すことに注意してください。</li>
 * 
 * @param <K>
 *            キーの型
 * @param <V>
 *            値の型
 * @author beyondseeker
 * @version $Id: Store.java 59 2008-05-19 15:49:07Z beyondseeker $
 */
public interface Store<K, V> {
	
	/**
	 * 指定されたキーと値を格納します。
	 * <p>
	 * 指定されたキーがすでに存在する場合は、キーと値の両方が上書きされます。
	 * </p>
	 * 
	 * @param key
	 *            キー(null不可)
	 * @param value
	 *            値(null不可)
	 * @return 以前の値、もしくは以前の値が存在しない場合は null
	 * @throws IllegalArgumentException
	 *             キーもしくは値が null の場合
	 */
	V put(K key, V value);
	
	/**
	 * すべてのエントリを削除します。
	 */
	void clear();
	
	/**
	 * 指定されたキーに対応する値を返します。
	 * 
	 * @param key
	 *            キー(null不可)
	 * @return 指定されたキーに対応する値、もしくは存在しない場合は null
	 * @throws IllegalArgumentException
	 *             キーが null の場合
	 */
	V get(K key);
	
	/**
	 * この Store 内のエントリ数を返します。
	 * 
	 * @return この Store 内のエントリ数。 {@link Integer#MAX_VALUE} 以上の場合は、{@link Integer#MAX_VALUE} を返します。
	 */
	int size();
	
	/**
	 * キーに対応するエントリを削除します。
	 * 
	 * @param key
	 *            キー(null不可)
	 * @return 以前の値、もしくは以前の値が存在しない場合は null
	 * @throws IllegalArgumentException
	 *             キーが null の場合
	 */
	V remove(K key);
	
}