我最近跳进了Android单元测试,但我仍然在编写单元测试时苦苦挣扎。我正在尝试测试我的演示器,特别是一种从 Github API 返回存储库列表的方法,但我不断收到空指针异常,我不明白为什么。
存储库演示器方法 我想进行单元测试:
public void presenterLoadRepos(boolean onlineRequired, String owner) {
// Clear old data on view
view.clearRepos();
//recovering access token data from Shared Preferences
String accessTokenString = repository.getAccessTokenString();
String accessTokenTypeString = repository.getAccessTokenType();
if(onlineRequired){
Disposable disposable = repository.loadRemoteRepos(owner, accessTokenString, accessTokenTypeString, PER_PAGE_VALUE) //this is line 188
.subscribeOn(ioScheduler) //this is line 189
.observeOn(uiScheduler)
.subscribe(this::handleReturnedData, this::handleError, () -> view.stopLoadingIndicator());
disposeBag.add(disposable);
}else {
Disposable disposable = repository.loadLocalRepos(owner, accessTokenString, accessTokenTypeString, PER_PAGE_VALUE)
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(this::handleReturnedData, this::handleError, () -> view.stopLoadingIndicator());
disposeBag.add(disposable);
}
}
整个存储库演示器类:
public class RepositoriesPresenter implements RepositoriesContract.Presenter, LifecycleObserver {
private static final String TAG = RepositoriesPresenter.class.getSimpleName();
private GitHubChallengeRepository repository;
private RepositoriesContract.View view;
private Scheduler ioScheduler;
private Scheduler uiScheduler;
private CompositeDisposable disposeBag;
@Inject
public RepositoriesPresenter(GitHubChallengeRepository repository, RepositoriesContract.View view,
@RunOn(IO) Scheduler ioScheduler, @RunOn(UI) Scheduler uiScheduler) {
this.repository = repository;
this.view = view;
this.ioScheduler = ioScheduler;
this.uiScheduler = uiScheduler;
// Initialize this presenter as a lifecycle-aware when a view is a lifecycle owner.
if (view instanceof LifecycleOwner) {
((LifecycleOwner) view).getLifecycle().addObserver(this);
}
disposeBag = new CompositeDisposable();
}
@Override @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) public void onAttach() {
presenterLoadRepos(false, view.getOwner());
}
@Override @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) public void onDetach() {
// Clean up any no-longer-use resources here
disposeBag.clear();
}
@Override
public void checkRepoPerUser(String owner) {
//recovering access token data from Shared Preferences;
String accessTokenString = repository.getAccessTokenString();
String accessTokenTypeString = repository.getAccessTokenType();
//Asking for a list of repositories with 1 repository per page.
//This let us know how many repositories we found and also to deal with error response code
Disposable disposable = repository.checkReposPerUser(owner, accessTokenString, accessTokenTypeString, "1")
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(this::handleReturnedHeaderData, this::handleHeaderError);
disposeBag.add(disposable);
}
@VisibleForTesting
private void handleReturnedHeaderData(Response<List<Headers>> response) {
//getting value 'Link' from response headers in order to count the repositories
String link = response.headers().get("Link");
String message = response.message();
//checking GitHub API requests limit
String limit = response.headers().get("X-RateLimit-Limit");
Log.d(TAG, "Limit requests: " + limit);
String limitRemaining = response.headers().get("X-RateLimit-Remaining");
Log.d(TAG, "Limit requests remaining: " + limitRemaining);
//getting http response code
int code = response.code();
switch (code){
case 404:
if(message.equalsIgnoreCase("not found")){ //User not exists
view.showUserNotFoundMessage();
}else{
view.showErrorMessage(message);
}
break;
case 403:
//GitHub API requests limit reached
//Instead of showing an error, we start the login process,
// store another access token in shared Preferences and resend the same request that failed before
view.startLogin();
break;
case 200:
if(link == null){ //Link value is not present into the header, it means there's 0 or 1 repo
Log.d(TAG, "Total repos for current user is 0 or 1.");
//get the repository
searchRepo(view.getOwner()); //Starting looking for data
}else if( link != null){
//get last page number: considering that we requested all the repos paginated with
//only 1 repo per page, the last page number is equal to the total number of repos
String totalRepoString = link.substring(link.lastIndexOf("&page=") + 6, link.lastIndexOf(">"));
Log.d(TAG, "Total repos for current user are " + totalRepoString);
// TODO once we know how many repositories we have, we can decide how many calls to do (total repositories/100 rounded up )
//get the repositories
searchRepo(view.getOwner()); //Starting 3 looking for data
}
break;
default:
searchRepo(view.getOwner()); //Starting 3 looking for data
break;
}
}
private void handleHeaderError(Throwable error) {
Log.e(TAG, error.getMessage(), error);
view.showErrorMessage(error.getLocalizedMessage());
}
@Override public void searchRepo(final String owner) {
view.showProgressBarIfHidden();
//recovering access token data from Shared Preferences
String accessTokenString = repository.getAccessTokenString();
String accessTokenTypeString = repository.getAccessTokenType();
// Load new one and populate it into view
Disposable disposable = repository.loadRemoteRepos(owner, accessTokenString, accessTokenTypeString, "100")
.flatMap(Observable::fromIterable)
.filter(repo -> repo.getName() != null)
.toList()
.toObservable()
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(repos -> {
if (repos.isEmpty()) {
// Clear old data from recycler view
view.clearRepos();
// Show notification
view.showEmptySearchResult();
} else {
// Update recycler view items
view.showRepos(repos);
}
});
disposeBag.add(disposable);
}
public void presenterLoadRepos(boolean onlineRequired, String owner) {
// Clear old data on view
view.clearRepos();
//recovering access token data from Shared Preferences
String accessTokenString = repository.getAccessTokenString();
String accessTokenTypeString = repository.getAccessTokenType();
if(onlineRequired){
Disposable disposable = repository.loadRemoteRepos(owner, accessTokenString, accessTokenTypeString, "100")
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(this::handleReturnedData, this::handleError, () -> view.stopLoadingIndicator());
disposeBag.add(disposable);
}else {
// Load new repositories and paginate them with 100 (GitHub API max) repositories par page.
Disposable disposable = repository.loadLocalRepos(owner, accessTokenString, accessTokenTypeString, "100")
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(this::handleReturnedData, this::handleError, () -> view.stopLoadingIndicator());
disposeBag.add(disposable);
}
}
/**
* Updates view after loading data is completed successfully.
*/
private void handleReturnedData(List<Repo> list) {
view.stopLoadingIndicator();
if (list != null && !list.isEmpty()) {
view.showRepos(list);
} else {
view.showNoDataMessage();
}
}
/**
* Updates view if there is an error after loading data from repository.
*/
private void handleError(Throwable error) {
if(error.getMessage().equalsIgnoreCase("http 403 forbidden")){
view.startLogin();
}else {
view.stopLoadingIndicator();
view.showErrorMessage(error.getLocalizedMessage());
}
}
@Override public void getRepo(int repoId) {
Disposable disposable = repository.getRepo(repoId)
.filter(repo -> repo != null)
.subscribeOn(ioScheduler)
.observeOn(uiScheduler)
.subscribe(repo -> view.showRepositoryDetail(repo));
disposeBag.add(disposable);
}
}
存储库演示器测试:
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
//other imports omitted
@RunWith(MockitoJUnitRunner.class)
public class RepositoriesPresenterTest {
private static final Repo REPO1 = new Repo();
private static final Repo REPO2 = new Repo();
private static final Repo REPO3 = new Repo();
private static final List<Repo> NO_REPOS = Collections.emptyList();
private static final List<Repo> THREE_REPOS = Arrays.asList(REPO1, REPO2, REPO3);
public static final String OWNER = "owner";
public static final String ACCESS_TOKEN_STRING = "access_token_string";
public static final String ACCESS_TOKEN_TYPE = "access_token_type";
public static final String PER_PAGE_VALUE = "per_page_value";
@Mock private GitHubChallengeRepository repositoryMock;
@Mock private RepositoriesContract.View viewMock;
private TestScheduler testScheduler;
private RepositoriesPresenter SUT; //System Under Test
@Before public void setUp() {
MockitoAnnotations.initMocks(this);
testScheduler = new TestScheduler();
SUT = new RepositoriesPresenter(repositoryMock, viewMock, testScheduler, testScheduler);
}
@Test public void repoPresenter_reposReturned_showReposOnViewExpected() {
// Given
given(repositoryMock.loadRemoteRepos( //this is line 128
OWNER,
ACCESS_TOKEN_STRING,
ACCESS_TOKEN_TYPE,
PER_PAGE_VALUE)).willReturn(Observable.just(THREE_REPOS));
// When
SUT.presenterLoadRepos(true, OWNER); //this is line 135
testScheduler.triggerActions();
// Then
then(viewMock).should().showRepos(THREE_REPOS);
then(viewMock).should(atLeastOnce()).stopLoadingIndicator();
}
}
这是我运行测试时得到的:
java.lang.NullPointerException
at link.mgiannone.githubchallenge.ui.repositories.RepositoriesPresenter.presenterLoadRepos(RepositoriesPresenter.java:189)
at link.mgiannone.githubchallenge.ui.repositories.RepositoriesPresenterTest.repoPresenter_reposReturned_showReposOnViewExpected(RepositoriesPresenterTest.java:138)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.mockito.internal.runners.DefaultInternalRunner$1.run(DefaultInternalRunner.java:68)
at org.mockito.internal.runners.DefaultInternalRunner.run(DefaultInternalRunner.java:74)
at org.mockito.internal.runners.StrictRunner.run(StrictRunner.java:39)
at org.mockito.junit.MockitoJUnitRunner.run(MockitoJUnitRunner.java:161)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:47)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:242)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMainV2.main(AppMainV2.java:131)
[MockitoHint] RepositoriesPresenterTest.repoPresenter_reposReturned_showReposOnViewExpected (see javadoc for MockitoHint):
[MockitoHint] 1. Unused... -> at link.mgiannone.githubchallenge.ui.repositories.RepositoriesPresenterTest.repoPresenter_reposReturned_showReposOnViewExpected(RepositoriesPresenterTest.java:131)
[MockitoHint] ...args ok? -> at link.mgiannone.githubchallenge.ui.repositories.RepositoriesPresenter.presenterLoadRepos(RepositoriesPresenter.java:188)
Process finished with exit code 255
从堆栈跟踪来看,我似乎没有使用参数,我做错了什么?
在演示器中,调用loadRemoteRepos
的perPageValue
为 "100"
,但在测试中,repositoryMock
的given
部分仅与参数值匹配 "per_page_value"
。
将最后一个参数与anyString
匹配(在这种情况下,所有其他参数都应包装在eq
匹配器中),或者在测试中使用与表示器代码中相同的值,或者通过表示器的构造函数注入值。
由于您的存储库是一个模拟实例,因此您需要模拟其所有方法的响应。这包括以下两个:
String accessTokenString = repository.getAccessTokenString();
String accessTokenTypeString = repository.getAccessTokenType();
所以你也需要给他们给出的陈述。线上的东西:
given(repositoryMock.getAccessTokenString()).willReturn("A string")
given(repositoryMock.getAccessTokenType()).willReturn("A string")