- 投稿日:2021-03-25T22:30:15+09:00
ActivityResultAPIのテストを書く
概要
startActivityForResult
とonActivityResult
が非推奨になったので、ActivityResultAPI
を使って書き直しました。
で、公式にFragment
向けのActivityResultRegistry
を置き換えてテストできる方法について触れている箇所があったのですが、
Activity
でテストする場合のことが書いてなかったので調べて対応しました。環境
ツールなど バージョンなど MacbookPro macOS Catalina 10.15.7 Android Studio 4.1.2 Java(JDK) openjdk version "11.0.10" Koin 2.2.2 activity-ktx 1.2.1 対応方法
KoinというDIライブラリを使って実現しました。
同じようなDIのライブラリを使ったり、あるいはMockitoで頑張れば似たようなことが出来るかと思います。0. ライブラリなど
Koinとテスト関連のものを抜粋しています。
同じコードでInstrumentationテストとRobolectricで動かせるように重複して登録している物が多いため長くなっています。app/build.gradledependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' implementation 'com.google.android.material:material:1.3.0' implementation 'androidx.fragment:fragment-ktx:1.3.1' implementation 'androidx.activity:activity-ktx:1.2.1' testImplementation 'junit:junit:4.13.2' testImplementation 'org.assertj:assertj-core:3.19.0' testImplementation 'androidx.test.ext:junit:1.1.2' testImplementation 'androidx.test:runner:1.3.0' testImplementation 'androidx.test:rules:1.3.0' testImplementation 'androidx.test.espresso:espresso-core:3.3.0' testImplementation 'androidx.test.espresso:espresso-contrib:3.3.0' testImplementation 'androidx.test.espresso:espresso-intents:3.3.0' // robolectric testImplementation 'org.robolectric:robolectric:4.5.1' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:rules:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.3.0' androidTestImplementation 'androidx.test.ext:junit:1.1.2' androidTestImplementation 'org.assertj:assertj-core:3.19.0' androidTestUtil 'androidx.test:orchestrator:1.3.0' // Koin for Kotlin apps def koin_version = "2.2.2" // Testing testImplementation "org.koin:koin-test:$koin_version" androidTestImplementation "org.koin:koin-test:$koin_version" // Koin for Android implementation "org.koin:koin-android:$koin_version" // Koin AndroidX Scope feature implementation "org.koin:koin-androidx-scope:$koin_version" // Koin AndroidX ViewModel feature implementation "org.koin:koin-androidx-viewmodel:$koin_version"1. 対象のActivityをScopedActivityにする
FooActivity.ktclass FooActivity : ScopedActivity() {2. Koinのモジュールを作る
Koinの説明は割愛しますので、詳しくは調べてください。
(1)モジュールを作る
modules.ktval scopeModules = module { scope<FooActivity> { scoped { get<AppCompatActivity>().activityResultRegistry } } } // モジュール群 val appModules = listOf( scopeModules )(2)アプリケーションクラスでKoinを設定する
MyApp.ktclass MyApp : Application() { override fun onCreate() { super.onCreate() startKoin { if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) else EmptyLogger() androidContext(this@MyApp) modules(appModules) } } }マニフェストファイルにアプリケーションクラスを設定するのもお忘れなく。
AndroidManifest.xml<application android:name=".MyApp" ...>(3)registerForActivityResultの第2引数を変更する
registerForActivityResult
の第2引数をget()
に変更します。FooActivity.ktprivate val activityResultLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult(), get()) { onBarActivityResult(it) }これでKoinが同じクラスを返すモジュールを探してInjectionしてくれます。
3. テスト用のモジュール作成
(1)テスト用のActivityResultRegistryを定義
TestModules.ktclass TestResultRegistry() : ActivityResultRegistry() { override fun <I, O> onLaunch( requestCode: Int, contract: ActivityResultContract<I, O>, input: I, options: ActivityOptionsCompat? ) { when (/* 条件 */) { // caseに応じたdispatchResult処理 dispatchResult(requestCode, /*リザルトコード*/, /*リザルトデータ*/) } } }期待する結果を入れておく変数や関数などは適宜作成してください。
(2)差し替えモジュール作成
テストの際に差し替えるモジュールを作成します。
TestModules.ktval testMockModule = module { scope<FooActivity> { scoped(override = true) { TestResultRegistry() as ActivityResultRegistry } } }4. テストクラス
(1)テストクラスを作成
androidTestで作っていきます。
FooActivityTest.kt@RunWith(AndroidJUnit4::class) class FooActivityTest : AutoCloseKoinTest() { @get:Rule val rule = InstantTaskExecutorRule() @Before fun setUp() { loadKoinModules(testMockModule) } @Test fun bar() { ActivityScenario.launch(FooActivity::class.java).use { scenario -> scenario.onActivity { } // 別のActivityを起動させる onView(withId(R.id.barActivityLaunchButton)).perform(click()) // resultがすぐにディスパッチされているので、結果を受け取って表示されているべき内容をチェックする onView(withText(getString(R.string.barbar))) .check(matches(isDisplayed())) } } }注意としては、この方法だと、直ぐに結果がdispatchされるため実際には
Activity
は何も起動していない点です。なのでActivityMonitor
を使って起動したActivity
のチェックを行うことは出来ません。そこで、次のように必要なテストに応じて追加の差替えモジュールをkoinに読ませるようにすると良いかも知れません。
その場合、
testMockModule
からTestResultRegistry
は除外しておく必要があります。(2)テストごとに追加モジュールを使う
TestModules.ktval testMockModule = module { //scope<FooActivity> { // scoped(override = true) { TestResultRegistry() as ActivityResultRegistry } // } }FooActivityTest.kt@Test fun bar_isLaunched() { // ResultActivityの起動を監視 val monitor = Instrumentation.ActivityMonitor( BarActivity::class.java.canonicalName, null, false ) InstrumentationRegistry.getInstrumentation().addMonitor(monitor) onView(withId(R.id.barActivityLaunchButton)).perform(click()) // ResultActivityが起動したか確認 InstrumentationRegistry.getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L) assertThat(monitor.hits).isEqualTo(1) } @Test fun bar_resultCheck() { val testRegistry = TestRegistry() // 追加のモジュール val scopedModule = module { scope<FooActivity> { scoped(override = true) { testRegistry as ActivityResultRegistry } } } loadKoinModules(scopedModule) ActivityScenario.launch(FooActivity::class.java).use { scenario -> scenario.onActivity { } // 別のActivityを起動させる onView(withId(R.id.barActivityLaunchButton)).perform(click()) // resultがすぐにディスパッチされているので、結果を受け取って表示されているべき内容をチェックする onView(withText(getString(R.string.barbar))) .check(matches(isDisplayed())) } }5.Roblectric版
Robolectricでも同じコードで動くのを確認しています。
ただし、Roblectricで動かしている場合はEspressoがonView
でダイアログ上のViewを拾えないので、ダイアログを表示している場合にはテストコードを100%流用することが出来ません。ご注意下さい。
(ActivityResultAPI関係の所は同じで動くはずです)参考サイトなど
- 投稿日:2021-03-25T22:10:52+09:00
Flutter アプリ内課金(サブスクリプリプション)の実装
内容
今回初めて、アプリ内課金(サブスクリプション、以下サブスク)の実装をしたので、忘却録として書きます。
2021/3/12 時点のサンプルコードを使用。
サブスク導入でつまずいた部分のみを記載(2021/03/26時点ではiOSのつまずき集のみ)。実装は気が向いたら書く予定。テスト中に何回も購入処理をして、トランザクションが残っていると色々と大変らしいので、YuKiOさんの記事を先に読んでおくことをおすすめします。
前提条件
●App Store, Google Play側の準備ができている(サブスクアイテム作成)
●今回はサブスクのみなので、consumable(消耗品)関係は未対応以下のような感じに複数のアイテムをセットしています
Set<String> _kProductIds = { Platform.isIOS ? 'ios.subscription01' : 'android.subscription01', Platform.isIOS ? 'ios.subscription02' : 'android.subscription02', Platform.isIOS ? 'ios.subscription03' : 'android.subscription03', };環境
PC:MacBook Pro
エディター:Visual Studio CodeFlutter:1.22.6
in_app_purchase:0.3.5+1
つまずき集
サンプルコードを修正しながら実行してみて動きを見ていた時のつまずき集です
サンプルコードはこちら
つまずきその1
シミュレータでデバッグしたら、return されてしまう
productDetailResponse = await _connection.queryProductDetails(_kProductIds); if (productDetailResponse.error != null) { setState( () { _queryProductError = productDetailResponse.error.message; _isAvailable = isAvailable; _products = productDetailResponse.productDetails; _purchases = []; _notFoundIds = productDetailResponse.notFoundIDs; _consumables = []; _purchasePending = false; _loading = false; }, ); return; }解決方法:シミュレータで実行していることが原因らしく、実機をつないで、デバッグしたらここで、returnしないらしいです(iPhone11で確認しました)
こちらを参考にしましたつまずきその2
サブスク用に各プラットフォームで作成したアイテムを_kProductIdsにセットしたが、見つからないと画面に表示されてしまう
画像のように[ios.subscription01,・・・] not foundと表示されるproductDetailResponse = await _connection.queryProductDetails(_kProductIds); if (productDetailResponse.productDetails.isEmpty) { setState( () { _queryProductError = null; _isAvailable = isAvailable; _products = productDetailResponse.productDetails; _purchases = []; _notFoundIds = productDetailResponse.notFoundIDs; _consumables = []; _purchasePending = false; _loading = false; }, ); return; }解決方法:こちらもシミュレータではproductDetailResponse.productDetails.isEmptyがtrueになってしまうので、実機に接続することで、解決できました
プラグインの公式ページにも以下のように記載がありましたUse flutter run to install the app and test it. Note that you need to test it on a real device instead of a simulator, and signing into any production service (including iTunes!) with the test account will permanently invalidate it. Sign in to the test account in the example app following the steps in the In-App Purchase Programming Guide.つまずきその3
SANDBOXアカウントの変更ができない
サブスク購入ボタンをクリック時にアカウントを入力するダイアログが表示され、一度、入力したユーザーが変更できなかった
解決方法:こちらの記事を参考にすると、SANDBOXアカウントの変更ができるつまずきその4
SANDBOXアカウントの変更をしてもアイテムの購入ができない
購入しても、purchaseDetails.status が purchased にならず、購入後の処理が実行されない解決方法:SANDBOXアカウントのアドレスを存在するアドレスにする
(たまたまそうなっただけで、正しいかどうかは未確認)つまずきその5
購入フォームが2回表示される
解決方法:つまずき4が解決したら表示されなくなった
githubにも上がってたけど、解決はしていない?
原因は不明のままつまずき6
保護者へのリクエストが表示され、購入が進まない
解決方法:githubに書いてありますが、PurchaseParamメソッドの引数のsandboxTestingをfalseにする。
PurchaseParam purchaseParam = PurchaseParam( sandboxTesting: false, productDetails: productDetails);参考
●各プラットフォームのアイテムの作成方法
・iOS
Qiita・Android
Qiita
- 投稿日:2021-03-25T11:51:57+09:00
自作のアダプタークラスでclear()を使う
問題のコード
ListWorkActivity.javapublic class ListWorkActivity extends AppCompatActivity implements View.OnClickListener { private ArrayList<Record> listItems = new ArrayList<>(); private DateArrayAdapter arrayAdapter; private ListView listView; protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_list); //listViewにlistItemsをセットしたい listView = findViewById(R.id.listView); {...} //nullじゃないlistView(ここは省略)と自作のDateArrayAdapter arrayAdapter = new DateArrayAdapter(this, R.layout.date_list, listItems); listView.setAdapter(arrayAdapter); } //menu内のボタンをクリックするとListViewの中身が逆順になるようにしたい public boolean onOptionsItemSelected(MenuItem menuItem){ int item = menuItem.getItemId(); switch (item) { {...} case R.id.menu_button: //button //逆順にする Collections.reverse(listItems); arrayAdapter.clear(); arrayAdapter.addAll(listItems); arrayAdapter.notifyDataSetChanged(); break; } return false; }自作のアダプタークラス
ListViewにカスタマイズしたレイアウトで要素を表示するためのクラスです。
DateArrayAdapter.javapublic class DateArrayAdapter extends ArrayAdapter<Record> { private int mResource; private List<Record> mItems; private LayoutInflater mInflater; /** * コンストラクタ * @param context コンテキスト * @param resource レイアウトファイルのリソースID * @param items リストビューの要素 */ public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){ super(context, resource, items); mResource = resource; mItems = items; //ArrayList -> List mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public int getCount() { return mItems.size(); } @Override public Record getItem(int position) { return mItems.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { View view; if (convertView != null){ view = convertView; }else{ view = mInflater.inflate(mResource, null); } Record record; //リストビューに表示する要素を取得 record = getItem(position); //date TextView textViewDate = view.findViewById(R.id.textViewDate_DateLayout); textViewDate.setText(record.getDate()); //work TextView textViewWork = view.findViewById(R.id.textViewWork_DateLayout); textViewWork.setText(record.getWork()); //memo TextView textViewMemo = view.findViewById(R.id.textViewMemo_DateLayout); textViewMemo.setText(record.getMemo()); return view; //may not be null } }
Record.javaはデータ格納用のクラスです
Record.javapublic class Record { //日付、work、メモを格納するクラス private String date; private String work; private String memo; //空のコンストラクタ public Record(){ } //コンストラクタ public Record(String date, String work, String memo){ this.date = date; this.work = work; this.memo = memo; } public String getDate() { return date; } public String getWork() { return work; } public String getMemo() { return memo; } }起こった問題について
やりたかったこと
・listViewの中身を逆順にして表示する
実際はどうなったか
・listViewの要素であるlistItemは逆順になった
・clear()をするとlistItemsが消えた//onOptionsItemSelected内の処理 Collections.reverse(listItems); //ここでlistItemsを逆順にすることには成功 arrayAdapter.clear(); //listItems の要素数が0になる = listItemsが消えた!? arrayAdapter.addAll(listItems); arrayAdapter.notifyDataSetChanged();何が問題だったか
これによると、コンストラクタにリストなどを渡してしまうと、そのリストは不変になってしまい、clear()を使うと例外が発生するということです。
DateArrayAdapterのコンストラクタではsuper(context, resource, items) (items = listView)を呼び出していたのでうまくいかなかったわけです。解決法
ArrayAdapterにはほかにもコンストラクタがあるので、T[]を使わないコンストラクタを呼び出しました。
DateArrayAdapter.javapublic class DateArrayAdapter extends ArrayAdapter<Record> { private int mResource; private List<Record> mItems; private LayoutInflater mInflater; /** * コンストラクタ * @param context コンテキスト * @param resource レイアウトファイルのリソースID * @param items リストビューの要素 */ //問題のコンストラクタ。引数にリストを渡している。 public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){ super(context, resource, items); mResource = resource; mItems = items; //ArrayList -> List mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } //新しいコンストラクタ。引数にarrayListは渡していない。 public DateArrayAdapter(Context context, int resource){ super(context, resource); mResource = resource; mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } //新しいコンストラクタではmItemsを設定できないので、セッターを用いる。 //これがないとほかのメソッドがmItemsを使えなくて動かなくなるので注意 public void setItems(List<Record> mItems) { this.mItems = mItems; } //あとは同じです。 @Override public int getCount() { return mItems.size(); } {...} }ListWorkActivity.java//アダプターをセットするところがこんな感じに変わる arrayAdapter = new DateArrayAdapter(this, R.layout.date_list); arrayAdapter.addAll(listItems); arrayAdapter.setItems(listItems); listView.setAdapter(arrayAdapter);ほかにいい解決法がありそうなら教えていただけると嬉しいです。
- 投稿日:2021-03-25T11:51:57+09:00
ListView用のカスタムアダプターの中身を逆順にするときにおこった問題について
問題のコード
ListWorkActivity.javapublic class ListWorkActivity extends AppCompatActivity implements View.OnClickListener { private ArrayList<Record> listItems = new ArrayList<>(); private DateArrayAdapter arrayAdapter; private ListView listView; protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_list); //listViewにlistItemsをセットしたい listView = findViewById(R.id.listView); {...} //nullじゃないlistView(ここは省略)と自作のDateArrayAdapter arrayAdapter = new DateArrayAdapter(this, R.layout.date_list, listItems); listView.setAdapter(arrayAdapter); } //menu内のボタンをクリックするとListViewの中身が逆順になるようにしたい public boolean onOptionsItemSelected(MenuItem menuItem){ int item = menuItem.getItemId(); switch (item) { {...} case R.id.menu_button: //button //逆順にする Collections.reverse(listItems); arrayAdapter.clear(); arrayAdapter.addAll(listItems); arrayAdapter.notifyDataSetChanged(); break; } return false; }自作のアダプタークラス
ListViewにカスタマイズしたレイアウトで要素を表示するためのクラスです。
DateArrayAdapter.javapublic class DateArrayAdapter extends ArrayAdapter<Record> { private int mResource; private List<Record> mItems; private LayoutInflater mInflater; /** * コンストラクタ * @param context コンテキスト * @param resource レイアウトファイルのリソースID * @param items リストビューの要素 */ public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){ super(context, resource, items); mResource = resource; mItems = items; //ArrayList -> List mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } @Override public int getCount() { return mItems.size(); } @Override public Record getItem(int position) { return mItems.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { View view; if (convertView != null){ view = convertView; }else{ view = mInflater.inflate(mResource, null); } Record record; //リストビューに表示する要素を取得 record = getItem(position); //date TextView textViewDate = view.findViewById(R.id.textViewDate_DateLayout); textViewDate.setText(record.getDate()); //work TextView textViewWork = view.findViewById(R.id.textViewWork_DateLayout); textViewWork.setText(record.getWork()); //memo TextView textViewMemo = view.findViewById(R.id.textViewMemo_DateLayout); textViewMemo.setText(record.getMemo()); return view; //may not be null } }
Record.javaはデータ格納用のクラスです
Record.javapublic class Record { //日付、work、メモを格納するクラス private String date; private String work; private String memo; //空のコンストラクタ public Record(){ } //コンストラクタ public Record(String date, String work, String memo){ this.date = date; this.work = work; this.memo = memo; } public String getDate() { return date; } public String getWork() { return work; } public String getMemo() { return memo; } }起こった問題について
やりたかったこと
・listViewの中身を逆順にして表示する
実際はどうなったか
・listViewの要素であるlistItemは逆順になった
・clear()をするとlistItemsが消えた//onOptionsItemSelected内の処理 Collections.reverse(listItems); //ここでlistItemsを逆順にすることには成功 arrayAdapter.clear(); //listItems の要素数が0になる = listItemsが消えた!? arrayAdapter.addAll(listItems); arrayAdapter.notifyDataSetChanged();何が問題だったか
これによると、コンストラクタにリストなどを渡してしまうと、そのリストは不変になってしまい、clear()を使うと例外が発生するということです。
DateArrayAdapterのコンストラクタではsuper(context, resource, items) (items = listView)を呼び出していたのでうまくいかなかったわけです。解決法
ArrayAdapterにはほかにもコンストラクタがあるので、T[]を使わないコンストラクタを呼び出しました。
DateArrayAdapter.javapublic class DateArrayAdapter extends ArrayAdapter<Record> { private int mResource; private List<Record> mItems; private LayoutInflater mInflater; /** * コンストラクタ * @param context コンテキスト * @param resource レイアウトファイルのリソースID * @param items リストビューの要素 */ //問題のコンストラクタ。引数にリストを渡している。 public DateArrayAdapter(Context context, int resource, ArrayList<Record> items){ super(context, resource, items); mResource = resource; mItems = items; //ArrayList -> List mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } //新しいコンストラクタ。引数にarrayListは渡していない。 public DateArrayAdapter(Context context, int resource){ super(context, resource); mResource = resource; mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); } //新しいコンストラクタではmItemsを設定できないので、セッターを用いる。 //これがないとほかのメソッドがmItemsを使えなくて動かなくなるので注意 public void setItems(List<Record> mItems) { this.mItems = mItems; } //あとは同じです。 @Override public int getCount() { return mItems.size(); } {...} }ListWorkActivity.java//アダプターをセットするところがこんな感じに変わる arrayAdapter = new DateArrayAdapter(this, R.layout.date_list); arrayAdapter.addAll(listItems); arrayAdapter.setItems(listItems); listView.setAdapter(arrayAdapter);ほかにいい解決法がありそうなら教えていただけると嬉しいです。