Twitterのフォロー一覧を取得してテストする
やったこと。
- Twitter4Jで自身のフォロー一覧を取得
実装の手順
- TwitterUserUseCaseインターフェースを定義
suspend fun getFollowUsers(...) : List<UserEntity>
だけ定義 - TwitterUserUseCaseを実装したTwitterUserInteractorクラスを作成
この時点では、getFollowUsers
の返りは単にreturn listOf()
とかでいい - TwitterUserInteractorTestを作成
class TwitterUserInteractor
にカーソルあわせてAlt+Enter
とかでtest
配下にテストクラス生成。test
配下に手動で作ってもいい。
TwitterUserUseCase
まずはTwitterUserUseCase
インターフェースを定義する。
interface TwitterUserUseCase { suspend fun getFollowUsers(userId: Long, listener: OnNotifyProgressListener? = null): List<UserEntity> // 進捗通知用のリスナー interface OnNotifyProgressListener { suspend fun onNotifyFollowUsersCount(count: Int) suspend fun onNotifyProgress(arg: Int) } } data class UserEntity { // Twitter4JのUserをラップするだけのデータクラス }
OnNotifyProgressListener
は無くても全然構わない。
ユーザを取得したときにプログレスバーとかに反映させる用。
TwitterAPIとの通信で別スレッドで実行する必要があるため、suspend
修飾子を付ける。
TwitterUserInteractor
次にTwitterUserUseCase
を実装したInteractorを作成する。
この段階では、ただ空のリストを返すだけの実装で十分。
class TwitterUserInteractor( private val context: Context, private val twitter: Twitter ) : TwitterUserUseCase { // UseCase で定義したメソッド override suspend fun getFollowUsers(userId: Long, listener: ...): List<UserEntity> { // とりあえず空のリストを返すだけの実装 return listOf() } }
次にテストを作成する。
TwitterUserInteractorTest
テストを書く! RobolectricとAssertJ使っています。
// context が必要なので RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class TwitterUserInteractorTest : CoroutineScope { override val coroutineContext: CoroutineContext get() = Dispatchers.Default + Job() // InstrumentationRegistryのimportがうまくいかない場合は、 // キャッシュ削除して再起動(File→Invalidate Caches Restart)するといいかも private val context = InstrumentationRegistry.getInstrumentation().context private var accessToken: AccessToken? = null private lateinit var twitter: Twitter private lateinit var useCase: TwitterUserUseCase private val userId = 3243831438 // kcpoipoiのユーザID // Coroutine でバックグラウンド処理をするため用意 private lateinit var latch: CountDownLatch @Before fun setUp() { // Twitterインスタンスの初期化など // なんかもうちょい良い書き方がある気がする accessToken = if (BuildConfig.IS_DEBUG) { AccessToken("token", "tokenSecret") } else { null } twitter = TwitterUtils.getTwitter(context, accessToken) useCase = TwitterUserInteractor(context, twitter) } @Test fun getFollowUsers() { latch = CountDownLatch(1) // Coroutine起動 launch { // ユーザ取得の開始、リスナーのセット val users = useCase.getFollowUsers(userId, object : TwitterUserUseCase.OnNotifyProgressListener { override suspend fun onNotifyFollowUsersCount(count: Int) { if (BuildConfig.IS_DEBUG) { println(LogDecorator.decoCyan("onNotifyFollowUsersCount = $count")) } // ユーザ総数が合っているか検査 assertThat(count) .isEqualTo(USERS) } override suspend fun onNotifyProgress(arg: Int) { if (BuildConfig.IS_DEBUG) { println(LogDecorator.decoGreen("onNotifyProgress = $arg")) } // 進捗がゼロでないか検査 assertThat(arg) .isNotZero() } }) // ユーザリストの件数を検査 assertThat(users) .isNotEmpty .hasSize(USERS) latch.countDown() } latch.await() } companion object { // R1年12月29日時点でのわいのフォロー数 private const val USERS = 2101 } }
この雑な検査を通過するようにTwitterUserInteractor
を実装していく。
TwitterUserInteractor最終形
最終形には次のようになる。 チョットナガイ。
class TwitterUserInteractor( private val context: Context, private val twitter: Twitter ) : TwitterUserUseCase { // UseCase で定義したメソッド override suspend fun getFollowUsers(userId: Long, listener: ...): List<UserEntity> { // ページング処理用の cursor は必ず初期値を -1L にする val cursor = -1L // userId をレートリミットに達するまで取得。再帰的に処理 val idsList = getFriendsIDs(userId, cursor, mutableListOf()) // ユーザ総数を通知 listener?.onNotifyFollowUsersCount(idsList.size) // ユーザがゼロなら空のリストを返す if (idsList.isEmpty()) { return listOf() } // userId のリストから User オブジェクトのリストを取得。再帰的に処理 return getUsers(idsList, 0, mutableListOf(), listener) } private suspend fun getFriendsIDs( userId: Long, cursor: Long, idsList: MutableList<Long> ): List<Long> { val ids = twitter.getFriendsIDs(userId, cursor) if (ids != null) { idsList.addAll(ids.iDs.toList()) val rateLimit = ids.rateLimitStatus return when { // レートリミットに達したらその時点で返す rateLimit.remaining <= 0 -> { idsList } // cursorをシフトして再帰的に呼び出し ids.hasNext() -> { getFriendsIDs(userId, ids.nextCursor, idsList) } // ids.hasNext == false で返す else -> { idsList } } } else { return idsList } } @Suppress("ConstantConditionIf") private suspend fun getUsers( ids: List<Long>, startIndex: Int, users: MutableList<UserEntity>, listener: TwitterUserUseCase.OnNotifyProgressListener? ): List<UserEntity> { // Userオブジェクトを取得する lookupUsers は一度に100件までしか処理できない val endIndex = if (startIndex + 99 > ids.lastIndex) { ids.lastIndex } else { startIndex + 99 } // ユーザIDリストを100件スライス val idsSlice = ids.slice(startIndex..endIndex) // Userオブジェクトを取得 val userResponseList = twitter.lookupUsers(*idsSlice.toLongArray()) // 生のUserオブジェクトを扱いやすいようUserEntityにmap val userEntities = userResponseList .map { user -> val name = Normalizer.normalize(user.name, Normalizer.Form.NFKC) if (BuildConfig.IS_DEBUG) { println(name) } UserEntity( 0, user.id, name, user.screenName, user.profileImageURL, user.description, "") } // リストに追加 users.addAll(userEntities) // 進捗を通知 listener?.onNotifyProgress(users.size) val rateLimit = userResponseList.rateLimitStatus return when { rateLimit.remaining <= 0 || endIndex == ids.lastIndex -> { // レートリミットに達するか、全件読み終えたら return users } else -> { // 再帰的に呼び出し getUsers(ids, endIndex + 1, users, listener) } } } }
再帰呼び出しチョット頑張った。
所感
- Coroutineで外部との通信処理がものすごい簡潔に(同期的に)書ける。
- 再帰呼び出しにすることでページング処理も楽に。
Coroutine中断時の処理とかも実装しなければ。