Модульное тестирование с mockito и шпионом, вызывающим ошибку

Я использую Mockito и Spy для модульного тестирования функции.

Это тестируемый класс:

public class RecipeListModelImp
        implements RecipeListModelContract {

    private Subscription subscription;
    private RecipesAPI recipesAPI;

    @Inject
    public RecipeListModelImp(@NonNull RecipesAPI recipesAPI) {
        this.recipesAPI = Preconditions.checkNotNull(recipesAPI);
    }

    @Override
    public void getRecipesFromAPI(final RecipeGetAllListener recipeGetAllListener) {
        subscription = recipesAPI.getAllRecipes()
               .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe(new Subscriber<List<Recipe>>() {
                    @Override
                    public void onCompleted() {
                              }
                    @Override

                    public void onError(Throwable e) {
                                  recipeGetAllListener.onRecipeGetAllFailure(e.getMessage());

                    }


                    @Override

                    public void onNext(List<Recipe> recipe) {                       recipeGetAllListener.onRecipeGetAllSuccess(recipe);
                    }
                });
    }

    @Override
    public void shutdown() {
        if(subscription != null && !subscription.isUnsubscribed()) {
            subscription.unsubscribe();
        }
    }
}

Я пытаюсь протестировать с помощью Mockito и шпиона, так как я не хочу вызывать настоящую функцию recipesAPI.getAllRecipes(), просто проверьте ее. Тест называется testGetRecipesFromAPI()

public class RecipeListModelImpTest {
    @Mock Subscription subscription;
    @Mock RecipesAPI recipesAPI;
    @Mock RecipeListModelContract.RecipeGetAllListener recipeGetAllListener;

    private RecipeListModelContract recipeListModel;

    @Before
    public void setup() {
        MockitoAnnotations.initMocks(RecipeListModelImpTest.this);
        recipeListModel = new RecipeListModelImp(recipesAPI);
    }

    @Test
    public void testGetRecipesFromAPI() {
        RecipeListModelContract recipeListModelSpy = spy(recipeListModel);
        RecipesAPI recipeApiSpy = spy(recipesAPI);
        doNothing().when(recipeApiSpy).getAllRecipes();

        recipeListModelSpy.getRecipesFromAPI(recipeGetAllListener);

        verify(recipesAPI, times(1)).getAllRecipes();

    }

    @Test
    public void testShouldShutdown() {
        recipeListModel.shutdown();
        verify(subscription, times(1)).unsubscribe();
    }
}

Это ошибка:

org.mockito.exceptions.base.MockitoException: 
Only void methods can doNothing()!
Example of correct use of doNothing():
    doNothing().
    doThrow(new RuntimeException())
    .when(mock).someVoidMethod();
Above means:
someVoidMethod() does nothing the 1st time but throws an exception the 2nd time is called

Я также пробовал это, что вызывает нулевой указатель:

  @Test
    public void testGetRecipesFromAPI() {
        RecipeListModelContract recipeListModelSpy = spy(recipeListModel);
        RecipesAPI recipeApiSpy = spy(recipesAPI);

        doReturn(Observable.just(Subscription.class)).when(recipeApiSpy).getAllRecipes();

        recipeListModelSpy.getRecipesFromAPI(recipeGetAllListener);

        verify(recipesAPI, times(1)).getAllRecipes();
    }

person ant2009    schedule 23.06.2017    source источник
comment
в какой строке вы получаете NullPointer?   -  person xyz    schedule 23.06.2017


Ответы (3)


Проблема в коде вот в этой части: subscribeOn(Schedulers.io()). Если бы мы только могли устранить это, то мы могли бы вернуть тестовые данные из recipesAPI и проверить, правильно ли эти данные обработаны recipeGetAllListener.

Итак, нам нужно как-то создать шов: если это производственный код — то использовать Schedulers.io()/AndroidSchedulers.mainThread(), если это тестовый код — то использовать какой-то конкретный планировщик.

Давайте объявим интерфейс, который будет предоставлять Schedulers:


    interface SchedulersProvider {
        Scheduler getWorkerScheduler();
        Scheduler getUiScheduler();
    }

Теперь давайте сделаем так, чтобы RecipeListModelImp зависело от SchedulersProvider:


    public class RecipeListModelImp implements RecipeListModelContract {

        ...
        private SchedulersProvider schedulersProvider;

        @Inject
        public RecipeListModelImp(@NonNull RecipesAPI recipesAPI, 
                                  @NonNull SchedulersProvider schedulerProvider) {
            ...
            this.schedulersProvider = schedulersProvider;
        }

        ...
    }

Теперь заменим планировщики:


    @Override
    public void getRecipesFromAPI(final RecipeGetAllListener recipeGetAllListener) {
        subscription = recipesAPI.getAllRecipes()
                                 .subscribeOn(schedulersProvider.getWorkerScheduler())
                                 .observeOn(schedulersProvider.getUiScheduler())
                                 ...
    }

Пришло время позаботиться о предоставлении SchedulerProvider:


    @Module
    public class MyModule {
        ...
        @Provides public SchedulerProvider provideSchedulerProvider() {
            return new SchedulerProvider() {
                @Override
                Scheduler getWorkerScheduler() {
                    return Schedulers.io();
                }

                @Override
                Scheduler getUiScheduler() {
                    return AndroidSchedulers.mainThread();
                }
            }
        }
    }

Теперь давайте создадим еще один модуль — TestModule, который будет предоставлять зависимости для тестовых классов. TestModule расширит MyModule и переопределит метод, предоставивший SchedulerProvider:


    public class TestModule extends MyModule {
        @Override public SchedulerProvider provideSchedulerProvider() {
            return new SchedulerProvider() {
                @Override
                Scheduler getScheduler() {
                    return Schedulers.trampoline();
                }

                @Override
                Scheduler getUiScheduler() {
                    return Schedulers.trampoline();
                }
            }
        }
    }

Schedulers.trampoline() будет выполнять задачи в текущей теме.

Пришло время создать тестовый компонент:


    @Component(modules = MyModule.class)
    public interface TestComponent extends MyComponent {
        void inject(RecipeListModelImpTest test);
    }

Теперь в тестовом классе:


    public class RecipeListModelImpTest {

        @Mock RecipesAPI recipesAPI;
        @Mock RecipeListModelContract.RecipeGetAllListener recipeGetAllListener;

        @Inject SchedulerProvider schedulerProvider;

        private RecipeListModelContract recipeListModel;

        @Before
        public void setup() {
            TestComponent component = DaggerTestComponent.builder()
                                                         .myModule(new TestModule())
                                                         .build();
            component.inject(this);

            MockitoAnnotations.initMocks(this);
            recipeListModel = new RecipeListModelImp(recipesAPI, schedulerProvider);
        }
        ...
    }

И собственно тестовая часть:

    private static final List<Recipe> TEST_RECIPES = new ArrayList<Recipe>() {
        {
            add(new Recipe(1)),
            add(new Recipe(2))
        }
    };

    @Test
    public void testGetRecipesFromAPI() {
        when(recipeAPI.getAllRecipes())
            .thenReturn(Observable.fromIterable(TEST_RECIPES));

        recipeListModel.getRecipesFromAPI(recipeGetAllListener);

        // verifying, that `recipeAPI.getAllRecipes()` has been called once
        verify(recipeAPI).getAllRecipes();

        // verifying, that we received correct result
        verify(recipeGetAllListener).onRecipeGetAllSuccess(TEST_RECIPES);
    }
person azizbekian    schedule 23.06.2017
comment
это был отличный ответ и решил мою проблему. Это мой предпочтительный способ использования макетов и инъекции с помощью кинжала. Просто быстрый вопрос: нужно ли расширять базовый компонент? то есть public interface TestComponent extends MyComponent - person ant2009; 24.06.2017
comment
Да, это потому, что, возможно, у вас также есть другие зависимости, которые вы хотите внедрить. Если вы не расширяете базовый компонент, то dagger не сможет предоставить эти зависимости. - person azizbekian; 24.06.2017
comment
Вы дали мне отличный ответ. Однако у меня есть еще одна проблема с тестированием RxJava2 с эспрессо, где я пытаюсь имитировать возврат данных из API, чтобы я мог тестировать изолированно. Однако я получаю исключение нулевого указателя в SubscribeOn(..) Мои вопросы находятся здесь stackoverflow.com/questions/45717803/ было бы здорово, если бы вы могли предоставить другого эксперта отвечать. Есть награда в 500 баллов. Заранее спасибо. - person ant2009; 20.08.2017
comment
Но что, если я создаю модульный тест для ошибки, возникшей специально для использования параллельного выполнения в Schedulers.io()? Если я заменю его на батут, модульный тест не будет провален для кода с ошибками. - person JustAMartin; 12.06.2020
comment
@JustAMartin, вы имеете дело с неправильно синхронизированным изменяемым состоянием, чего не должно быть в этом конкретном сценарии. Можете подробно описать вашу проблему? - person azizbekian; 12.06.2020
comment
@azizbekian в моем случае ошибка была вызвана использованием flatMapComletable вместо concatMapComletable. Итак, чтобы воспроизвести ошибку, мне нужно принудительно выполнить параллельное выполнение наблюдаемых. Однако, похоже, trampoline заставляет flatMapComletable выполняться в том же потоке. Таким образом, я не могу создать тестовый пример для ошибки. Но я сделаю новый вопрос для проблемы. - person JustAMartin; 12.06.2020

как вы написали

подписка = recipesAPI.getAllRecipes().subscribeOn(Schedulers.io())

затем метод getAllRecipes() возвращает некоторый объект, и вы не можете использовать

делатьНичего(). Когда(recipeApiSpy).getAllRecipes();

doNothing() - метод возвращает void.

вариант правильный:

doReturn(doReturn(Observable.just(Subscription.class)).when(recipeApiSpy).getAllRecipes()

person xyz    schedule 23.06.2017

Вы смешиваете Spy (частичные макеты) и Mocks (полные макеты). В этом нет необходимости — Spy позволяет вам смешивать насмешливые и реальные вызовы методов, но вам не нужны какие-либо частичные насмешки. В вашем случае вы либо полностью издеваетесь, либо не издеваетесь. документация от Mockito содержит больше информация о насмешках и шпионаже.

В вашем первом примере ошибка заключается в том, что вы пытаетесь doNothing использовать метод, который что-то возвращает. Мокито не позволяет этого. То, что вы делали во втором примере, было почти правильно.

Для вашего второго примера проблема заключается в том, что вы настроили getAllRecipes() для возврата Observable.just(Subscription.class), но у вас все еще есть вся цепочка методов, вызываемых для этого в тестируемом модуле: subscribeOn, observeOn и subscribe. Вам также нужно имитировать эти вызовы, чтобы возвращать имитированные объекты, с которыми вы можете работать, или те вызовы, которые вызывают исключение NullPointerException.

@Test
public void testGetRecipesFromAPI() { 
    //recipesAPI.getAllRecipes() needs to be mocked to return something (likely a mock)
    // so subscribeOn can be called.
    //That needs to be mocked to return something so observeOn can be called
    //etc.

    recipeListModel.getRecipesFromAPI(recipeGetAllListener);

    verify(recipesAPI, times(1)).getAllRecipes();
}
person Daniel Bickler    schedule 23.06.2017