2011年4月3日日曜日

AndroidのOnClickListenerをテストしよう

最近、Androidの本、よく売れていますね。

本屋でもJavaのコーナーよりも場所が広かったりします。

で、それらに掲載されているコードに文句をつけるつもりは毛頭ございませんが、

書いてあるのをそのまま参考にすると、テストで痛い目を見ます。

では、まずダメなアプリの例から。


package orz.mikeneck;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import  android.widget.Button;

public class FirstActivity extends Activity {

 /**
  * @see android.app.Activity#onCreate(android.os.Bundle)
  */
 @Override
 protected void onCreate(Bundle savedInstanceState) {
  // Setting layout to a display.
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);

  // Adding onClickListener to a button.
  Button button = (Button) findViewById(R.id.button_id);
  button.setOnClickListener(

   new View.onClickListener() {
    public void onClick(View v){

     // If this button is pressed, new activity will be launched.
     Intent intent = new Intent(this, NextActivity.class);
     startActivity(intent);
    }
   }
  );
 }
}


ダメであるという理由は、次のテストが実施できないからです。

・ボタンを押したときに、指定したActivityが起動すること

なぜテストが実施できないかというと、

オブジェクトButtonというよりはViewには、
setOnClickListener(android.view.View.OnClickListener)はあっても、
getOnClickListener()がないため、
ボタン(View)を押すという操作をテストから実行することができないからです。

一般的な開発工程のあり方としては、単体テストでバグを摘出するほうが、結合試験以降でバグを抽出することよりもコストが低いと言われています。

結合テストまでこのバグが潜んでしまっていた場合、どのクラスによって発生したのか切り分ける必要があるため、念入りに調査することが必要になります。

プログラマーで勘の良い人はだいたいどの辺にバグがあるのかを検出するのが速いわけですが、誰しもがそうではないわけで、こんな単純なバグが原因だったのかと呆れてしまうこともあったりします。

私もSIに務めているので、そのようなことはよくあったりします。

なんか画面のボタンを押したときに、条件を限定しているわけではないのに、なにか限定された結果が得られて、どのような条件でどのような操作をするとバグが発生するのか、詳細にバグ票に書かなければならないので、結構しんどかったりします。

で、その結果、実はボタンを押したあとの挙動にハードコーディングがしてあったとかよくありました。

…最近は上流のテストはやっていないので、そんなことはなくなったのですが…

話を戻すと、View.onClickListenerはせっかくinterfaceなのだから、それを有効活用しない手はありません。

というわけで、以下がまだましな例。
FirstActivity.java

package orz.mikeneck;

import android.app.Activity;
import android.os.Bundle;
import android.view.View.OnClickListener;
import  android.widget.Button;

public class FirstActivity extends Activity {

 /**
  * @see android.app.Activity#onCreate(android.os.Bundle)
  */
 @Override
 protected void onCreate(Bundle savedInstanceState) {
  // Setting layout to a display.
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);

  // Getting instance of OnClickListener.
  View.onClickListener listen = new OnClickListenerImpl();
  listener.setPreContext(this);
  listener.setNextContext(NextActivity.class);

  // Adding onClickListener to a button.
  Button button = (Button) findViewById(R.id.button_id);
  button.setOnClickListener(listener);
 }
}


ここで、先程の例ではActivity内にあったFirstActivityの中にあった、
View.onClickListenerの実装が外に出されました。

で、これが外に抽出されたクラスOnClickListenerImpl.javaです。

package orz.mikeneck;

import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.view.View.OnClickListener;

public class OnClickListenerImpl implements OnClickListener {

 /**
  * Previous Context listening user motion. 
  */
 private Context preContext;

 /**
  * Next Context to be launched.
  */
 private Class nextContext;

 /**
  * If the button is pressed, the next Activity will be launched by this method.
  * And, this method can be called by external classes.
  */
 @Override
 public void onClick(View v) {
  Intent intent = new Intent(preContext, nextContext);
  preContext.startActivity(intent);
 }

 public void setPreviousContext(Context preContext) {
  this.preContext = preContext;
 }

 public void setNextContext(Class nextContext) {
  this.nextContext = nextContext;
 }


}


そうすると、これである特定の画面から、次の画面を呼び出すという挙動のテストができるようになります。
以下はテストコード。
OnClickListenerImplTest.java

package orz.mikeneck.test;

import android.content.Intent;
import android.test.mock.MockContext;
import junit.framework.TestCase;
import orz.mikeneck.OnClickListenerImpl;

public class OnClickListenerImplTest extends TestCase {

 private OnClickListenerImpl listener;

 private boolean isTestOnClickPassed;

 @Override
 public void setUp(){
  this.isTestOnClickPassed = false;
  listener = new OnClickListenerImpl();
  PreContext preContext = new PreContext();
  listener.setPreviousContext(preContext);
  listener.setNextContext(NextContext.class);
 }

 /**
  * Click the button(fake).
  */
 public void testOnClick(){
  listener.onClick(null);
  assertTrue(isTestOnClickPassed);
 }

 private class PreContext extends MockContext{

  /**
   * @see android.test.mock.MockContext#startActivity(android.content.Intent)
   */
  @Override
  public void startActivity(Intent intent) {
   super.startActivity(intent);
   isTestOnClickPassed = true;
  }
  
 }

 private class NextContext extends MockContext{ 
 }
}


実はView.onClickListenerのメソッドonClick(View v)は、
パブリックメソッドなので、クラスの外から呼び出すことが可能です。

したがって、テストクラスでは堂々とlistener.onClick(null)と呼び出していますね。



ただし、ここからは私のうっかりミスが発覚します。
これをそのままテスト実行すると、もれなく


java.lang.UnsupportedOperationException
at android.test.mock.MockContext.getPackageName(MockContext.java:101)
at android.content.ComponentName.(ComponentName.java:75)
at android.content.Intent.(Intent.java:2551)
at orz.mikeneck.OnClickListenerImpl.onClick(OnClickListenerImpl.java:52)
at orz.mikeneck.test.OnClickListenerImplTest.setUp(OnClickListenerImplTest.java:28)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:169)
at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:154)
at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:430)
at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1447)


とかなります。

う~ん、前々から気にはしていたんですがね、android.test.mock.MockContextは、
どうも暴れん坊大将軍様で、呼び出すメソッドすべてがUnSupportedException
返してくれるという素敵仕様になっております。
したがって、テストを通すまでかなりの道のりを経なくてはならない化物なのでございます。

で、今回はExceptionの発生しているメソッドのオーバーライドもしました。

 private class PreContext extends MockContext{

  /**
   * @see android.test.mock.MockContext#startActivity(android.content.Intent)
   */
  @Override
  public void startActivity(Intent intent) {
   isTestOnClickPassed = true;
  }

  /**
   * Added method.
   * @see android.test.mock.MockContext#getPackageName()
   */
  @Override
  public String getPackageName() {
   return "orz.mikeneck.test";
  }
  
 }


これで、見事にテストが通りました。

というわけで、ある特定のActivityから指定したActivityを起動することが出来ているという確認が取れるようになりました。



はい、妙に長ったらしい解説でしたが、

Activity内部でinterfaceの実装を記述しないこと


が今回言いたかったことです。


書いていたら、22時過ぎてしまった…orz

1 件のコメント:

  1. Thank you, this has been very helpful. The Google translation from Japanese to English (though awful) was just good enough with the code to help. :-)

    返信削除