Единично тестване с 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()? Ако го заменя с trampoline, тестът на модула няма да се провали за кода на бъга. - person JustAMartin; 12.06.2020
comment
@JustAMartin, имате работа с неправилно синхронизирано променливо състояние, което не трябва да е случай за този конкретен сценарий. Можете ли да опишете проблема си в подробности? - person azizbekian; 12.06.2020
comment
@azizbekian в моя случай грешката беше причинена от използването на flatMapComletable вместо concatMapCompletable. Така че, за да възпроизведа грешката, трябва да принудя паралелно изпълнение на наблюдаеми. Изглежда обаче, че trampoline кара flatMapComletable да се изпълнява в същата нишка. По този начин не мога да създам тестов случай за грешката. Но ще направя нов въпрос за изданието. - person JustAMartin; 12.06.2020

както си написал

абонамент = recipesAPI.getAllRecipes().subscribeOn(Schedulers.io())

тогава методът getAllRecipes() връща някакъв обект и не можете да използвате

doNothing().when(recipeApiSpy).getAllRecipes();

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

вариантът е правилен:

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

person xyz    schedule 23.06.2017

Вие смесвате Spy (частични подигравки) и Mocks (пълни подигравки). Това не е необходимо - Spy ви позволява да смесвате подигравателни и реални извиквания на методи, но не се нуждаете от частично подиграване. В твоя случай или се подиграваш напълно, или не се подиграваш. документация на Mockito има повече информация за подигравки и шпиониране.

В първия ви пример грешката е, че се опитвате да doNothing на метод, който връща нещо. Mockito не позволява това. Това, което правехте във втория си пример, беше почти правилно.

За вашия втори пример, проблемът е, че сте настроили 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