遥かへのスピードランナー

シリコンバレーでAndroidアプリの開発してます。コンピュータービジョン・3D・アルゴリズム界隈にもたまに出現します。

AndroidでContentProviderのモックを使ったテストを行う

ContentProviderからデータを取得しているアプリのテストコードを書くときに、テストデータとして端末内のデータを使わずにモックのデータを使いたいということは多いと思います。
今回は端末内の画像を一覧表示するアプリケーションを例にして、このようなテストコードの書き方を説明します。

テスト対象のActivity

以下のようなActivityをテストすることを考えます。

package com.polysfactory.mocktest;

import android.app.ListActivity;
import android.database.Cursor;
import android.os.Bundle;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Images.ImageColumns;
import android.widget.ArrayAdapter;

public class MyActivity extends ListActivity {
	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		Cursor cursor = getContentResolver().query(Images.Media.EXTERNAL_CONTENT_URI,
		        new String[] { ImageColumns.TITLE }, null, null, null);
		if (cursor != null) {
			String[] names = new String[cursor.getCount()];
			int i = 0;
			while (cursor.moveToNext()) {
				names[i++] = cursor.getString(0);
			}
			cursor.close();
			setListAdapter(new ArrayAdapter<String>(this,
			        android.R.layout.simple_expandable_list_item_1, names));
		}
	}
}

見ての通り、Adnroid標準のContentProviderであるMediaStoreから画像コンテンツの情報を全て取得して、タイトルをListViewの表示させるだけのActivityです。
このActivityを起動すると以下のような画面が表示されます。

こいつのテストをするとなると、Listの1件目にxxxxという画像名称が表示されていることを確認したい、というケースが出てきます。

当然、端末ごとに保存されている画像は異なりますが、極力端末に依存させないよう、Activityからはダミーのデータにアクセスさせて、それが表示されているかをテストする必要があるわけです。

テストプロジェクトの概要

今回のテストでは、MediaStoreのダミーとなるContentProviderを自作します。そしてMediaStoreのContent URIを指定された場合にそのダミーを見にいくようなContentResolverとContextのモックを作成し、Activityにインジェクトします。
そうすることでActivity側のコードを変えることなく、端末のデータベースを参照せずに、モックとして用意したデータを使ってテストを行うことが出来るようになるのです。

超適当クラス図にするとこんな感じです。

以下それぞれのクラスについて説明していきます。

ContentProviderのモックを準備する

いきなり肝ですが、MediaStoreのダミーとして動作するContentProviderのモックを用意してあげます。これはContentProviderを継承したクラスを自作します。
ここでのポイントはqueryメソッドをオーバーラードするときに、データベースから取得したCursorを返さずに、MatrixCursorクラスを使って仮想のカーソルを返してあげるところです。

public class MockImagesProvider extends ContentProvider {
    ....
	@Override
	public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
	        String sortOrder) {
		MatrixCursor result = new MatrixCursor(projection);
		String[] values = new String[projection.length];
		for (int i = 0; i < projection.length; i++) {
                    ...
                    values[i] = "hogehoge";
                    ...
		}
		result.addRow(values);
		return result;
	}
    ...
}

これによって、テストデータをデータベースから読まこませず、ファイルから読み込んだ値などを返すことができます。
今回はサンプルのため、テストデータをソース中に書き込みます。以下、このクラスの全体ソースです。

public class MockImagesProvider extends ContentProvider {

	private static final String[] IMAGE_COLUMNS = { ImageColumns._ID, ImageColumns._COUNT,
	        ImageColumns.DATA, ImageColumns.DATE_ADDED, ImageColumns.DATE_MODIFIED,
	        ImageColumns.DISPLAY_NAME, ImageColumns.SIZE, ImageColumns.TITLE };

	private static final String[] VALUE1 = { "1", "1", "/sdcard/hoge.jpg", "1000", "1000",
	        "display_name_test", "100", "title_test" };

	public static final int IMAGE = 1;

	public static final int IMAGE_ID = 2;

	@Override
	public boolean onCreate() {
		return true;
	}

	@Override
	public Uri insert(Uri uri, ContentValues values) {
		return null;
	}

	@Override
	public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs,
	        String sortOrder) {
		MatrixCursor result = new MatrixCursor(projection);
		String[] values = new String[projection.length];
		for (int i = 0; i < projection.length; i++) {
			for (int j = 0; j < IMAGE_COLUMNS.length; j++) {
				if (IMAGE_COLUMNS[j].equals(projection[i])) {
					values[i] = VALUE1[j];
				}
			}
		}
		result.addRow(values);
		return result;

	}

	@Override
	public int delete(Uri uri, String selection, String[] selectionArgs) {
		return 0;
	}

	@Override
	public String getType(Uri uri) {
		return null;
	}

	@Override
	public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
		return 0;
	}
}

CotentResolverのモックを作成する

第2の肝です。MediaStoreから画像の情報を取得するときのURIはcontent://media/external/imagesというURIになります。
ContentResolverのモックを作成して、content://mediaのURIに対しては、先ほど作成したMockImagesProviderにルーティングするように実装します。

public class MyMockContentResolver extends MockContentResolver {

	public MyMockContentResolver(Context context) {
		ContentProvider provider = new MockImagesProvider();
		ProviderInfo providerInfo = new ProviderInfo();
		providerInfo.authority = "media";
		providerInfo.enabled = true;
		providerInfo.isSyncable = false;
		providerInfo.packageName = MockImagesProvider.class.getPackage().getName();
		provider.attachInfo(context, providerInfo);
		super.addProvider("media", provider);
	}
}

Contextのモックを作成する

ActivityにセットするためのContextのモックを作成します。
ContextWrapperを継承して、getContentResolver()だけをオーバーライドしてあげることで、ContentResolverを取得する部分だけをモックの動作に置き換えることができます。

public class MyMockContext extends ContextWrapper {

	private ContentResolver contentResolver;

	public MyMockContext(Context context) {
		super(context);
		contentResolver = new MyMockContentResolver(context);
	}


	@Override
	public ContentResolver getContentResolver() {
		return contentResolver;
	}

}

テストケース

最後にテストを実行する部分のコードです。ActivityのテストはActivityInstrumentationTestCase2が使われることが多いですが、ActivityInstrumentationTestCase2はテスト対象のActivityに対するContextのインジェクションができないという制約があるため、代わりにActivityUnitTestCaseを使います。
ActivityUnitTestCase.setActivityContextメソッドを使うことで、先ほど作ったMockContextをActivityにセットすることが出来ます。

public class MockTestSample extends ActivityUnitTestCase<MyActivity> {

	public MockTestSample() {
		super(MyActivity.class);
	}

	@Smoke
	public void testSample() {
		Context mockContext = new MyMockContext(getInstrumentation().getTargetContext());
		setActivityContext(mockContext);
		startActivity(new Intent(), null, null);
		final MyActivity activity = getActivity();
		ListView listView = activity.getListView();
		int count = listView.getCount();
		String item0 = (String)listView.getAdapter().getItem(0);

		assertNotNull(activity);
                //表示されているデータが1件であることを確認する
		assertEquals(1, count);
                //表示されている画像名がtitle_testであることを確認する
		assertEquals("title_test", item0);
	}
}

テスト実行

これでテストを実行してみます。

見事、成功です。端末のデータベースではなくモックのデータを読み込んでテストを実行してくれました。

ソースコード

GitHubに上げてるので適当に持っていってください。

まとめ

ContentResolver、ContentResolver、Contextという長い道のりを経て、モックのデータでActivityのテストを行う方法について書きました。
これで何が嬉しいかといういうと、端末ごとのデータに依存せずにテストを実行できることです。一度テストデータとコードを書いてしまえば、どんな機種でも実行できるようにようになるので、品質を上げるにに必ず役立つと思ってます。