1. はじめに
以前公開したORMLiteを使ったアドレス帳アプリandropenguin/AddressBookORMliteをテストしやすいように書き直し、Espressoでテストを行います。
住友孝郎@cattaka_net氏のスライド開発を効率的に進めるられるまでの道程を読んで、データベース、Adapterがあるアプリで、どうテストしやすく書き換えるか、どうテストするか学びました。
上記スライドによると、データベース、Adapter、プリファレンスの単体テストを行い、また、Activityのテスト、つまりUIのテストは、データベース、Adapter、プリファレンスをダミーに置き換えてテストを行うと、いいそうです。上記スライドは、テストのやり方の概要を紹介するもので、実際のテストの仕方は、cattaka/FastCheckListのサンプルを読んで学びました。
以前公開したアドレス帳アプリAddressBookORMliteをテストしやすいように、adapter、app、asynctask、core、db、entityのサブパッケージを作ります。以前のアプリでは、adapterやdbに関するコードは、アクティビティに書いていましたし、また、DBから全件取得するコードを書いていたにも関わらず、AsyncTaskLoaderを使用していなかったので、asynctaskのサブパッケージも導入しました。あと、coreサブパッケージにあるクラスは、住友孝郎@cattaka_net氏が導入しているものです。
2. プロダクションコード
プロダクションコードの各クラスを見ていきます。
2.1 entityサブパッケージ
PersonクラスとAddressクラスは以下の通りです。多分内容は以前と変わっていないと思います。
Person.java
package com.sarltokyo.addressbookormlite7.entity; import com.j256.ormlite.field.DatabaseField; import com.j256.ormlite.table.DatabaseTable; @DatabaseTable(tableName = "person") public class Person { @DatabaseField(generatedId = true) private Integer id; @DatabaseField(unique = true) private String name; @DatabaseField(foreign = true, foreignAutoCreate = true, foreignAutoRefresh = true) private Address address; public Person() { } public Integer getId() { return id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public Address getAddress() { return address; } public void setAddress(Address address) { this.address = address; } }
Address.java
package com.sarltokyo.addressbookormlite7.entity; import com.j256.ormlite.field.DatabaseField; import com.j256.ormlite.table.DatabaseTable; @DatabaseTable(tableName = "address") public class Address { @DatabaseField(generatedId = true) private Integer id; @DatabaseField private String zipcode; @DatabaseField private String prefecture; @DatabaseField private String city; @DatabaseField private String other; public Address() { } public Integer getId() { return id; } public String getZipcode() { return zipcode; } public void setZipcode(String zipcode) { this.zipcode = zipcode; } public String getPrefecture() { return prefecture; } public void setPrefecture(String prefecture) { this.prefecture = prefecture; } public String getCity() { return city; } public void setCity(String city) { this.city = city; } public String getOther() { return other; } public void setOther(String other) { this.other = other; } }
2.2 dbサブパッケージ
データベースに関するコードは次の通りです。以前は、CRUDのコードを他のクラスに書いていましたが、テストしやすいように、OpenHelperクラスに書きました。
OpenHelper.java
package com.sarltokyo.addressbookormlite7.db; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.util.Log; import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper; import com.j256.ormlite.dao.Dao; import com.j256.ormlite.support.ConnectionSource; import com.j256.ormlite.table.TableUtils; import com.sarltokyo.addressbookormlite7.entity.Address; import com.sarltokyo.addressbookormlite7.entity.Person; import java.sql.SQLException; import java.util.List; public class OpenHelper extends OrmLiteSqliteOpenHelper { private final static String TAG = OpenHelper.class.getSimpleName(); private final static String DATABASE_NAME = "addressbook.db"; private final static int DATABASE_VERSION = 1; private Dao<Person, Integer> mPersonDao; private Dao<Address, Integer> mAddressDao; public OpenHelper(Context context) { this(context, DATABASE_NAME); } public OpenHelper(Context context, String name) { super(context, name, null, DATABASE_VERSION); } @Override public void onCreate(SQLiteDatabase database, ConnectionSource connectionSource) { try { // エンティティを指定してcreate tableする TableUtils.createTable(connectionSource, Person.class); TableUtils.createTable(connectionSource, Address.class); } catch (SQLException e) { Log.e(TAG, "cannot create database"); Log.e(TAG, e.getMessage()); } } @Override public void onUpgrade(SQLiteDatabase database, ConnectionSource connectionSource, int oldVersion, int newVersion) { try { TableUtils.dropTable(connectionSource, Address.class, true); TableUtils.dropTable(connectionSource, Person.class, true); onCreate(database); } catch (SQLException e) { Log.e(TAG, "cannot upgrade database"); Log.e(TAG, e.getMessage()); } } public Dao<Person, Integer> getPersonDao() throws SQLException { if (mPersonDao == null) { mPersonDao = getDao(Person.class); } return mPersonDao; } public Dao<Address, Integer> getAddressDao() throws SQLException { if (mAddressDao == null) { mAddressDao = getDao(Address.class); } return mAddressDao; } public Person findPerson(String name) throws SQLException { List<Person> persons = getPersonDao().queryForEq("name", name); if (persons.isEmpty()) { Log.d(TAG, "persons isEmpty"); return null; } else { Log.d(TAG, "person exists"); return persons.get(0); } } public List<Person> findPerson() throws SQLException { return getPersonDao().queryForAll(); } public void registerPerson(Person person) throws SQLException { getPersonDao().create(person); } public void updatePerson(Person person) throws SQLException { Address address = person.getAddress(); getAddressDao().update(address); // todo getPersonDao().update(person); } public boolean deletePerson(String name) throws SQLException { Person person = findPerson(name); if (person == null) { return false; } else { Address address = person.getAddress(); // todo getAddressDao().delete(address); getPersonDao().delete(person); return true; } } }
2.3 coreサブパッケージ
住友孝郎@cattaka_net氏のコードを使用しています。
2.4 asynctaskサブパッケージ
AsyncTaskLoaderはほぼ定番コードを使用するので、Android の非同期処理を行う Loader の起動方法で入手した、absractなAsyncTaskLoaderを使用します。下記コードで、使用ケースに応じて、Tにクラス名を書いて使用します。
AbstractAsyncTaskLoader.java
package com.sarltokyo.addressbookormlite7.asynctask; import android.content.Context; import android.support.v4.content.AsyncTaskLoader; /** * Android の非同期処理を行う Loader の起動方法 * http://tomoyamkung.net/2014/02/24/android-loader-execute/ */ public abstract class AbstractAsyncTaskLoader<T> extends AsyncTaskLoader<T> { protected T mResult; public AbstractAsyncTaskLoader(Context context) { super(context); } abstract public T loadInBackground(); @Override public void deliverResult(T data) { if (isReset()) { return; } mResult = data; if (isStarted()) { super.deliverResult(data); } } @Override protected void onStartLoading() { if (mResult != null) { deliverResult(mResult); } if (takeContentChanged() || mResult == null) { forceLoad(); // 非同期処理を開始 } } @Override protected void onStopLoading() { cancelLoad(); // 非同期処理のキャンセル } @Override public void onCanceled(T data) { // nop } @Override protected void onReset() { super.onReset(); onStopLoading(); mResult = null; } }
実際に使用するPersonLoader.javaは以下の通りです。上記AbstractAsyncTaskLoaderクラスで、TをList
PersonLoader.java
import java.util.List; public class PersonLoader extends AbstractAsyncTaskLoader<List<Person>> { private final static String TAG = PersonLoader.class.getSimpleName(); private ContextLogic mContextLogic; private OpenHelper mOpenHelper; public PersonLoader(Context context) { super(context); mContextLogic = ContextLogicFactory.createContextLogic(context); mOpenHelper = mContextLogic.createOpenHelper(); } @Override public List<Person> loadInBackground() { return getPersons(); } public List<Person> getPersons() { List<Person> persons = null; try { persons = mOpenHelper.findPerson(); } catch (SQLException e) { Log.e(TAG, e.getMessage()); } if (persons == null) { persons = new ArrayList<Person>(); } if (persons.size() == 0) { Log.d(TAG, "person is empty"); } for (Person person: persons) { Log.d(TAG, "person = " + person.getName()); } return persons; } }
2.5 appサブパッケージ
アクティビティを置いたサブパッケージです。
MainActivity.java
package com.sarltokyo.addressbookormlite7.app; import android.app.ProgressDialog; import android.content.Intent; import android.support.v4.app.LoaderManager; import android.support.v4.content.Loader; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.AdapterView; import android.widget.ListView; import android.widget.Toast; import com.sarltokyo.addressbookormlite7.adapter.AdapterEx; import com.sarltokyo.addressbookormlite7.asynctask.PersonLoader; import com.sarltokyo.addressbookormlite7.core.ContextLogic; import com.sarltokyo.addressbookormlite7.core.ContextLogicFactory; import com.sarltokyo.addressbookormlite7.db.OpenHelper; import com.sarltokyo.addressbookormlite7.entity.Person; import java.sql.SQLException; import java.util.List; public class MainActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<List<Person>> { private final static String TAG = MainActivity.class.getSimpleName(); private ListView mListView; private OpenHelper mOpenHelper; public final static String TYPE = "type"; public final static String CREATE_DATA_TYPE = "create"; public final static String UPDATE_DATA_TYPE = "update"; private AdapterEx mPersonsAdapter; private ContextLogic mContextLogic; private ProgressDialog mProgressDialog; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // todo: ここ重要。DBをproduction用、test用に切り替えられるようにする。 mContextLogic = ContextLogicFactory.createContextLogic(this); mOpenHelper = mContextLogic.createOpenHelper(); } @Override protected void onResume() { super.onResume(); Log.d(TAG, "onResume called"); showProgress(); LoaderManager manager = getSupportLoaderManager(); if (manager.getLoader(0) != null) { manager.destroyLoader(0); } manager.initLoader(0, null, this); } @Override public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.menu_main, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { // Handle action bar item clicks here. The action bar will // automatically handle clicks on the Home/Up button, so long // as you specify a parent activity in AndroidManifest.xml. int id = item.getItemId(); //noinspection SimplifiableIfStatement if (id == R.id.action_settings) { return true; } else if (id == R.id.input_add) { Intent intent = new Intent(MainActivity.this, RegisterActivity.class); intent.putExtra(TYPE, CREATE_DATA_TYPE); startActivity(intent); return true; } return super.onOptionsItemSelected(item); } private void showProgress() { Log.d(TAG, "showProcess()"); mProgressDialog = new ProgressDialog(this); mProgressDialog.setMessage("running"); mProgressDialog.show(); } private void dismissProgress() { Log.d(TAG, "dismissProgress()"); mProgressDialog.dismiss(); } @Override public Loader<List<Person>> onCreateLoader(int id, Bundle bundle) { return new PersonLoader(this); } @Override public void onLoadFinished(Loader<List<Person>> loader, List<Person> data) { Log.d(TAG, "PersonLoader finished"); dismissProgress(); if (data != null) { mPersonsAdapter = new AdapterEx(this, data); mListView = (ListView)findViewById(android.R.id.list); mListView.setAdapter(mPersonsAdapter); mListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { updateAddress(view); } }); mListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { @Override public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { return deletePerson(view); } }); } else { // todo Toast.makeText(this, "Some error occured.", Toast.LENGTH_LONG).show();; } LoaderManager manager = getSupportLoaderManager(); // 既にローダーがある場合は破棄 if (manager.getLoader(0) != null) { manager.destroyLoader(0); } } @Override public void onLoaderReset(Loader<List<Person>> loader) { // nop } private void updateAddress(View view) { Log.d(TAG, "updateAddress is called"); int positon; String name = null; if (!(view.getTag() instanceof Integer)) { return; } positon = (Integer)view.getTag(); if (positon < 0 | mPersonsAdapter.getCount() <= positon) { return; } Log.d(TAG, "position in updateAddress = " + positon); Person person = mPersonsAdapter.getItem(positon); Log.d(TAG, "person in updateAddress = " + person); try { name = mOpenHelper.findPerson(person.getName()).getName(); Log.d(TAG, "name in updateAddress = " + name); } catch (SQLException e) { Log.e(TAG, e.getMessage()); } if (name == null) return; // todo: 多分、ありえない Intent intent = new Intent(MainActivity.this, RegisterActivity.class); intent.putExtra(TYPE, UPDATE_DATA_TYPE); intent.putExtra("name", name); startActivity(intent); } private boolean deletePerson(View view) { int positon; if (!(view.getTag() instanceof Integer)) { return true; } positon = (Integer)view.getTag(); if (positon < 0 | mPersonsAdapter.getCount() <= positon) { return true; } Person person = mPersonsAdapter.getItem(positon); try { boolean isDeleted = mOpenHelper.deletePerson(person.getName()); if (isDeleted) { Toast.makeText(MainActivity.this, person.getName() + " was deleted.", Toast.LENGTH_LONG).show(); List<Person> list = mOpenHelper.findPerson(); mPersonsAdapter = new AdapterEx(this, list); mListView.setAdapter(mPersonsAdapter); mPersonsAdapter.notifyDataSetChanged(); } else { Toast.makeText(MainActivity.this, person.getName() + " cannot be deleted.", Toast.LENGTH_LONG).show(); } return true; } catch (SQLException e) { Toast.makeText(MainActivity.this, person.getName() + " cannot be deleted.", Toast.LENGTH_LONG).show(); Log.e(TAG, e.getMessage()); return true; } } }
person追加かアドレス更新の時に遷移する先のアクティビティ
RegisterActivity.java
package com.sarltokyo.addressbookormlite7.app; import android.content.res.Resources; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.Toast; import com.sarltokyo.addressbookormlite7.core.ContextLogic; import com.sarltokyo.addressbookormlite7.core.ContextLogicFactory; import com.sarltokyo.addressbookormlite7.db.OpenHelper; import com.sarltokyo.addressbookormlite7.entity.Address; import com.sarltokyo.addressbookormlite7.entity.Person; import java.sql.SQLException; /** * Created by osabe on 15/07/17. */ public class RegisterActivity extends AppCompatActivity implements View.OnClickListener { private final static String TAG = RegisterActivity.class.getSimpleName(); private ContextLogic mContextLogic; private OpenHelper mOpenHelper; private Button mBtn; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_register); // todo: ここ重要。DBをproduction用、test用に切り替えられるようにする。 mContextLogic = ContextLogicFactory.createContextLogic(this); mOpenHelper = mContextLogic.createOpenHelper(); mBtn = (Button)findViewById(R.id.btn); mBtn.setOnClickListener(this); String type = getIntent().getStringExtra(MainActivity.TYPE); String name = getIntent().getStringExtra("name"); Resources resources = getResources(); if (type.equals(MainActivity.CREATE_DATA_TYPE)) { mBtn.setText(resources.getString(R.string.register)); } else if (type.equals(MainActivity.UPDATE_DATA_TYPE)) { mBtn.setText(resources.getString(R.string.update)); showAddress(name); } } @Override public void onClick(View view) { int id = view.getId(); if (id == R.id.btn) { String type = getIntent().getStringExtra(MainActivity.TYPE); if (type.equals(MainActivity.CREATE_DATA_TYPE)) { registerAddressBook(); } else if (type.equals(MainActivity.UPDATE_DATA_TYPE)) { String name = getIntent().getStringExtra("name"); updateAddressBook(name); } } } public void registerAddressBook() { String name = ((EditText)findViewById(R.id.nameEt)).getText().toString(); String zipcode = ((EditText)findViewById(R.id.zipcodeEt)).getText().toString(); String prefecture = ((EditText)findViewById(R.id.prefectureEt)).getText().toString(); String city = ((EditText)findViewById(R.id.cityEt)).getText().toString(); String other = ((EditText)findViewById(R.id.otherEt)).getText().toString(); if (!checkData(name, zipcode, prefecture, city, other)) { return; } Address address = new Address(); address.setZipcode(zipcode); address.setPrefecture(prefecture); address.setCity(city); address.setOther(other); Person person = new Person(); person.setName(name); person.setAddress(address); try { mOpenHelper.registerPerson(person); finish(); } catch (SQLException e) { Toast.makeText(this, "cannot register address.", Toast.LENGTH_LONG).show(); Log.e(TAG, e.getMessage()); } } public void updateAddressBook(String name) { String newName = ((EditText)findViewById(R.id.nameEt)).getText().toString(); String zipcode = ((EditText)findViewById(R.id.zipcodeEt)).getText().toString(); String prefecture = ((EditText)findViewById(R.id.prefectureEt)).getText().toString(); String city = ((EditText)findViewById(R.id.cityEt)).getText().toString(); String other = ((EditText)findViewById(R.id.otherEt)).getText().toString(); if (!checkData(name, zipcode, prefecture, city, other)) { return; } try { Person person = mOpenHelper.findPerson(name); Address address = person.getAddress(); address.setZipcode(zipcode); address.setPrefecture(prefecture); address.setCity(city); address.setOther(other); person.setName(newName); person.setAddress(address); mOpenHelper.updatePerson(person); finish(); } catch (SQLException e) { Toast.makeText(this, "cannot update address.", Toast.LENGTH_LONG).show(); Log.e(TAG, e.getMessage()); } } public void showAddress(String name) { String type = getIntent().getStringExtra(MainActivity.TYPE); if (!type.equals(MainActivity.UPDATE_DATA_TYPE)) return; Person person; Address address; try { person = mOpenHelper.findPerson(name); } catch (SQLException e) { // ありえない Log.e(TAG, e.getMessage()); return; } address = person.getAddress(); String zipcode = address.getZipcode(); String prefecture = address.getPrefecture(); String city = address.getCity(); String other = address.getOther(); ((EditText)findViewById(R.id.nameEt)).setText(name); ((EditText)findViewById(R.id.zipcodeEt)).setText(zipcode); ((EditText)findViewById(R.id.prefectureEt)).setText(prefecture); ((EditText)findViewById(R.id.cityEt)).setText(city); ((EditText)findViewById(R.id.otherEt)).setText(other); } public boolean checkData(String name, String zipcode, String prefecture, String city, String other) { if (TextUtils.isEmpty(name)) { Toast.makeText(this, "name is empty.", Toast.LENGTH_LONG).show(); return false; } if (TextUtils.isEmpty(zipcode)) { Toast.makeText(this, "zipcode is empty.", Toast.LENGTH_LONG).show(); return false; } if (TextUtils.isEmpty(prefecture)) { Toast.makeText(this, "prefecture is empty.", Toast.LENGTH_LONG).show(); return false; } if (TextUtils.isEmpty(city)) { Toast.makeText(this, "city is empty.", Toast.LENGTH_LONG).show(); return false; } if (TextUtils.isEmpty(other)) { Toast.makeText(this, "other is empty.", Toast.LENGTH_LONG).show(); return false; } return true; } }
ここで、coreサプパッケージのContexLogic、ContexLogicFactoryクラスを使って、アクティビティクラスのonCreateメソッド内で、
// todo: ここ重要。DBをproduction用、test用に切り替えられるようにする。 mContextLogic = ContextLogicFactory.createContextLogic(this); mOpenHelper = mContextLogic.createOpenHelper();
のようにコーディングすると、アプリ使用時には実際のデータベースファイルaddressbook.db、テスト時には、test_addressbook.db ファイルを使用できるようになります。ContexLogic、ContexLogicFactoryクラスクラスについては、cattaka/FastCheckListのサンプルにファイルがあります。
2.6 adapterサブパッケージ
アダプタのコードは、以前はMainActivity.javaに書いていましたが、テストしやすいように、独立なクラスにします。
AdapterEx.java
package com.sarltokyo.addressbookormlite7.adapter; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ArrayAdapter; import android.widget.TextView; import com.sarltokyo.addressbookormlite7.app.R; import com.sarltokyo.addressbookormlite7.entity.Person; import java.util.List; public class AdapterEx extends ArrayAdapter<Person> { public AdapterEx(Context context, List<Person> persons) { super(context, R.layout.layout_main_item, persons); } @Override public View getView(int position, View convertView, ViewGroup parent) { Person person = getItem(position); if (convertView == null) { LayoutInflater inflater = LayoutInflater.from(getContext()); convertView = inflater.inflate(R.layout.layout_main_item, null); } convertView.setTag(position); convertView.findViewById(R.id.nameTv).setTag(position); TextView nameTv = (TextView)convertView.findViewById(R.id.nameTv); nameTv.setText(person.getName()); return convertView; } }
3. Espressoを使ったテストの準備
Espress 2.2を使うと、テストランナー android.support.test.runner.AndroidJUnitRunner で、データベース、アダプター、プリファレンス、UIのテストをすべて行えます。
app/build.gradleを以下のように編集します。applicationIdやtestApplicationIdは適宜修正します。なお、不要なライブラリを参照しているかもしれません。
buildscript { repositories { jcenter() } dependencies { classpath 'com.android.tools.build:gradle:1.1.1' } } apply plugin: 'com.android.application' repositories { jcenter() } android { compileSdkVersion 22 buildToolsVersion "22.0.1" defaultConfig { applicationId "com.sarltokyo.addressbookormlite7.app" minSdkVersion 9 targetSdkVersion 22 versionCode 1 versionName "1.0" testApplicationId "com.sarltokyo.addressbookormlite7.app.test" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_6 targetCompatibility JavaVersion.VERSION_1_6 } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } debug { testCoverageEnabled true } } packagingOptions { exclude 'LICENSE.txt' } } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) compile 'com.android.support:appcompat-v7:22.2.1' compile 'com.j256.ormlite:ormlite-android:4.48' androidTestCompile ('com.android.support.test:runner:0.3') { exclude module: 'support-annotations' } androidTestCompile ('com.android.support.test:rules:0.3') { exclude module: 'support-annotations' } androidTestCompile ('com.android.support.test.espresso:espresso-core:2.2') { exclude module: 'support-annotations' } androidTestCompile ('com.android.support.test.espresso:espresso-idling-resource:2.2') { exclude module: 'support-annotations' } androidTestCompile 'org.mockito:mockito-core:1.10.19' androidTestCompile 'com.google.dexmaker:dexmaker-mockito:1.2' }
4. Espressoを使ったテスト
4.1 テスト用ツール類 app.testサブパッケージ
住友孝郎@cattaka_net氏のBaseTestCase、TestContextLogic、TestUtil、UnlockKeyguardActivityクラスを使用します。https://github.com/cattaka/FastCheckList/tree/master/app/src/androidTest/java/net/cattaka/android/fastchecklist/test からファイルは入手可能です。本記事では、データベースのクリア、ダミーデータの作成のために、BaseTestCaseを改変しました。
BaseTestCase.java
package com.sarltokyo.addressbookormlite7.app.test; import android.app.Activity; import android.app.KeyguardManager; import android.content.Context; import android.content.Intent; import android.os.PowerManager; import android.os.SystemClock; import android.test.ActivityInstrumentationTestCase2; import android.util.Log; import com.sarltokyo.addressbookormlite7.core.ContextLogic; import com.sarltokyo.addressbookormlite7.core.ContextLogicFactory; import com.sarltokyo.addressbookormlite7.db.OpenHelper; import com.sarltokyo.addressbookormlite7.entity.Address; import com.sarltokyo.addressbookormlite7.entity.Person; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; /** * Created by osabe on 15/07/14. * * This file is a modified version of BaseTestCase.java * https://github.com/cattaka/FastCheckList/blob/master/app/src/androidTest/java/net/cattaka/android/fastchecklist/test/BaseTestCase.java */ public class BaseTestCase<T extends Activity> extends ActivityInstrumentationTestCase2<T> { protected ContextLogic mContextLogic; private final static String TAG = BaseTestCase.class.getSimpleName(); public BaseTestCase(Class<T> tClass) { super(tClass); } protected void setUp() throws Exception { super.setUp(); Context context = getInstrumentation().getTargetContext(); mContextLogic = new TestContextLogic(context); { // Replace ContextLogicFactory to use RenamingDelegatingContext. ContextLogicFactory.replaceInstance(new ContextLogicFactory() { @Override public ContextLogic newInstance(Context context) { return mContextLogic; } }); } { // Unlock keyguard and screen on KeyguardManager km = (KeyguardManager) getInstrumentation() .getTargetContext().getSystemService(Context.KEYGUARD_SERVICE); PowerManager pm = (PowerManager) getInstrumentation() .getTargetContext().getSystemService(Context.POWER_SERVICE); if (km.inKeyguardRestrictedInputMode() || !pm.isScreenOn()) { Intent intent = new Intent(getInstrumentation().getContext(), UnlockKeyguardActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); getInstrumentation().getTargetContext().startActivity(intent); while (km.inKeyguardRestrictedInputMode()) { SystemClock.sleep(100); } } } cleanData(); } @Override protected void tearDown() throws Exception { super.tearDown(); } public void cleanData() { Log.d(TAG, "cleanData"); OpenHelper openHelper = mContextLogic.createOpenHelper(); List<Person> persons = null; try { persons = openHelper.findPerson(); } catch (SQLException e) { Log.e(TAG, e.getMessage()); } for (Person person : persons) { try { openHelper.deletePerson(person.getName()); } catch (SQLException e) { Log.e(TAG, e.getMessage()); } } } public List<Person> createTestData(int personsNum) { Log.d(TAG, "createTestData"); List<Person> persons = new ArrayList<Person>(); // Creating dummy data. OpenHelper openHelper = mContextLogic.createOpenHelper(); for (int i = 0; i < personsNum; i++) { Person person = new Person(); Address address = new Address(); address.setZipcode("123-456" + i); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higash-shinjyuku 1-2-" + i); person.setName("Hoge" + i); person.setAddress(address); try { openHelper.registerPerson(person); persons.add(person); } catch (SQLException e) { Log.e(TAG, e.getMessage()); } } return persons; } } [/java] 4.2 アダプターのテスト アダプターのテストは、AdapterExのコンストラクタがAdapterEx(Context context, List<Person> persons)のようにList<Person>型の引数を持ちますので、そこにダミーのデータを入れて、Viewが期待した通りになるかテストします。 adapterサブパッケージ AdapterExTest.java package com.sarltokyo.addressbookormlite7.adapter; import android.content.Context; import android.test.InstrumentationTestCase; import android.view.View; import android.widget.LinearLayout; import android.widget.TextView; import com.sarltokyo.addressbookormlite7.app.R; import com.sarltokyo.addressbookormlite7.entity.Address; import com.sarltokyo.addressbookormlite7.entity.Person; import org.hamcrest.Matchers; import java.util.ArrayList; import java.util.List; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThat; public class AdapterExTest extends InstrumentationTestCase { public void testGetView() { List<Person> dummys = new ArrayList<Person>(); Person person1 = new Person(); Address address1 = new Address(); address1.setZipcode("123-4567"); address1.setPrefecture("Tokyo"); address1.setCity("Shinjuku"); address1.setOther("hoge 1-2-3"); person1.setName("Foo"); person1.setAddress(address1); Person person2 = new Person(); Address address2 = new Address(); address2.setZipcode("111-2222"); address2.setPrefecture("Kyoto"); address2.setCity("Kyoto"); address2.setOther("boo 4-5-6"); person2.setName("Bar"); person2.setAddress(address2); dummys.add(person1); dummys.add(person2); Context context = getInstrumentation().getTargetContext(); AdapterEx sut = new AdapterEx(context, dummys); View view1 = sut.getView(0, null, null); assertThat(view1, is(Matchers.instanceOf(LinearLayout.class))); assertThat(view1.findViewById(R.id.nameTv), is(Matchers.instanceOf(TextView.class))); assertThat(((TextView)view1.findViewById(R.id.nameTv)).getText().toString(), is("Foo")); View view2 = sut.getView(1, null, null); assertThat(view2, is(Matchers.instanceOf(LinearLayout.class))); assertThat(view2.findViewById(R.id.nameTv), is(Matchers.instanceOf(TextView.class))); assertThat(((TextView)view2.findViewById(R.id.nameTv)).getText().toString(), is("Bar")); } }
4.3 AsyncTaskLoaderのテスト
PersonLoaderのテストは、メソッド抽出したgetPersonsメソッドが、ダミーなデータ入力に対して、期待した値を返すかでテストします。
asynctaskサブパッケージ
PersonLoaderTest.java
package com.sarltokyo.addressbookormlite7.asynctask; import android.content.Context; import android.test.InstrumentationTestCase; import android.test.RenamingDelegatingContext; import com.sarltokyo.addressbookormlite7.db.OpenHelper; import com.sarltokyo.addressbookormlite7.entity.Address; import com.sarltokyo.addressbookormlite7.entity.Person; import java.util.List; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThat; public class PersonLoaderTest extends InstrumentationTestCase { private OpenHelper mOpenHelper; private Context mContext; private PersonLoader mPersonLoader; @Override protected void setUp() throws Exception { super.setUp(); mContext = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" ); mOpenHelper = new OpenHelper(mContext); mPersonLoader = new PersonLoader(mContext); } @Override protected void tearDown() throws Exception { super.tearDown(); mOpenHelper.close(); mOpenHelper = null; mPersonLoader = null; } private void createRegularData() throws Exception { // insert for (int i = 0; i < 10; i++) { Person person = new Person(); Address address = new Address(); address.setZipcode(String.valueOf(i)); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku " + (i+1)); person.setName("Abe" + i); person.setAddress(address); mOpenHelper.registerPerson(person); } } private void clearData() throws Exception { List<Person> persons = mOpenHelper.findPerson(); for (Person person: persons) { mOpenHelper.deletePerson(person.getName()); } } public void testSucces() throws Exception { createRegularData(); List<Person> actual = mPersonLoader.getPersons(); for (int i = 0; i< actual.size(); i++) { Person actualPerson = actual.get(i); String actuallName = actualPerson.getName(); Address actualAddress = actualPerson.getAddress(); String actualZipcode = actualAddress.getZipcode(); String actualPrefecture = actualAddress.getPrefecture(); String acutalCity = actualAddress.getCity(); String actualOther = actualAddress.getOther(); assertThat(actualZipcode, is(String.valueOf(i))); assertThat(actualPrefecture, is(("Tokyo"))); assertThat(acutalCity, is(("Shinjyuku-ku"))); assertThat(actualOther, is("Higashi-shinjyuku " + (i+1))); assertThat(actuallName, is("Abe" + i)); } } public void testEmptyData() throws Exception { clearData(); List<Person> actual = mPersonLoader.getPersons(); assertTrue(actual.isEmpty()); } }
データベースをダミーのものに置き換えてテストを行うには、
@Override protected void setUp() throws Exception { super.setUp(); Context context = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" ); mOpenHelper = new OpenHelper(context); }
のように、contextを、 new RenamingDelegatingContext(getInstrumentation().getTargetContext(), “test_” );で置き換えることが重要です。これを行うと、データベースファイルは、実際のデータベースファイル名の頭に”test_”がついて、ダミーのデータベースファイルでテストを行うことができます。
4.4 coreサブパッケージのクラスのテスト
FastCheckList/app/src/androidTest/java/net/cattaka/android/fastchecklist/core/で、パッケージを修正して、テストします。
4.5 データベースのテスト
データベースのテストはダミーのデータファイルを使用してテストを行います。
dbサブパッケージ
OpenHelperTest.java
package com.sarltokyo.addressbookormlite7.db; import android.content.Context; import android.test.InstrumentationTestCase; import android.test.RenamingDelegatingContext; import com.sarltokyo.addressbookormlite7.entity.Address; import com.sarltokyo.addressbookormlite7.entity.Person; import java.sql.SQLException; import java.util.List; import static org.hamcrest.Matchers.is; import static org.junit.Assert.assertThat; public class OpenHelperTest extends InstrumentationTestCase { private OpenHelper mOpenHelper; @Override protected void setUp() throws Exception { super.setUp(); Context context = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" ); mOpenHelper = new OpenHelper(context); } @Override protected void tearDown() throws Exception { super.tearDown(); mOpenHelper.close(); mOpenHelper = null; } public void testInsertSelect()throws Exception{ assertEquals(0, mOpenHelper.findPerson().size()); Person person = new Person(); Address address = new Address(); address.setZipcode("123-4567"); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku 1-2-3"); person.setName("Foo"); person.setAddress(address); // insert mOpenHelper.registerPerson(person); // select Person sut = mOpenHelper.findPerson("Foo"); assertThat(sut.getAddress().getZipcode(), is("123-4567")); assertThat(sut.getAddress().getPrefecture(), is("Tokyo")); assertThat(sut.getAddress().getCity(), is("Shinjyuku-ku")); assertThat(sut.getAddress().getOther(), is("Higashi-shinjyuku 1-2-3")); } public void testDuplicate() throws Exception { assertEquals(0, mOpenHelper.findPerson().size()); Person person = new Person(); Address address = new Address(); address.setZipcode("123-4567"); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku 1-2-3"); person.setName("Foo"); person.setAddress(address); // insert mOpenHelper.registerPerson(person); assertEquals(1, mOpenHelper.findPerson().size()); Person person2 = new Person(); Address address2 = new Address(); address.setZipcode("111-1111"); address.setPrefecture("Kyoto"); address.setCity("Kyoto"); address.setOther("boo 1-2-3"); person2.setName("Foo"); // duplicate person2.setAddress(address2); // insert // JUnit 3の書き方になってしまう try { mOpenHelper.registerPerson(person2); fail("SQLException is expected"); } catch (SQLException e) { } assertEquals(1, mOpenHelper.findPerson().size()); } public void testRegister() throws Exception { assertEquals(0, mOpenHelper.findPerson().size()); // insert for (int i = 0; i < 10; i++) { Person person = new Person(); Address address = new Address(); address.setZipcode(String.valueOf(i)); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku " + (i+1)); person.setName("Abe" + i); person.setAddress(address); mOpenHelper.registerPerson(person); } // select List<Person> sut = mOpenHelper.findPerson(); assertEquals(10, sut.size()); } public void testFind() throws Exception { assertEquals(0, mOpenHelper.findPerson().size()); Person person = new Person(); Address address = new Address(); address.setZipcode("123-4567"); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku 1-2-3"); person.setName("Foo"); person.setAddress(address); // insert mOpenHelper.registerPerson(person); // select List<Person> sut = mOpenHelper.findPerson(); assertEquals(1, mOpenHelper.findPerson().size()); } public void testUpdate() throws Exception { assertEquals(0, mOpenHelper.findPerson().size()); Person person = new Person(); Address address = new Address(); address.setZipcode("123-4567"); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku 1-2-3"); person.setName("Foo"); person.setAddress(address); // insert mOpenHelper.registerPerson(person); // select List<Person> sut = mOpenHelper.findPerson(); assertEquals(1, mOpenHelper.findPerson().size()); Person person2 = mOpenHelper.findPerson(person.getName()); Address address2 = person2.getAddress(); address2.setZipcode("123-0000"); address2.setPrefecture("Osaka"); address2.setCity("Osaka"); address2.setOther("hoge 1-2-3"); person2.setName("Foo2"); person2.setAddress(address2); // update mOpenHelper.updatePerson(person2); // select assertEquals(1, mOpenHelper.findPerson().size()); Person sut2 = mOpenHelper.findPerson("Foo2"); assertThat(sut2.getAddress().getZipcode(), is("123-0000")); assertThat(sut2.getAddress().getPrefecture(), is("Osaka")); assertThat(sut2.getAddress().getCity(), is("Osaka")); assertThat(sut2.getAddress().getOther(), is("hoge 1-2-3")); assertThat(sut2.getName(), is("Foo2")); } public void testDelete() throws SQLException { assertEquals(0, mOpenHelper.findPerson().size()); Person person = new Person(); Address address = new Address(); address.setZipcode("123-4567"); address.setPrefecture("Tokyo"); address.setCity("Shinjyuku-ku"); address.setOther("Higashi-shinjyuku 1-2-3"); person.setName("Foo"); person.setAddress(address); // insert mOpenHelper.registerPerson(person); // delete mOpenHelper.deletePerson(person.getName()); assertEquals(0, mOpenHelper.findPerson().size()); } }
データベースのテストをダミーデータで行うためには、
@Override protected void setUp() throws Exception { super.setUp(); Context context = new RenamingDelegatingContext(getInstrumentation().getTargetContext(), "test_" ); mOpenHelper = new OpenHelper(context); }
のように、contextを、 new RenamingDelegatingContext(getInstrumentation().getTargetContext(), “test_” );で置き換えることが重要です。これを行うと、データベースファイルは、実際のデータベースファイル名の頭に”test_”がついて、ダミーのデータベースファイルでテストを行うことができます。
4.6 UIのテスト
アクティビティ、つまりUIのテストは、ダミーなデータベースファイルを使用してテストを行います。
MainActivity3Test.java
package com.sarltokyo.addressbookormlite7.app; import android.os.IBinder; import android.support.test.espresso.*; import android.support.test.espresso.assertion.ViewAssertions; import android.support.test.espresso.matcher.ViewMatchers; import android.view.WindowManager; import android.widget.ListView; import com.sarltokyo.addressbookormlite7.app.test.BaseTestCase; import org.hamcrest.Description; import org.hamcrest.Matcher; import org.hamcrest.TypeSafeMatcher; import static android.support.test.espresso.Espresso.onData; import static android.support.test.espresso.Espresso.onView; import static android.support.test.espresso.action.ViewActions.*; import static android.support.test.espresso.assertion.ViewAssertions.matches; import static android.support.test.espresso.matcher.ViewMatchers.*; import static org.hamcrest.Matchers.anything; public class MainActivity3Test extends BaseTestCase<MainActivity> { private final static String TAG = MainActivity3Test.class.getSimpleName(); public MainActivity3Test() { super(MainActivity.class); } public void testTransitionRegisterActivity1() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); final ListView listView = (ListView) activity.findViewById(android.R.id.list); // The test works OK on API Level 21 emulator, but fails on API Level 19 emulator. assertEquals(200, listView.getCount()); onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0) .check(ViewAssertions.matches(ViewMatchers.isDisplayed())); // todo: これでOK onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0) .perform(click()); // todo: これでOK onView(withId(R.id.nameEt)).check(matches(withText("Hoge0"))); onView(withId(R.id.zipcodeEt)).check(matches(withText("123-4560"))); onView(withId(R.id.prefectureEt)).check(matches(withText("Tokyo"))); onView(withId(R.id.cityEt)).check(matches(withText("Shinjyuku-ku"))); onView(withId(R.id.otherEt)).check(matches(withText("Higash-shinjyuku 1-2-0"))); // todo : ボタンがUPDATEと表示されてることをテストしたい onView(withId(R.id.btn)).check(matches(isClickable())); } public void testTransitionRegisterActivity2() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onView(withId(R.id.input_add)).perform(click()); onView(withId(R.id.nameEt)).check(matches(withText(""))); onView(withId(R.id.zipcodeEt)).check(matches(withText(""))); onView(withId(R.id.prefectureEt)).check(matches(withText(""))); onView(withId(R.id.cityEt)).check(matches(withText(""))); onView(withId(R.id.otherEt)).check(matches(withText(""))); // todo : ボタンがREGISTERと表示されてることをテストしたい onView(withId(R.id.btn)).check(matches(isClickable())); } public void testAddPerson() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); onView(withId(R.id.input_add)).perform(click()); assertFalse(activity.isFinishing()); onView(withId(R.id.nameEt)).perform(typeText("Bar")); onView(withId(R.id.zipcodeEt)).perform(typeText("123-4567")); onView(withId(R.id.prefectureEt)).perform(typeText("Tokyo")); onView(withId(R.id.cityEt)).perform(typeText("Nerima-ku")); onView(withId(R.id.otherEt)).perform(typeText("Nerima 1-2-3")); onView(withId(R.id.btn)).perform(click()); } public void testUpdateAddress() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onData(anything()).inAdapterView(withId(android.R.id.list)).atPosition(0) .perform(click()); onView(withId(R.id.zipcodeEt)).perform(clearText()).perform(typeText("345-6789")); onView(withId(R.id.prefectureEt)).perform(clearText()).perform(typeText("Kyoto")); onView(withId(R.id.cityEt)).perform(clearText()).perform(typeText("Shimogyo-ku")); onView(withId(R.id.otherEt)).perform(clearText()).perform(typeText("Higashi 1-2-3")); onView(withId(R.id.btn)).perform(click()); // todo: MainActivityに戻ったことをテストしたい } public void testNameEmptyInfo() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onView(withId(R.id.input_add)).perform(click()); onView(withId(R.id.nameEt)).perform(typeText("")); // todo: うまくいかない // onView(withText("name is empty.")).inRoot(withDecorView(not(is(getActivity().getWindow().getDecorView())))) // .check(matches(isDisplayed())); onView(withText("name is empty.")).inRoot(isToast()); } public void testZipcodeEmptyInfo() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onView(withId(R.id.input_add)).perform(click()); onView(withId(R.id.zipcodeEt)).perform(typeText("")); onView(withText("zipcode is empty.")).inRoot(isToast()); } public void testPrefectureEmptyInfo() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onView(withId(R.id.input_add)).perform(click()); onView(withId(R.id.prefectureEt)).perform(typeText("")); onView(withText("prefecture is empty.")).inRoot(isToast()); } public void testCityEmptyInfo() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onView(withId(R.id.input_add)).perform(click()); onView(withId(R.id.cityEt)).perform(typeText("")); onView(withText("city is empty.")).inRoot(isToast()); } public void testOtherEmptyInfo() throws Throwable { // Creating dummy data createTestData(200); MainActivity activity = getActivity(); assertFalse(activity.isFinishing()); onView(withId(R.id.input_add)).perform(click()); onView(withId(R.id.otherEt)).perform(typeText("")); onView(withText("other is empty.")).inRoot(isToast()); } // private void setSelection(final ListView listView, final int position) throws Throwable { // runTestOnUiThread(new Runnable() { // @Override // public void run() { // listView.setSelection(position); // } // }); // } //// private void select(final int position) throws Throwable { //// runTestOnUiThread(new Runnable() { //// @Override //// public void run() { //// onData(allOf(hasToString(startsWith("Hoge")))).inAdapterView(withId(android.R.id.list)).atPosition(position) //// .perform(click()); //// } //// }); //// } /** * Matcher that is Toast window. * http://baroqueworksdevjp.blogspot.jp/2015/03/espressotoast.html */ public static Matcher<Root> isToast() { return new TypeSafeMatcher<Root>() { @Override public void describeTo(Description description) { description.appendText("is toast"); } @Override public boolean matchesSafely(Root root) { int type = root.getWindowLayoutParams().get().type; if ((type == WindowManager.LayoutParams.TYPE_TOAST)) { IBinder windowToken = root.getDecorView().getWindowToken(); IBinder appToken = root.getDecorView().getApplicationWindowToken(); if (windowToken == appToken) { // windowToken == appToken means this window isn't contained by any other windows. // if it was a window for an activity, it would have TYPE_BASE_APPLICATION. return true; } } return false; } }; } }
このテストでは、テストクラスが、BaseTestCaseを継承しており、BaseTestTestCaseクラスのsetUpメソッド内で、
protected void setUp() throws Exception { super.setUp(); Context context = getInstrumentation().getTargetContext(); mContextLogic = new TestContextLogic(context); { // Replace ContextLogicFactory to use RenamingDelegatingContext. ContextLogicFactory.replaceInstance(new ContextLogicFactory() { @Override public ContextLogic newInstance(Context context) { return mContextLogic; } }); } ... }
と書かれています。TestContextLogicクラスは、
public class TestContextLogic extends ContextLogic { private RenamingDelegatingContext mRdContext; public TestContextLogic(Context context) { super(context); mRdContext = new RenamingDelegatingContext(context, "test_"); } @Override public OpenHelper createOpenHelper() { return new OpenHelper(mRdContext); } }
になっており、ContextLogicFactoryクラスは、
public class ContextLogicFactory { static ContextLogicFactory INSTANCE = new ContextLogicFactory(); public ContextLogic newInstance(Context context) { return new ContextLogic(context); } public static ContextLogic createContextLogic(Context context) { return INSTANCE.newInstance(context); } public static void replaceInstance(ContextLogicFactory INSTANCE) { ContextLogicFactory.INSTANCE = INSTANCE; } }
になっています。これらのコードで、テスト時に、実際のデータベースファイルのファイル名の頭に、”test_”をつけたファイル名のデータベースを使用するようになっています。
アクティビティのテストでは、ダミーデータを用意しておいた場合、
メイン画面で全件表示できるか
メイン画面のリストで行をタップすると、Address更新画面に遷移するか
遷移先でアドレスを更新できるか
+ボタンのタップで、名前、アドレス入力画面に遷移するか、名前、アドレスを入力できるか、personを追加できるか
アドレス入力画面で、入力欄が空欄の場合、Toast表示を確認できるか
などをテストしています。
5. ソースファイル公開
今回作成したアプリのソースファイルを公開します。
andropenguin/AddressBookORMLite7
6. 参考サイト
開発を効率的に進めるられるまでの道程
cattaka/FastCheckList
Android の非同期処理を行う Loader の起動方法
EspressoでToast表示のチェックをする