ちょっと話題の記事

[Android Tips] AsyncTaskLoaderをTestする

2014.03.24

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

AsyncTaskLoaderをテストしたい

非同期処理はテストしづらいですね。 AsyncTaskLoaderのテストをする際にはまったのでメモしておきます。いわゆる俺得エントリー

準備するもの

レシピには以下のものが必要になります

  • AsyncTaskLoaderのクラスを使った非同期処理があるAndroidプロジェクト
  • テストしたいプロジェクトを対象にしたAndroid Testプロジェクト
  • 諦めない心

今回利用するソースコードはこちら。 AsyncTaskSampleとAsyncTaskSampleTestの2つのプロジェクトを利用します

今回は、文字列から緯度経度を検索する非同期処理を行う簡単なAndroidプロジェクトを対象に、テストプロジェクトを作成します。AsyncTaskLoaderの拡張であるGeocoderLoaderは以下の通りです。

/**
 * 住所や施設名などから緯度経度情報を取得する、Geocoder#getFromLocationName()
 * を非同期で実施するAsyncTaskLoaderクラス。
 * 
 */
public class GeocoderLoader extends AsyncTaskLoader<LatLng> {

    private Context context;
    private String string;

    /**
     * Constructor
     * 
     * @param context {@link Context}
     * @param str 検索する住所
     */
    public GeocoderLoader(Context context, String string) {
        super(context);

        this.context = context;
        this.string = string;

    }

    /** LatLngを返却 */
    @Override
    public LatLng loadInBackground() {

        LatLng latLng = null;

        // 検索バーからの住所検索処理
        Geocoder geocoder = new Geocoder(context, Locale.getDefault());

        try {
            // 住所を1件取得する
            List<Address> addressList;
            addressList = geocoder.getFromLocationName(string, 1);
            Address address = addressList.get(0);

            double lat = address.getLatitude();
            double lng = address.getLongitude();

            // 取得した住所からLatLng作成
            latLng = new LatLng(lat, lng);
            
        } catch (IOException e) {
            e.printStackTrace();
            Log.d("", "geocoder Error: " + e.toString());
		}
        return latLng;
    }
}

LatLngクラスは、元々Google Play Servicesの中にあるクラスですが、面倒なので簡易的なクラスを作成しました。気を付けておくのは、ここで利用するAsyncTaskLoaderはandroid.support.v4.content.AsyncTaskLoaderのものです

public class LatLng {
	public double lat;
	public double lng;
	
	public LatLng(double lat, double lng) {
		this.lat = lat;
		this.lng = lng;
	}
	
	@Override
	public String toString() {
		return "lat:" + lat + ", lng:" + lng;
	}
}

テストプロジェクト

さて、ここからが本番です。上記のGeocoderLoaderをテストしていきましょう。 まずは素直に書いてみます

NGパターン1

/**
 * GeocoderLoaderをテストする
 * @author komuro.hiraku
 *
 */
public class TestGeocoderLoader extends
		ActivityInstrumentationTestCase2<MainActivity> {
	
	private MainActivity mActivity;

	public TestGeocoderLoader(Class<MainActivity> activityClass) {
		super(activityClass);		
	}
	
	public TestGeocoderLoader() {
		super(MainActivity.class);
	}
	
	@Override
	protected void setUp() throws Exception {
		super.setUp();
		
		// Activityを取得
		mActivity = getActivity();
	}

	/**
	 * Geocoderで「東京タワー」の緯度経度を検索する処理をテストする
	 * @throws InterruptedException 
	 */
	public void testGeocoder正常_東京タワー() throws InterruptedException {
		
		// 検索対象文字列
		final String TARGET = "東京タワー";
		
		// Loaderの結果を受けるためのCallbackを作成
		final LoaderCallbacks<LatLng> callback = new LoaderCallbacks<LatLng>() {

			@Override
			public Loader<LatLng> onCreateLoader(int id, Bundle args) {
				// GeocoderLoaderを作成
				return new GeocoderLoader(mActivity, TARGET);
			}

			@Override
			public void onLoadFinished(Loader<LatLng> loader, LatLng data) {
				// 東京タワーの位置は「35.6585805, 139.7454329」
				LatLng expect = new LatLng(35.6585805, 139.7454329);
			assertEquals("東京タワーの位置が合致しました", expect.toString(), data.toString());
			}

			@Override
			public void onLoaderReset(Loader<LatLng> loader) {
				// Resetは今回は考慮しない
			}
		};
		
		// Loaderを作成
		LoaderManager lm =  mActivity.getLoaderManager();
		Loader<LatLng> loader = lm.initLoader(0, null, callback);
		
		// Loaderを実行
		loader.forceLoad();
	}
}

これは当然NGパターンです。なぜなら、assertEqualsの期待値の値を何に変えてもグリーンでテストを通過してしまいます。 これは、loaderの起動直後にメソッドから抜けてしまうため、何もAssertせずにメソッドを無事通過してしまうためです。 onLoadFinishedを実行しようとしたときには、すでにテスト後のため、当然処理の結果は受け取れません。 きちんと処理完了まで待ってあげましょう

NGパターン2

非同期通信完了まで、待つためにCountDownLatchを導入します。 詳しくはこちらを参照してください。 非同期処理の待ち合わせ機構です。

/**
 * GeocoderLoaderをテストする
 * @author komuro.hiraku
 *
 */
public class TestGeocoderLoader extends
		ActivityInstrumentationTestCase2<MainActivity> {
	
	private MainActivity mActivity;

	public TestGeocoderLoader(Class<MainActivity> activityClass) {
		super(activityClass);		
	}
	
	public TestGeocoderLoader() {
		super(MainActivity.class);
	}
	
	@Override
	protected void setUp() throws Exception {
		super.setUp();
		
		// Activityを取得
		mActivity = getActivity();
	}

	/**
	 * Geocoderで「東京タワー」の緯度経度を検索する処理をテストする
	 * @throws InterruptedException 
	 */
	public void testGeocoder正常_東京タワー() throws InterruptedException {
		
		// 非同期処理を待つためのLatch。
		// 待機するのは非同期処理一本だけなので引数は1で作成
		final CountDownLatch latch = new CountDownLatch(1);
		
		// 検索対象文字列
		final String TARGET = "東京タワー";
		
		// Loaderの結果を受けるためのCallbackを作成
		final LoaderCallbacks<LatLng> callback = new LoaderCallbacks<LatLng>() {

			@Override
			public Loader<LatLng> onCreateLoader(int id, Bundle args) {
				// GeocoderLoaderを作成
				return new GeocoderLoaderV4(mActivity, TARGET);
			}

			@Override
			public void onLoadFinished(Loader<LatLng> loader, LatLng data) {
				// 東京タワーの位置は「35.6585805, 139.7454329」
				LatLng expect = new LatLng(35.6585805, 139.7454329);
				assertEquals("東京タワーの位置が合致しました", expect.toString(), data.toString());
				
				// 非同期通信が完了したので、処理を続行
				latch.countDown();
			}

			@Override
			public void onLoaderReset(Loader<LatLng> loader) {
				// Resetは今回は考慮しない
			}
		};
		
		// Loaderを作成
		LoaderManager lm =  mActivity.getSupportLoaderManager();
		Loader<LatLng> loader = lm.initLoader(0, null, callback);
		
		// Loaderを実行
		loader.forceLoad();
		
		// 非同期通信が完了するまで10秒待機
		boolean res = latch.await(10, TimeUnit.SECONDS);
		assertEquals("通信完了", true, res);
	}
}

CountDownLatchを導入し、onLoadFinishedまで待機するようにしました。 一応、10秒間のタイムアウトを設定しています。

さて、これで一見いいように思えるのですが、実行してみると失敗します。

TestFailed_1

TestFailed_detail

失敗しました。 どうやらonLoadFinishedが呼ばれずにlatch.await()がTimeoutしているようです

メインスレッドがawait()でブロックされているため、メインスレッドのキューがいつまでも実行されずにブロックされているのではないかと推測しました。 さらに別スレッドで実行させるよう試みてみます。

OKパターン

結論としては、前述した推測が正解だったようです。 v4のAsyncTaskLoaderの動作をテストしたい場合は、下記のように記述すればOKです

/**
 * Geocoderで「東京タワー」の緯度経度を検索する処理をテストする
 * @throws InterruptedException 
 */
public void testGeocoder正常_東京タワー() throws InterruptedException {
	
	// 非同期処理を待つためのLatch。
	// 待機するのは非同期処理一本だけなので引数は1で作成
	final CountDownLatch latch = new CountDownLatch(1);
	
	// 検索対象文字列
	final String TARGET = "東京タワー";
	
	// Loaderの結果を受けるためのCallbackを作成
	final LoaderCallbacks<LatLng> callback = new LoaderCallbacks<LatLng>() {

		@Override
		public Loader<LatLng> onCreateLoader(int id, Bundle args) {
			// GeocoderLoaderを作成
			return new GeocoderLoaderV4(mActivity, TARGET);
		}

		@Override
		public void onLoadFinished(Loader<LatLng> loader, LatLng data) {
			// 東京タワーの位置は「35.6585805, 139.7454329」
			LatLng expect = new LatLng(35.6585805, 139.7454329);
			assertEquals("東京タワーの位置が合致しました", expect.toString(), data.toString());
			
			// 非同期通信が完了したので、処理を続行
			latch.countDown();
		}

		@Override
		public void onLoaderReset(Loader<LatLng> loader) {
			// Resetは今回は考慮しない
		}
	};
	
	mActivity.runOnUiThread(new Runnable(){

		@Override
		public void run() {
			// Loaderを作成
			LoaderManager lm =  mActivity.getSupportLoaderManager();
			Loader<LatLng> loader = lm.initLoader(0, null, callback);
			
			// Loaderを実行
			loader.forceLoad();
		}
	});
	
	
	// 非同期通信が完了するまで10秒待機
	boolean res = latch.await(10, TimeUnit.SECONDS);
	assertEquals("通信完了", true, res);
}

ActivityのrunOnUiThread上で別スレッドとして呼び出す必要があります。 こうするときちんとonLoadFinishedまで通過し、テストが無事完了します。

完了! TestSuccess_1

AsyncTaskLoaderをテストする時のポイント

ポイントは、AsyncTaskLoaderが非同期処理であることを意識することです。 「そんなの当たり前だろ!」と方々から矢が飛んできそうですが、意外と忘れがちです。

また、Support LibraryのAsyncTaskLoaderAsyncTaskLoaderクラスかでも動作が大きく異なるので注意しましょう。

まとめ

なぜか、NGパターン2の実装で動かなかったので、不思議に思って色々調べた結果です。AsyncTaskLoaderをテストする場合は色々注意が必要のようです。 日本語の情報がさっぱり載っていなかったので、みなさんどのようにテストしているのでしょうか。 当たり前すぎて出てこない情報なんでしょうか。

さらに実験してる最中に気づきましたが、v4パッケージのAsyncTaskLoaderと通常のAsyncTaskLoaderでは動作が違うようです。 これはまた別エントリで解説しようと思います

それでは皆様ごきげんよう

参考