/var/log/messages

Feb 24, 2018 - 7 minute read - Comments - android

DroidKaigi2018UnitTestHandsOn 実装掘削メモ

Presenter より下のレイヤについて

unit test の対象となるレイヤについても一応の確認をしておきます。ここより先はドキュメントとしての unit test を確認したほうが良いと思いますが、構造的な部分について注力して確認をすすめておく形ですすめます。

以下の順で確認を行います

  • UseCase、GetRepositories  - 呼び出し元は Presenter
  • GitHubRepository、GitHubInfraRepository
  • GitHubRemoteDatasource、GitHubLocalDataSource

UseCase あたりは domain 配下でそれより下は infrastructure 配下にて定義されています。順に確認をすすめます。

UseCase

以下な形で RepoListPresenter にて使用されています。

  @Override
  public void getRepositoryList(@NonNull String account) {
    view.showProgressBar();
    ticket = getRepositories.enqueue(GetRepositories.Param.newInstance(account));
  }

  @Override
  public void refreshRepositoryList(@NonNull String account) {
    ticket = getRepositories.enqueue(GetRepositories.Param.newInstance(account, true));
  }

先ず、基底クラスの UseCase から確認します。定義が以下。

public abstract class UseCase<PARAM> {
  @NonNull
  private final TaskQueue taskQueue;

  @NonNull
  protected final Bus bus;

  protected UseCase(@NonNull TaskQueue taskQueue, @NonNull Bus bus) {
    this.taskQueue = taskQueue;
    this.bus = bus;
  }

  @NonNull
  public TaskTicket enqueue(PARAM param) {
    Task task = buildTask(param);
    Future<?> future = taskQueue.enqueue(task);
    return new TaskTicket(task.getId(), future);
  }

  protected abstract Task buildTask(PARAM param);
}

インターフェースを提供して buildTask の実装はサブクラスに、という形です。GetRepositories の定義を順に確認します。

public class GetRepositories extends UseCase<GetRepositories.Param> {
  @NonNull
  private final GitHubRepository gitHubRepository;

  @NonNull
  private final RepositoryMapper repositoryMapper;

  public GetRepositories(@NonNull TaskQueue taskQueue, @NonNull Bus bus, @NonNull GitHubRepository gitHubRepository, @NonNull RepositoryMapper repositoryMapper) {
    super(taskQueue, bus);
    this.gitHubRepository = gitHubRepository;
    this.repositoryMapper = repositoryMapper;
  }

@NonNull な属性二点 (GitHubRepository 型、RepositoryMapper 型) とコンストラクタの定義です。RepositoryMapper については別途確認の方向です。

次に定義されているのは buildTask メソッドです。定義を以下に引用します。

  @Override
  protected Task buildTask(Param param) {
    return new Task() {
      @Override
      public void run() {
        try {
          List<RepositoryEntity> entities = param.refresh
              ? gitHubRepository.refreshUserRepositories(param.account)
              : gitHubRepository.getUserRepositories(param.account);
          List<Repository> repositories = repositoryMapper.convertList(entities);
          bus.post(new OnSuccessGetRepositories(repositories));
        } catch (ApiException e) {
          GitHubErrorEntity gitHubErrorEntity = e.getGitHubErrorEntity();
          String message = gitHubErrorEntity == null
              ? e.getMessage()
              : gitHubErrorEntity.getMessage();
          bus.post(new OnFailureGetRepositories(new Exception(message)));
        } catch (Exception e) {
          bus.post(new OnFailureGetRepositories(e));
        }
      }
    };
  }

基本的には param.refresh の値によって

  • GitHubRepository#refreshUserRepositories
  • GitHubRepository#getUserRepositories

のどちらかを呼び出してその戻りを entities に格納し、RepositoryMapper#convertList により List に変換して callback する形です。以下のいずれも

  • OnSuccessGetRepositories
  • OnFailureGetRepositories

RepoListPresenter にて定義されています (引用は略します)。また、このクラス定義ファイルにおいては

  • Factory クラス
  • PARAM の定義

の記述がありますが、詳細は略します。また、GetRepositories の試験において確認されている事項を以下に列挙しておきます。

  • GetRepositories#enqueue の呼び出し時、TaskQueue#enqueue が一度呼び出されていることの確認
  • GetRepositories.Param.newInstance("srym") により作成したオブジェクトを使って GetRepositories#buildTask().run() した時に依存しているライブラリ手続きが呼び出されていることの確認 (詳細略)
  • エラーとなる状態を作った上で GetRepositories#buildTask().run() を呼び出して想定しているライブラリ手続きが呼び出されていることの確認
  • buildTask に渡すパラメータの refresh が true の場合に想定しているライブラリ手続きが呼び出されていることの確認
  • buildTask に渡すパラメータの refresh が true の場合のエラー処理において想定しているライブラリ手続きが呼び出されていることの確認

Repository

interface は datasource/GitHubRepository で定義が以下です。

public interface GitHubRepository {
  @NonNull
  List<RepositoryEntity> getUserRepositories(@NonNull String account) throws IOException, ApiException;

  List<RepositoryEntity> refreshUserRepositories(@NonNull String account) throws IOException, ApiException;
}

これを実装しているのが datasource/GitHubInfraRepository になります。実装の引用は略します。属性は以下二点で @NonNull です。

  • private final GitHubRemoteDataSource remoteDataSource
  • private final GitHubLocalDataSource localDataSource

これらは両方コンストラクタにて値が設定されます。また、以降にて定義されているのは @Override な以下二点のメソッドと

  • public List getUserRepositories(@NonNull String account)
  • public List refreshUserRepositories(@NonNull String account)

プライベートな変換メソッド二点と

  • private RoomRepositoryEntity entityToRoom(RepositoryEntity)
  • private RoomAccountEntity entityRoom(AccountEntity)

Factory クラスとなっています。singleton クラスというわけではなく、生成要求があれば都度オブジェクトを生成する形に見えます。また、このクラスの試験において確認されている事項を以下に列挙しておきます。

  • ローカルデータソースにデータが存在する場合のテスト、この場合リモートデータソースの呼び出しは行われないことも検証
  • ローカルデータソースもリモートデータソースも空の場合のテスト、呼び出されないメソッドなどの確認なども行う
  • ローカルデータソースは空でリモートデータソースが結果を戻す場合のテスト、リモートデータソース取得時にはローカルデータソースへの書き込みも行われるため、その検証も行う
  • リモートデータソースが空を戻す場合のテスト
  • リモートデータソースが値を戻す場合のテスト

Datasource

先ず、datasource/local/ 側から確認をすすめていきます。GitHubLocalDataSource は interface 定義となっています。以下に引用します。

public interface GitHubLocalDataSource {
  List<RepositoryEntity> getUserRepositories(@NonNull String account);

  void insertRepositoriesAndAccounts(@NonNull List<RoomRepositoryEntity> repositories, @NonNull List<RoomAccountEntity> accounts);

  void deleteAndInsertRepositoriesAndAccounts(@NonNull String account, @NonNull List<RoomRepositoryEntity> repositories, @NonNull List<RoomAccountEntity> accounts);
}

この interaface を実装しているのが GitHubRoomDataSource です。属性は @NonNull でコンストラクタにて設定されています。

public class GitHubRoomDataSource implements GitHubLocalDataSource {
  @NonNull
  private final AppDatabase db;

  @NonNull
  private final RepositoryEntityMapper repositoryEntityMapper;

  public GitHubRoomDataSource(@NonNull AppDatabase db, @NonNull RepositoryEntityMapper repositoryEntityMapper) {
    this.db = db;
    this.repositoryEntityMapper = repositoryEntityMapper;
  }

以降は interface で実装を強制されているメソッドの定義となります。

  @Override
  public List<RepositoryEntity> getUserRepositories(@NonNull String account) {
    List<RepoWithAccount> repoWithAccountList = db.repositoryDao().findByAccount(account);
    return repositoryEntityMapper.convertList(repoWithAccountList);
  }

  @Override
  public void insertRepositoriesAndAccounts(@NonNull List<RoomRepositoryEntity> repositories, @NonNull List<RoomAccountEntity> accounts) {
    db.repositoryDao().insertRepositoriesAndAccounts(repositories, Stream.of(accounts).distinct().toList());
  }

  @Override
  public void deleteAndInsertRepositoriesAndAccounts(@NonNull String account, @NonNull List<RoomRepositoryEntity> repositories, @NonNull List<RoomAccountEntity> accounts) {
    db.repositoryDao().deleteAndInsertRepositoriesAndAccounts(account, repositories, Stream.of(accounts).distinct().toList());
  }
}

以下に機能と用途を列挙します。基本的にデータのやりとりが発生するのはローカルにある SQLite となります。

  • getUserRepositories はデータを取得して RepositoryEntity のリストに変換して戻します
  • insertRepositoriesAndAccounts は Repositories および Accounts というテーブルにデータを insert する処理
  • deleteAndInsertRepositoriesAndAccounts は指定されたデータを一旦消去して再度 insert する手続きとなります

ここで Room に関するざっくりとした確認をしておきます。先ず room/db/AppDataBase にて dao と言うかたちでデータベースに存在するテーブルの定義をしておきます。

AppDatabase.java

@Database(entities = {RoomAccountEntity.class, RoomRepositoryEntity.class}, version = 1, exportSchema = false)
@TypeConverters({DateConverter.class})
public abstract class AppDatabase extends RoomDatabase {
  public abstract RoomAccountDao accountDao();

  public abstract RoomRepositoryDao repositoryDao();
}

dao ではデータ操作の方法を以下のような形で規定します。

@Dao
public abstract class RoomRepositoryDao {
  @Query("SELECT * FROM repository")
  public abstract List<RoomRepositoryEntity> getAll();

あるいは insertRepositoriesAndAccounts の定義は以下のようになっています。

  @Transaction
  public void insertRepositoriesAndAccounts(List<RoomRepositoryEntity> repositories, List<RoomAccountEntity> accounts) {
    insertAllRepositories(repositories.toArray(new RoomRepositoryEntity[repositories.size()]));
    insertAllAccounts(accounts.toArray(new RoomAccountEntity[accounts.size()]));
  }

呼び出されている二点の手続きについては同じクラスにて定義済みです。

  @Insert(onConflict = OnConflictStrategy.REPLACE)
  public abstract void insertAllRepositories(RoomRepositoryEntity... repositories);

  @Delete
  public abstract void deleteAllRepositories(RoomRepositoryEntity... repositories);

  @Insert(onConflict = OnConflictStrategy.REPLACE)
  public abstract void insertAllAccounts(RoomAccountEntity... accounts);

また上記の他にオブジェクトとして表現するための Entity クラスと検索結果などを Entity に変換するための Mapper クラスが定義されています。最後に定義されている試験の確認をしておきます。 local 配下には以下の試験があるようです。

  • room/dao/RoomAccountDaoTest
  • room/dao/RoomRepositoryDaoTest
  • room/RoomTest
  • GitHubRoomDataSourceTest

ここがおそらく最下層の試験となっているはずです。順に確認していきます。RoomRepositoryDaoTest から

  • DB に保存された全てのリポジトリデータの取得する getAll を検証
  • ユーザのリポジトリデータを取得する findByAccountId を検証
  • ユーザ ID のユーザのリポジトリデータを取得する findByAccount を検証
  • 指定したリポジトリデータをすべて挿入する insertAllRepositories を検証
  • 指定したリポジトリデータをすべて削除する deleteAllRepositories を検証
  • 指定したユーザ情報をすべて挿入する insertAllAccounts を検証
  • 指定したリポジトリとユーザをすべて挿入する insertRepositoriesAndAccounts を検証
  • 指定したアカウントが持つリポジトリ情報をすべて消して後から追加する deleteAndInsertRepositoriesAndAccounts を検証

次は RoomAccountDaoTest です。

  • DB に保存された全てのユーザデータの取得する getAll を検証
  • ユーザ ID のユーザのユーザデータを取得する findByAccount を検証
  • 指定したユーザデータをすべて挿入する insertAll を検証
  • 指定したユーザデータをすべて削除する deleteAll を検証

次は RoomTest です。

  • ユーザデータを格納する accountDao の取得およびアクセサの動作確認を行う account_dao_test
  • リポジトリデータを格納する repositoryDao の取得およびアクセサの動作確認を行う repository_dao_test
  • ユーザデータとリポジトリデータの連携を確認する repo_with_account_test
  • RoomRepositoryDao#insertRepositoriesAndAccounts の動作確認を行う deleteAndInsert

最後に GitHubRoomDataSourceTest です。

  • GitHubRoomDataSource#getUserRepositories(String) を呼ぶと内部で RoomRepositoryDao#findByAccount(String) が呼ばれることを検証し、RepositoryEntityMapper#convertList(List) が内部で呼ばれることも検証
  • GitHubRoomDataSource#insertRepositoriesAndAccounts(List, List) を呼ぶと内部で RepositoryDao#insertRepositoriesAndAccounts(List, List) が呼ばれることを検証
  • GitHubRoomDataSource#deleteAndInsertRepositoriesAndAccounts(String, List, List) を呼ぶと内部で RoomRepositoryDao#deleteAndInsertRepositoriesAndAccounts(String, List, List) が呼ばれることを検証

次は datasource/remote 側の実装の確認を行っていきます。こちらも GitHubRemoteDataSource から確認を。こちらも interface な定義になっています。

public interface GitHubRemoteDataSource {
  @NonNull
  List<RepositoryEntity> getUserRepositories(@NonNull String account) throws IOException, ApiException;
}

これを実装しているのが GitHubRestDataSource で定義の先頭部分から確認すすめます。

public class GitHubRestDataSource extends BaseRestDataSource<List<RepositoryEntity>> implements GitHubRemoteDataSource {
  @NonNull
  private final GitHubService gitHubService;

  public GitHubRestDataSource(@NonNull Gson gson, @NonNull GitHubService gitHubService) {
    super(gson);
    this.gitHubService = gitHubService;
  }

GitHubService 型の属性は @NonNull でコンストラクタで渡されています。後は実装を強制されているメソッドと sort key と並び順な enum になっています。メソッド定義は以下で

  @NonNull
  @Override
  public List<RepositoryEntity> getUserRepositories(@NonNull String account) throws IOException, ApiException {
    Response<List<RepositoryEntity>> response = gitHubService.getUserRepositories(
        account,
        Sort.UPDATED.value,
        Direction.DESC.value
    ).execute();
    return stripResult(response);
  }

って、このあたりで派手に道に迷っていたのですが、そもそも GitHubInfraRepository の Factory method にて GitHubRemoteDataSource が生成されているのでした。

        GitHubRemoteDataSource remote = new GitHubRestDataSource(
            GsonProvider.INSTANCE.get(),
            RetrofitProvider.INSTANCE.get().create(GitHubService.class)
        );

ええと、GitHubService 型な属性には datasource/remote/service 配下に定義されている RetrofitProvider の instance 属性 (Retrofit 型) の create が戻すオブジェクトが、という事なのか。

そしてそのオブジェクトであるところの GitHubServicedatasource/remote/service 配下にて定義されています。interface ですね。以下に定義を引用します。

public interface GitHubService {
  @GET("users/{user}/repos")
  Call<List<RepositoryEntity>> getUserRepositories(
      @Path("user") String user,
      @Query("sort") String sort,
      @Query("direction") String direction
  );
}

このあたりはこんなもんだ、って理解で良いのかどうか。別途で再確認した方が良さげですね。このまますすめます。

あと、以下な記述を再度引用するんですが

    Response<List<RepositoryEntity>> response = gitHubService.getUserRepositories(
        account,
        Sort.UPDATED.value,
        Direction.DESC.value
    ).execute();

execute しないと非同期通信しない、という理解で良いのかどうか (別途確認

要確認

  • RepositoryMapper
  • RepositoryEntityMapper
  • Retrfit のあたり諸々

DroidKaigi2018UnitTestHandsOn 実装掘削メモ django いいな

comments powered by Disqus