Closed tbruyelle closed 9 years ago
Honestly we're struggling with this right now, as we try to convert our flagship app from maven to gradle.
Our legacy unit tests are based on a combination of JUnit 4, Mockito, and Robolectric 2.2. (We're stuck on 2.2 because newer flavors refuse to run code targeting sdk 19 or later.) Robolectric sneakily allows us to mock what would otherwise be final and/or stubbed classes and methods from Android land. There is some flakiness when we lean too hard on Robolectric's emulation of the Android lifecycle in a couple of particularly nasty tests, but on balance it works. We have a couple of test support classes, attached below, that help with this.
But this hacky combination is starting to creak under the new build tools. We can keep the tests running at the command line if we use the https://github.com/robolectric/robolectric-gradle-plugin, but running and debugging such tests in AndroidStudio requires some seriously Rube Goldberg hackery.
Also, in the meantime we've found that going by the book and simply running AndroidTestCase or JU3 TestCase classes on an emulator or device is just as fast running in a JVM with Robolectric, making us wonder why we're bothering. We're exploring writing all new tests in that style, but it brings its own set of issues. We can instantiate intents and bundles and such directly, so that's a big win. But mocking out a view effectively is much more difficult. E.g., the View#getContext method that Presenter and MortarTestEnve lean on so heavily is final, so we'll probably be forced to declare a formal view interface for every Presenter — boilerplate we've avoided to date.
We'll keep banging on it and we'll share whatever patterns we wind up with. But unit testing Android apps continues to be a difficult, neglected corner of the platform.
public final class MortarTestEnv {
/**
* Returns a Mockito mock {@link Application} with a real {@link MortarScope}.
* One twist: the scope is an {@link MortarActivityScope}, as opposed to the
* more limited {@link MortarScope} that would normally be returned by an Application.
* This is unrealistic, but reduces test boilerplate.
*/
public static Application mockApplication() {
return new MortarTestEnv().appContext;
}
public static Context mockActivity() {
return new MortarTestEnv().activityContext;
}
public static MortarActivityScope createActivityScope() {
return new MortarTestEnv().activityScope;
}
public static void setMockContainerToCallBack(CanShowScreen view) {
doAnswer(new Answer() {
@Override public Object answer(InvocationOnMock invocation) throws Throwable {
Object[] arguments = invocation.getArguments();
((Flow.Callback) arguments[2]).onComplete();
return null;
}
}).when(view)
.showScreen(any(RegisterScreen.class), any(Flow.Direction.class), any(Flow.Callback.class));
}
public final Application appContext;
public final Context activityContext;
public final MortarScope rootScope;
public final MortarActivityScope activityScope;
public MortarTestEnv() {
rootScope = Mortar.createRootScope(true);
activityScope = Mortar.requireActivityScope(rootScope, new TestBlueprint());
Mocked m = new Mocked();
initMocks(m);
appContext = m.appContext;
activityContext = m.activityContext;
when(appContext.getSystemService("mortar_scope")).thenReturn(rootScope);
when(activityContext.getSystemService("mortar_scope")).thenReturn(activityScope);
}
/**
* Sets up a Mockito mocked context to provide {@link #activityScope}. For use
* from tests that need to set up their own mock context instead of using {@link #appContext}.
*/
public void initMockContext(Context context) {
when(context.getSystemService("mortar_scope")).thenReturn(activityScope);
}
public static class TestBlueprint implements Blueprint {
@Override public String getMortarScopeName() {
return "ROOT_TEST_SCOPE";
}
@Override public Object getDaggerModule() {
return null;
}
}
private static class Mocked {
@Mock(answer = RETURNS_DEEP_STUBS) Application appContext;
@Mock(answer = RETURNS_DEEP_STUBS) Context activityContext;
}
}
public class MockPopup<D extends Parcelable, R> implements Popup<D, R> {
private final Context context;
public Boolean flourished;
public PopupPresenter<D, R> presenter;
public D showing;
public MockPopup(Context context) {
this.context = context;
}
@Override public void show(D info, boolean withFlourish, PopupPresenter<D, R> presenter) {
this.showing = info;
this.flourished = withFlourish;
this.presenter = presenter;
}
@Override public boolean isShowing() {
return showing != null;
}
@Override public void dismiss(boolean withFlourish) {
showing = null;
flourished = withFlourish;
}
@Override public Context getContext() {
return context;
}
}
And here's a sample test:
@RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE)
public class ChooseOtherTypePresenterTest {
@Mock(answer = RETURNS_DEEP_STUBS) ChooseOtherTypeView view;
@Mock(answer = RETURNS_DEEP_STUBS) AccountStatusSettings settings;
ChooseOtherTypePresenter presenter;
static OtherTenderType createOtherTenderType(int type, String name, String note) {
return new OtherTenderType(type, name, note);
}
static void setOtherTenderTypes(int count, AccountStatusSettings settings) {
setOtherTenderTypes(count, settings, true);
}
static void setOtherTenderTypes(int count, AccountStatusSettings settings, boolean emptyNote) {
List<OtherTenderType> result = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
String note = emptyNote ? null : "Note" + i;
result.add(createOtherTenderType(0, "Name" + i, note));
}
when(settings.getPaymentSettings().getOtherTenderOptions()).thenReturn(result);
}
@Before public void setUp() {
initMocks(this);
Context activityContext = MortarTestEnv.mockActivity();
when(view.getContext()).thenReturn(activityContext);
when(view.getCheckedId()).thenReturn(AdapterView.INVALID_POSITION);
// Res is a convenience interface that shadows a few methods in Resources.
Res res = ResTestUtil.mockRes() //
.string(R.string.pay_other_note_hint, "Default Hint") //
.asRes();
presenter = new ChooseOtherTypePresenter(res, settings);
}
@Test
public void addsNoRowsWithLessThanTwoTenderTypes() {
setOtherTenderTypes(1, settings);
presenter.takeView(view);
verify(view).addRows(new String[0]);
}
}
@rjrjr If you could start from scratch, what method would you recommend pursuing?
This is something I've been trying to tackle for a while now. Posting some samples when you guys settle into something would be wonderful.
I thought I'd also link an unanswered question concerning this on StackOverflow I asked a few weeks ago: http://stackoverflow.com/questions/27096540/writing-tests-for-flow-and-mortar-apps
But unit testing Android apps continues to be a difficult, neglected corner of the platform.
So true... Thanks for revealing you are struggling too, I feel better.
About the method, I also stopped to fight against Robolectric and the new build system, I write standard tests and run them on Genymotion. And like you I hate the man who set getContext()
final.
@rjrjr Did you guys end up sticking with the pattern shown above, or have you found something better?
And we're eagerly waiting for Google's promised junit support.
A first step forward, no more final methods ! http://tools.android.com/tech-docs/unit-testing-support
@rjrjr Where did you guys end up falling on this?
@rjrjr how did you end up doing this?
Here's an easy approach.
One of the best benefit of dealing with Presenters is test writing. Your test class extends
AndroidTestCase
, you mock all the presenter's dependencies and you can start. That's how I did before I start to work with Mortar.With Mortar you have to invoke
takeView()
to inject your (mocked) view in the presenter. AndtakeView()
invokesview.getContext().getSystemService()
, which results to NPE becauseContext
isn't mockable.How to test the presenter with Mortar ?