Clone the project from https://github.com/Mobiquity/AndroidUnitTests
Project Wiki: mobiquity.jira.com/wiki/display/MW/Unit+Testing+for+Android
"But it was working yesterday..."
git clone git@github.com:Mobiquity/AndroidUnitTests.git
Unit Tests
./gradlew testDebugUnitTest
Results are under: app/build/reports/tests/{buildFlavor}/index.html
Functional Tests
./gradlew connectedDebugAndroidTest
Results are under: app/build/reports/androidTests/connected/index.html
Tests require an emulator or a physical device
Code Coverage
./gradlew testDebugUnitTestCoverage
./gradlew fullTestDebugUnitTestCoverage
Results are under: app/build/reports/jacoco/{coverageType}/html/index.html
Annotation | |
---|---|
@Test | Informs JUnit that method can be run as a test -Expected: Specifies a test should throw a particular exception -Timeout: Fails test if it has not finished within a limit |
@Before | Test pre-conditions to be run before each test |
@After | Test post-conditions to be run after each test |
@Ignore | Prevent a test from running |
@Rule | Redefine test behavior |
public class AdditionOperator extends Operator {
public AdditionOperator() {
super("+", InputType.OPERATOR);
}
@Override
public double execute(double param1, double param2) {
return add(param1, param2);
}
@Override
public int getPrecedence() {
return Precedence.ADDITION_PRECEDENCE.getValue();
}
@Override
public boolean isLeftAssociative() {
return true;
}
private double add(double firstAddend, double secondAddend) {
return firstAddend + secondAddend;
}
}
What Do We Test?
public class AdditionOperatorTest {
private AdditionOperator additionOperator;
@Before
public void setUp() {
additionOperator = new AdditionOperator();
}
...
@Test
public void testGetPrecedence_IsEqualToAdditionPrecedence() {
assertThat(additionOperator.getPrecedence())
.isEqualTo(Operator.Precedence.ADDITION_PRECEDENCE.getValue());
}
@Test
public void testShouldBeLeftAssociative() {
assertThat(additionOperator.isLeftAssociative()).isTrue();
}
@Test
public void testExecute_ShouldAddOperands() {
assertThat(additionOperator.execute(2,3)).isWithin(5);
assertThat(additionOperator.execute(0,10)).isWithin(10);
assertThat(additionOperator.execute(10,-10)).isWithin(0);
assertThat(additionOperator.execute(-2,-3)).isWithin(-5);
}
}
@AppScope
public class Calculator {
private InfixInputParser infixInputParser;
...
@Inject
public Calculator(InfixInputParser infixInputParser) {
this.infixInputParser = infixInputParser;
}
public double evaluate(Input[] inputs) throws CalculatorEvaluationException {
try {
Queue<Input> postfixInputs = infixInputParser.toPostfix(inputs);
// Return if there are no inputs to evaluate
if (postfixInputs.isEmpty()) {
return 0;
}
Stack<Double> stack = new Stack<>();
for (Input input : postfixInputs) {
switch (input.getType()) {
case NUMBER:
NumericInput numericInput = (NumericInput) input;
stack.push(numericInput.getValue());
break;
case OPERATOR:
Operator operator = (Operator) input;
double secondOperand = stack.pop();
double firstOperand = stack.pop();
double result = operator.isLeftAssociative() ?
operator.execute(firstOperand, secondOperand) :
operator.execute(secondOperand, firstOperand);
stack.push(result);
break;
}
}
// prevents calculator from returning -0
return stack.pop() + 0.0;
} catch (EmptyStackException | InfixInputParser.InputParserException | NumberFormatException e) {
throw new CalculatorEvaluationException("Invalid Expression");
}
}
}
We need to test that, given postfix input, our calculator returns the correct result.
public Calculator(InfixInputParser infixInputParser) {
...
}
We want to test the behavior of the Calculator class, not our InfixInputParser
So... let's mock the behavior of our parser!
Create Mocks: @Mock or Mockito.mock()
Configure Mocks: Mockito.when(...).thenReturn(...)
Verify Mocks: Mockito.verify(...)
Annotations are used with either MockitoRule or MockitoAnnotations.initMocks()
public class CalculatorTest {
private Calculator calculator;
@Mock InfixInputParser infixInputParser;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
calculator = new Calculator(infixInputParser);
}
...
// Our tests ...
private void mockPostFix(Input[] input, Input[] expectedInput) {
Mockito.when(infixInputParser.toPostfix(input)).thenReturn(
new LinkedList<>(Arrays.asList(expectedInput))
);
}
}
Our Calculator Test is Separate From our Input Parser Test
We can verify that the Calculator behaves correctly without needing the real input parser to work correctly!
Testing each unit in isolation
If something
and our Calculator tests still pass
but our InfixInputParser tests
@Test
public void testEvaluate_AdditionMultiply() throws Exception {
//Test: 1 + 2 * 3 = 7
Input input[] = new Input[]{
new NumericInput(1),
new AdditionOperator(),
new NumericInput(2),
new MultiplicationOperator(),
new NumericInput(3)
};
double expectedResult = 7;
// Assume the infix input parser works and provide the correct postfix for the input
Input[] postfixInput = new Input[] {
new NumericInput(1),
new NumericInput(2),
new NumericInput(3),
new MultiplicationOperator(),
new AdditionOperator()
};
mockPostFix(input, postfixInput);
double result = calculator.evaluate(input);
assertThat(result).isWithin(expectedResult);
}
/**
* Before: 3 + 4 * 5
* After: 3 4 5 * +
*/
@Test
public void testToPostfix_AdditionMultiplication() {
Input[] inputs = new Input[] {
three,
plus,
four,
times,
five
};
Queue<Input> postfixOutput = infixInputParser.toPostfix(inputs);
assertThat(postfixOutput).containsExactly(three, four, five, times, plus)
.inOrder();
}
@AppScope
public class WolframPresenter extends Presenter<WolframView> {
private WolframService wolframService;
@Inject
public WolframPresenter(WolframService wolframService) {
this.wolframService = wolframService;
}
public void startQuery(String query) {
wolframService.query(query).enqueue(new Callback<WolframResponse>() {
@Override
public void onResponse(Call<WolframResponse> call, Response<WolframResponse> response) {
view().updatePods(response.body().getPods());
}
@Override
public void onFailure(Call<WolframResponse> call, Throwable t) {
Timber.e(t, t.getMessage());
view().showWolframFailure();
}
});
}
}
We want to test the service call without calling the real service
We need to test the callback for both success and failure
We should also mock our UI (WolframView)
@Mock(answer = RETURNS_DEEP_STUBS) WolframService wolframService;
@Mock WolframView wolframView;
Use it to capture argument values for further assertions
We can use ArgumentCaptor to
our callback path
@Captor ArgumentCaptor<Callback<WolframResponse>> responseCaptor;
@Test
public void testStartQuery_SuccessfulQuery() {
wolframPresenter.bind(wolframView);
wolframPresenter.startQuery(Mockito.anyString());
Mockito.verify(wolframService.query(Mockito.anyString()))
.enqueue(responseCaptor.capture());
responseCaptor.getValue().onResponse(
Mockito.mock(Call.class), Response.success(mockResponse)
);
Mockito.verify(wolframView).updatePods(Mockito.anyList());
}
@Test
public void testStartQuery_FailedQuery() {
wolframPresenter.bind(wolframView);
wolframPresenter.startQuery(Mockito.anyString());
Mockito.verify(wolframService.query(Mockito.anyString()))
.enqueue(responseCaptor.capture());
responseCaptor.getValue().onFailure(
Mockito.mock(Call.class), Mockito.mock(Throwable.class)
);
Mockito.verify(wolframView).showWolframFailure();
}
This allows us to test classes with android dependencies WITHOUT needing an actual device
testCompile "org.robolectric:robolectric:3.0"
Robolectric only supports <=21 right now
@Config(constants = BuildConfig.class, sdk=21)
@RunWith(RobolectricGradleTestRunner.class)
public class MyRobolectricTest {
...
}
Our CalculatorActivity implements CalculatorView for MVP
Do the methods do what we think?
@RunWith(CustomGradleRunner.class)
public class CalculatorActivityViewTest {
private CalculatorActivity calculatorActivity;
@Before
public void setUp() {
calculatorActivity = Robolectric.setupActivity(CalculatorActivity.class);
}
...
}
@Test
public void testUpdateDisplay_ChangesDisplayText() {
String expectedDisplayText = "1+1";
calculatorActivity.updateDisplayText(expectedDisplayText);
assertThat(calculatorActivity.displayInput.getText().toString())
.isEqualTo(expectedDisplayText);
}
@Test
public void testShowSuccessfulCalculation_ChangesResultText() {
String expectedResultText = "12";
calculatorActivity.showSuccessfulCalculation(expectedResultText);
assertThat(calculatorActivity.resultText.getText().toString())
.isEqualTo(expectedResultText);
}
Testing our CalculatorView methods in isolation
With Robolectric and Mockito, you can mock dependencies and test your components in isolation!
We need to test the behavior of the app as a
Provides APIs for writing UI tests to simulate user interactions
As of 2015, it is now officially part of the Android Testing Support Library
Automatic synchronization of test actions with the UI of the app you are testing
defaultConfig {
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
dependencies {
androidTestCompile "com.android.support.test.espresso:espresso-core:2.2.1",
androidTestCompile "com.android.support.test.espresso:espresso-intents:2.2.1"
androidTestCompile ("com.android.support.test.espresso:espresso-contrib:2.2.1") {
exclude module: 'support-annotations'
exclude module: 'gridlayout-v7'
exclude module: 'recyclerview-v7'
exclude module: 'support-v4'
}
}
configurations.all {
resolutionStrategy {
force "com.android.support:support-annotations:23.3.0"
}
}
Espresso recommends disabling animations for tests
// Disable animations for functional tests with espresso
def adb = android.getAdbExe().toString()
afterEvaluate {
task grantAnimationPermission(type: Exec, dependsOn: 'installDebug') {
commandLine "$adb shell pm grant $android.defaultConfig.applicationId android.permission.SET_ANIMATION_SCALE".split(' ')
}
tasks.each { task ->
if (task.name.startsWith('assembleDebugAndroidTest')) {
task.dependsOn grantAnimationPermission
}
}
}
public class DisableAnimationsRule implements TestRule {
private Method mSetAnimationScalesMethod;
private Method mGetAnimationScalesMethod;
private Object mWindowManagerObject;
public DisableAnimationsRule() {
try {
Class<?> windowManagerStubClazz = Class.forName("android.view.IWindowManager$Stub");
Method asInterface = windowManagerStubClazz.getDeclaredMethod("asInterface", IBinder.class);
Class<?> serviceManagerClazz = Class.forName("android.os.ServiceManager");
Method getService = serviceManagerClazz.getDeclaredMethod("getService", String.class);
Class<?> windowManagerClazz = Class.forName("android.view.IWindowManager");
mSetAnimationScalesMethod = windowManagerClazz.getDeclaredMethod("setAnimationScales", float[].class);
mGetAnimationScalesMethod = windowManagerClazz.getDeclaredMethod("getAnimationScales");
IBinder windowManagerBinder = (IBinder) getService.invoke(null, "window");
mWindowManagerObject = asInterface.invoke(null, windowManagerBinder);
}
catch (Exception e) {
throw new RuntimeException("Failed to access animation methods", e);
}
}
@Override
public Statement apply(final Statement statement, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
setAnimationScaleFactors(0.0f);
try { statement.evaluate(); }
finally { setAnimationScaleFactors(1.0f); }
}
};
}
private void setAnimationScaleFactors(float scaleFactor) throws Exception {
float[] scaleFactors = (float[]) mGetAnimationScalesMethod.invoke(mWindowManagerObject);
Arrays.fill(scaleFactors, scaleFactor);
mSetAnimationScalesMethod.invoke(mWindowManagerObject, scaleFactors);
}
}
Matchers: Provide a way to find a view instance given criteria
View Action: Perform an action on a view
Assertions: Check if view state matches criteria
https://google.github.io/android-testing-support-library/docs/espresso/cheatsheet/
Make use of the JUnit @Rule annotation
@Rule
public ActivityTestRule<CalculatorActivity> activityRule = new ActivityTestRule<>(CalculatorActivity.class);
@RunWith(AndroidJUnit4.class)
public class CalculatorActivityTest {
@Rule
public ActivityTestRule<CalculatorActivity> activityRule = new ActivityTestRule<>(CalculatorActivity.class);
@Rule
public DisableAnimationsRule disableAnimationsRule = new DisableAnimationsRule();
@Test
public void shouldConcatNumberInput() {
onView(withId(R.id.digit_1)).perform(click());
onView(withId(R.id.add_op)).perform(click());
onView(withId(R.id.digit_1)).perform(click());
onView(withId(R.id.digit_2)).perform(click());
onView(withId(R.id.display_input)).check(matches(withText("1+12")));
}
...
}
Ex: Test that numbers are displayed correctly when the user types on the calculator