使用Retrofit和RxJava进行Android应用程序单元测试

22
我开发了一款使用Retrofit和RxJava的Android应用程序,现在我正在尝试使用Mockito设置单元测试,但我不知道如何模拟API响应,以便创建不进行真实调用但具有虚假响应的测试。
例如,我想测试SplashPresenter中的syncGenres方法是否正常工作。我的类如下:
public class SplashPresenterImpl implements SplashPresenter {

private SplashView splashView;

public SplashPresenterImpl(SplashView splashView) {
    this.splashView = splashView;
}

@Override
public void syncGenres() {
    Api.syncGenres(new Subscriber<List<Genre>>() {
        @Override
        public void onError(Throwable e) {
            if(splashView != null) {
                splashView.onError();
            }
        }

        @Override
        public void onNext(List<Genre> genres) {
            SharedPreferencesUtils.setGenres(genres);
            if(splashView != null) {
                splashView.navigateToHome();
            }
        }
    });
}
}

Api类似于:

public class Api {
    ...
    public static Subscription syncGenres(Subscriber<List<Genre>> apiSubscriber) {
        final Observable<List<Genre>> call = ApiClient.getService().syncGenres();
        return call
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribe(apiSubscriber);
    }

}

现在我正在尝试测试SplashPresenterImpl类,但我不知道怎么做,我应该做些什么:

public class SplashPresenterImplTest {

@Mock
Api api;
@Mock
private SplashView splashView;

@Captor
private ArgumentCaptor<Callback<List<Genre>>> cb;

private SplashPresenterImpl splashPresenter;

@Before
public void setupSplashPresenterTest() {
    // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
    // inject the mocks in the test the initMocks method needs to be called.
    MockitoAnnotations.initMocks(this);

    // Get a reference to the class under test
    splashPresenter = new SplashPresenterImpl(splashView);
}

@Test
public void syncGenres_success() {

    Mockito.when(api.syncGenres(Mockito.any(ApiSubscriber.class))).thenReturn(); // I don't know how to do that

    splashPresenter.syncGenres();
    Mockito.verify(api).syncGenres(Mockito.any(ApiSubscriber.class)); // I don't know how to do that



}
}

你有没有想过如何模拟和验证API的响应?谢谢!

编辑: 根据@invariant的建议,现在我把一个客户端对象传递给我的Presenter,这个API返回一个Observable而不是Subscription。然而,当进行API调用时,我的Subscriber会出现NullPointerException。测试类如下:

public class SplashPresenterImplTest {
@Mock
Api api;
@Mock
private SplashView splashView;

private SplashPresenterImpl splashPresenter;

@Before
public void setupSplashPresenterTest() {
    // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
    // inject the mocks in the test the initMocks method needs to be called.
    MockitoAnnotations.initMocks(this);

    // Get a reference to the class under test
    splashPresenter = new SplashPresenterImpl(splashView, api);
}

@Test
public void syncGenres_success() {
    Mockito.when(api.syncGenres()).thenReturn(Observable.just(Collections.<Genre>emptyList()));


    splashPresenter.syncGenres();


    Mockito.verify(splashView).navigateToHome();
}
}

为什么会出现NullPointerException?

非常感谢!

6个回答

39

如何测试 RxJava 和 Retrofit

1. 消除静态调用 - 使用依赖注入

您代码中的第一个问题是使用了静态方法。这不是易于测试的架构,因为它使得更难模拟实现。 要正确地进行操作,而不是使用访问 ApiClient.getService()Api,应通过构造函数将此服务注入到Presenter中:

public class SplashPresenterImpl implements SplashPresenter {

private SplashView splashView;
private final ApiService service;

public SplashPresenterImpl(SplashView splashView, ApiService service) {
    this.splashView = splashView;
    this.apiService = service;
}

2. 创建测试类

实现您的JUnit测试类,并在@Before方法中使用模拟依赖项初始化Presenter:

public class SplashPresenterImplTest {

@Mock
ApiService apiService;

@Mock
SplashView splashView;

private SplashPresenter splashPresenter;

@Before
public void setUp() throws Exception {
    this.splashPresenter = new SplashPresenter(splashView, apiService);
}

3. 模拟和测试

接下来是真正的模拟和测试,例如:

@Test
public void testEmptyListResponse() throws Exception {
    // given
    when(apiService.syncGenres()).thenReturn(Observable.just(Collections.emptyList());
    // when
    splashPresenter.syncGenres();
    // then
    verify(... // for example:, verify call to splashView.navigateToHome()
}

这样你就可以测试 Observable + Subscription。如果要测试 Observable 是否正确运行,可以使用 TestSubscriber 实例进行订阅。


故障排除

在使用 RxJava 和 RxAndroid 调度器(如 Schedulers.io()AndroidSchedulers.mainThread())进行测试时,您可能会遇到一些问题来运行您的可观察/订阅测试。

空指针异常

第一个问题是在应用给定调度器的代码行上抛出 NullPointerException,例如:

.observeOn(AndroidSchedulers.mainThread()) // throws NPE

问题的原因是AndroidSchedulers.mainThread()内部使用了android的Looper线程,也就是一个LooperScheduler。但在JUnit测试环境中并没有这个依赖项,所以调用会导致NullPointerException。

竞态条件

第二个问题是,如果应用程序使用单独的工作线程来执行可观察对象,则在执行 @Test 方法的线程和该工作线程之间会发生竞态条件。通常结果是测试方法在可观察对象执行完成之前返回。

解决方案

这两个问题可以通过提供符合测试要求的调度程序来轻松解决,以下是几个选项:

  1. 使用RxJavaHooksRxAndroidPlugins API重写对Schedulers.?AndroidSchedulers.?的任何调用,强制Observable使用例如Scheduler.immediate()

    @Before
    public void setUp() throws Exception {
            // Override RxJava schedulers
            RxJavaHooks.setOnIOScheduler(new Func1<Scheduler, Scheduler>() {
                @Override
                public Scheduler call(Scheduler scheduler) {
                    return Schedulers.immediate();
                }
            });
    
            RxJavaHooks.setOnComputationScheduler(new Func1<Scheduler, Scheduler>() {
                @Override
                public Scheduler call(Scheduler scheduler) {
                    return Schedulers.immediate();
                }
            });
    
            RxJavaHooks.setOnNewThreadScheduler(new Func1<Scheduler, Scheduler>() {
                @Override
                public Scheduler call(Scheduler scheduler) {
                    return Schedulers.immediate();
                }
            });
    
            // Override RxAndroid schedulers
            final RxAndroidPlugins rxAndroidPlugins = RxAndroidPlugins.getInstance();
            rxAndroidPlugins.registerSchedulersHook(new RxAndroidSchedulersHook() {
                @Override
                public Scheduler getMainThreadScheduler() {
                    return Schedulers.immediate();
            }
        });
    }
    
    @After
    public void tearDown() throws Exception {
        RxJavaHooks.reset();
        RxAndroidPlugins.getInstance().reset();
    }
    

    这段代码必须包装Observable测试,以便可以在@Before@After中完成,如所示,它可以放入JUnit @Rule或放置在代码的任何位置。只是不要忘记重置钩子。

  2. 第二个选项是通过依赖注入向类(Presenter、DAO)提供显式的Scheduler实例,然后再使用Schedulers.immediate()(或其他适用于测试的实例)。

  3. 正如@aleien指出的那样,您还可以使用注入的RxTransformer实例来执行Scheduler应用程序。

我已经在生产中使用了第一种方法,并取得了良好的结果。


2
为了进行错误测试,您可以使用Observable.error(Throwable throwable)工厂方法,例如when(apiService.syncGenres()).thenReturn(new RuntimeException());。如果您想更具体地描述错误,请使用Retrofit的HttpException - maciekjanusz
嗨@maciekjanusz,我正在尝试做这件事,但是我收到了这个错误:ScalarSynchronousObservable无法通过getApi()返回。我已经按照您所示修改了RxJavaHooks和RxAndroidPlugin。我该怎么办? - Lücks
@Lücks,这可能是因为错误使用Mockito的API - 请参阅[此答案](https://dev59.com/82gu5IYBdhLWcg3wuJSF#11520080)。如果不是这种情况,我需要更多信息:一些代码片段和可能的库版本。 - maciekjanusz
嗨@maciekjanusz,我已经尝试过了:(我不得不更新我的反应式到1.1.7来修改RxJavaHooks。我正在尝试做这个:when(mUserService.getApi().getUsers()).thenReturn(rx.Observable.just(Collections.<User>emptyList())); - Lücks
@maciekjanusz,我创建了一个新问题,你能帮我吗?http://stackoverflow.com/questions/41537482/unittesting-rxjava-methods - Lücks
显示剩余8条评论

2

将您的syncGenres方法返回类型从Subscription改为Observable。然后,您可以模拟此方法以返回Observable.just(...)而不是进行真实的API调用。

如果您想在该方法中保留Subscription作为返回值(我不建议这样做,因为它会破坏Observable的可组合性),则需要使该方法不是静态的,并将ApiClient.getService()返回的任何内容作为构造函数参数传递,并在测试中使用模拟服务对象(这种技术称为依赖注入)。


1
@maciekjanusz提供的解决方案非常完美,所述问题出现在使用Schedulers.io()AndroidSchedulers.mainThread()时。@maciekjanusz的答案存在理解上过于复杂且并不是所有人都使用Dagger2(事实应该如此)。另外,我不太确定使用RxJava2时我的RxJavaHooks导入是否有效。
RxJava2的更好解决方案:请将RxSchedulersOverrideRule添加到您的测试包中,并只需在测试类中添加以下行即可。
@Rule
public RxSchedulersOverrideRule schedulersOverrideRule = new RxSchedulersOverrideRule();

就这样,没有其他需要补充的了,你的测试用例现在应该可以正常运行了。


1

你的API方法为什么要返回Subscription?通常情况下,从API方法返回Observable(或Single)更方便(特别是考虑到Retrofit能够生成Observables和Singles而不是调用)。如果没有特殊原因,我建议切换到像这样的返回值:

public interface Api {
    @GET("genres")
    Single<List<Genre>> syncGenres();
    ...
}

所以你对API的调用看起来会像这样:

...
Api api = retrofit.create(Api.class);
api.syncGenres()
   .subscribeOn(Schedulers.io())
   .observeOn(AndroidSheculers.mainThread())
   .subscribe(genres -> soStuff());

以这种方式,您将能够模拟API类并编写以下内容:
List<Genre> mockedGenres = Arrays.asList(genre1, genre2...);
Mockito.when(api.syncGenres()).thenReturn(Single.just(mockedGenres));

还需要考虑到,由于测试不会等待工作线程,因此您将无法在工作线程上测试响应。为了解决这个问题,我建议阅读这些文章(recommend reading these articles),并考虑使用类似调度程序管理器或transformer的东西,以便能够明确告诉演示文稿要使用哪些调度程序(真实的或测试用的)。


非常感谢您的回答。我会看一下您所说的内容。有一个问题,为什么要使用Single而不是Observable?非常感谢。 - FVod
通常你不会从API响应中接收事件序列,而只是一块数据(即使该数据是一个列表(:))。这就是Single的作用-只有一个发出的对象。你可以使用Observable,这也完全可行,但Single更具体地告诉了你的意图-你只等待一个对象。此外,如果你想使用Single.create()创建自己的Single,那么你需要实现更少的方法。简而言之:它比Observable更具体 (: - aleien
非常感谢。您有学习如何使用RxTransformer.java的示例吗? - FVod
你只需将它传递给 presenter,而不是编写 .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())... 使用 .compose(rxTransformer.chainSchedulers()),就像这样:https://gist.github.com/aleien/52a079138156570017d64d2dbd8b627e - aleien

1

我使用以下这些类:

  1. Service
  2. RemoteDataSource
  3. RemoteDataSourceTest
  4. TopicPresenter
  5. TopicPresenterTest

简单的服务:

public interface Service {
    String URL_BASE = "https://guessthebeach.herokuapp.com/api/";

    @GET("topics/")
    Observable<List<Topics>> getTopicsRx();

}

对于远程数据源

public class RemoteDataSource implements Service {

    private Service api;

    public RemoteDataSource(Retrofit retrofit) {


        this.api = retrofit.create(Service.class);
    }


    @Override
    public Observable<List<Topics>> getTopicsRx() {
        return api.getTopicsRx();
    }
}

关键是okhttp3的MockWebServer

这个库使得测试你的应用程序在进行HTTP和HTTPS调用时是否正确变得容易。它允许您指定要返回哪些响应,然后验证请求是否按预期进行。

因为它练习了您的完整HTTP堆栈,所以您可以确信您正在测试所有内容。您甚至可以从真实的Web服务器复制并粘贴HTTP响应,以创建代表性的测试用例。或者测试您的代码是否能够在难以重现的情况下生存,例如500错误或加载缓慢的响应。

使用MockWebServer的方式与使用Mockito等模拟框架相同:

编写模拟。 运行应用程序代码。 验证已经发出了预期的请求。 以下是RemoteDataSourceTest中的完整示例:

public class RemoteDataSourceTest {

    List<Topics> mResultList;
    MockWebServer mMockWebServer;
    TestSubscriber<List<Topics>> mSubscriber;

    @Before
    public void setUp() {
        Topics topics = new Topics(1, "Discern The Beach");
        Topics topicsTwo = new Topics(2, "Discern The Football Player");
        mResultList = new ArrayList();
        mResultList.add(topics);
        mResultList.add(topicsTwo);

        mMockWebServer = new MockWebServer();
        mSubscriber = new TestSubscriber<>();
    }

    @Test
    public void serverCallWithError() {
        //Given
        String url = "dfdf/";
        mMockWebServer.enqueue(new MockResponse().setBody(new Gson().toJson(mResultList)));
        Retrofit retrofit = new Retrofit.Builder()
                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .baseUrl(mMockWebServer.url(url))
                .build();
        RemoteDataSource remoteDataSource = new RemoteDataSource(retrofit);

        //When
        remoteDataSource.getTopicsRx().subscribe(mSubscriber);

        //Then
        mSubscriber.assertNoErrors();
        mSubscriber.assertCompleted();
    }

    @Test
    public void severCallWithSuccessful() {
        //Given
        String url = "https://guessthebeach.herokuapp.com/api/";
        mMockWebServer.enqueue(new MockResponse().setBody(new Gson().toJson(mResultList)));
        Retrofit retrofit = new Retrofit.Builder()
                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create())
                .baseUrl(mMockWebServer.url(url))
                .build();
        RemoteDataSource remoteDataSource = new RemoteDataSource(retrofit);

        //When
        remoteDataSource.getTopicsRx().subscribe(mSubscriber);

        //Then
        mSubscriber.assertNoErrors();
        mSubscriber.assertCompleted();
    }

}

你可以在GitHub这个教程中查看我的示例。

此外,在演示文稿中,你可以看到我使用RxJava进行的服务器调用:

public class TopicPresenter implements TopicContract.Presenter {

    @NonNull
    private TopicContract.View mView;

    @NonNull
    private BaseSchedulerProvider mSchedulerProvider;

    @NonNull
    private CompositeSubscription mSubscriptions;

    @NonNull
    private RemoteDataSource mRemoteDataSource;


    public TopicPresenter(@NonNull RemoteDataSource remoteDataSource, @NonNull TopicContract.View view, @NonNull BaseSchedulerProvider provider) {
        this.mRemoteDataSource = checkNotNull(remoteDataSource, "remoteDataSource");
        this.mView = checkNotNull(view, "view cannot be null!");
        this.mSchedulerProvider = checkNotNull(provider, "schedulerProvider cannot be null");

        mSubscriptions = new CompositeSubscription();

        mView.setPresenter(this);
    }

    @Override
    public void fetch() {

        Subscription subscription = mRemoteDataSource.getTopicsRx()
                .subscribeOn(mSchedulerProvider.computation())
                .observeOn(mSchedulerProvider.ui())
                .subscribe((List<Topics> listTopics) -> {
                            mView.setLoadingIndicator(false);
                            mView.showTopics(listTopics);
                        },
                        (Throwable error) -> {
                            try {
                                mView.showError();
                            } catch (Throwable t) {
                                throw new IllegalThreadStateException();
                            }

                        },
                        () -> {
                        });

        mSubscriptions.add(subscription);
    }

    @Override
    public void subscribe() {
        fetch();
    }

    @Override
    public void unSubscribe() {
        mSubscriptions.clear();
    }

}

现在是TopicPresenterTest:

@RunWith(MockitoJUnitRunner.class)
public class TopicPresenterTest {

    @Mock
    private RemoteDataSource mRemoteDataSource;

    @Mock
    private TopicContract.View mView;

    private BaseSchedulerProvider mSchedulerProvider;

    TopicPresenter mThemePresenter;

    List<Topics> mList;

    @Before
    public void setup() {
        MockitoAnnotations.initMocks(this);

        Topics topics = new Topics(1, "Discern The Beach");
        Topics topicsTwo = new Topics(2, "Discern The Football Player");
        mList = new ArrayList<>();
        mList.add(topics);
        mList.add(topicsTwo);

        mSchedulerProvider = new ImmediateSchedulerProvider();
        mThemePresenter = new TopicPresenter(mRemoteDataSource, mView, mSchedulerProvider);


    }

    @Test
    public void fetchData() {

        when(mRemoteDataSource.getTopicsRx())
                .thenReturn(rx.Observable.just(mList));

        mThemePresenter.fetch();

        InOrder inOrder = Mockito.inOrder(mView);
        inOrder.verify(mView).setLoadingIndicator(false);
        inOrder.verify(mView).showTopics(mList);

    }

    @Test
    public void fetchError() {

        when(mRemoteDataSource.getTopicsRx())
                .thenReturn(Observable.error(new Throwable("An error has occurred!")));
        mThemePresenter.fetch();

        InOrder inOrder = Mockito.inOrder(mView);
        inOrder.verify(mView).showError();
        verify(mView, never()).showTopics(anyList());
    }

}

您可以在 GitHub本文 中查看我的示例。


0

我遇到了同样的问题:

.observeOn(AndroidSchedulers.mainThread())

我用以下代码解决了它:

public class RxJavaUtils {
    public static Supplier<Scheduler> getSubscriberOn = () -> Schedulers.io();
    public static Supplier<Scheduler> getObserveOn = () -> AndroidSchedulers.mainThread();
}

并像这样使用它

deviceService.findDeviceByCode(text)
            .subscribeOn(RxJavaUtils.getSubscriberOn.get())
            .observeOn(RxJavaUtils.getObserveOn.get())

在我的测试中

@Before
public void init(){
    getSubscriberOn = () -> Schedulers.from(command -> command.run()); //Runs in curren thread
    getObserveOn = () -> Schedulers.from(command -> command.run()); //runs also in current thread
}

对于 io.reactivex 也适用


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接