
Testing Android Applications
Testing is one of the most important parts of developing quality software products. Today we will talk about some methodologies and libraries developed and used by our team to write tests of Android applications.
Let's start with the most basic things, because more experienced developers can go directly to the section on tools for UI testing. For those who want to learn or refresh basic things - enjoy reading.
Create the first test
Let's create a small component, which we will test. It parses the file with the JSON object containing the name, and returns the resulting string:
public class NameRepository {
private final File file;
public NameRepository(File file) {
this.file = file;
}
public String getName() throws IOException {
Gson gson = new Gson();
User user = gson.fromJson(readFile(), User.class);
return user.name;
}
public String readFile() throws IOException {
byte[] bytes = new byte[(int) file.length()];
try (FileInputStream in = new FileInputStream(file)) {
in.read(bytes);
}
return new String(bytes, Charset.defaultCharset());
}
private static final class User {
String name;
}
}
Here and in the future I will provide an abridged version of the code. The full version can be viewed in the repository . A link to the full code will be attached to each snippet .
Now write the first JUnit test. JUnit is a Java library for writing tests. In order for JUnit to know that the method is a test, you need to add an annotation to it @Test
. JUnit contains a class Assert
that allows you to compare the actual values with the expected ones and generates an error if the values do not match. This test will test the correctness of our component, namely reading the file, parsing JSON and getting the correct field:
public class NameRepositoryTest {
private static final File FILE = new File("test_file");
NameRepository nameRepository = new NameRepository(FILE);
@Test
public void getName_isSasha() throws Exception {
PrintWriter writer = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(FILE), UTF_8)), true);
writer.println("{name : Sasha}");
writer.close();
String name = nameRepository.getName();
Assert.assertEquals(name, "Sasha");
FILE.delete();
}
@Test
public void getName_notMia() throws Exception {
PrintWriter writer = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(FILE), UTF_8)), true);
writer.println("{name : Sasha}");
writer.close();
String name = nameRepository.getName();
Assert.assertNotEquals(name, "Mia");
FILE.delete();
}
}
Libraries for writing tests
Tests are also code that needs to be supported. Moreover, the test code should be easy to understand so that it can be verified in the mind. Therefore, it makes sense to invest in simplifying the test code, getting rid of duplication, and increasing readability. Let's look at the widely used libraries that will help us in this matter.
In order not to duplicate the preparation code in each test, there are annotations @Before
and @After
. Methods marked with annotation @Before
will be executed before each test, and methods marked with annotation will be performed @After
after each test. There are also annotations @BeforeClass
and @AfterClass
, which are executed respectively before and after all tests in the class. Let's redo our test using the following methods:
public class NameRepositoryTest {
private static final File FILE = new File("test_file");
NameRepository nameRepository = new NameRepository(FILE);
@Before
public void setUp() throws Exception {
PrintWriter writer = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(new FileOutputStream(FILE), UTF_8)), true);
writer.println("{name : Sasha}");
writer.close();
}
@After
public void tearDown() {
FILE.delete();
}
@Test
public void getName_isSasha() throws Exception {
String name = nameRepository.getName();
Assert.assertEquals(name, "Sasha");
}
@Test
public void getName_notMia() throws Exception {
String name = nameRepository.getName();
Assert.assertNotEquals(name, "Mia");
}
}
We were able to remove duplication of the setup code for each test. However, many different classes with tests may require the creation of a file, and this duplication would also be desirable to remove. To do this, there is a library of test rules ( TestRule ). The test rule performs a function similar to @Before
and @After
. In the apply () method of this class, we can perform the actions we need before and after each or all tests are completed. In addition to reducing code duplication, the advantage of this method is that the code is taken out of the test class, which reduces the amount of code in the test and makes it easier to read. Let's write a rule for creating a file:
public class CreateFileRule implements TestRule {
private final File file;
private final String text;
public CreateFileRule(File file, String text) {
this.file = file;
this.text = text;
}
@Override
public Statement apply(final Statement s, Description d) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
PrintWriter writer =
new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(
new FileOutputStream(FILE), UTF_8)), true);
writer.println(text);
writer.close();
try {
s.evaluate();
} finally {
file.delete();
}
}
};
}
}
We use this rule in our test. In order for actions to be TestRule
performed for each test, you need to mark it with TestRule
annotation @Rule
.
public class NameRepositoryTest {
static final File FILE = new File("test_file");
@Rule public final CreateFileRule fileRule =
new CreateFileRule(FILE, "{name : Sasha}");
NameRepository nameRepository = new NameRepository(new FileReader(FILE));
@Test
public void getName_isSasha() throws Exception {
String name = nameRepository.getName();
Assert.assertEquals(name, "Sasha");
}
...
}
If the rule is marked with annotation @ClassRule
, then actions will not be called before each test, but once before all tests in the class, similarly to @BeforeClass
and annotations @AfterClass
.
When several are used in tests TestRule
, it may be necessary that they run in a certain order, for this there is a RuleChain with which you can determine the order of launch of ours TestRule
. Create a rule that should create a folder before the file is created:
public class CreateDirRule implements TestRule {
private final File dir;
public CreateDirRule(File dir) {
this.dir = dir;
}
@Override
public Statement apply(final Statement s, Description d) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
dir.mkdir();
try {
s.evaluate();
} finally {
dir.delete();
}
}
};
}
}
With this rule, the test class will look like this:
public class NameRepositoryTest {
static final File DIR = new File("test_dir");
static final File FILE = Paths.get(DIR.toString(), "test_file").toFile();
@Rule
public final RuleChain chain = RuleChain
.outerRule(new CreateDirRule(DIR))
.around(new CreateFileRule(FILE, "{name : Sasha}"));
@Test
public void getName_isSasha() throws Exception {
String name = nameRepository.getName();
Assert.assertEquals(name, "Sasha");
}
...
}
Now, in each test, a directory will be created before creating the file and deleted after deleting the file.
Google Truth is a library for improving the readability of test code. It contains assert methods (similar to JUnit Assert ), but more human-readable, and also includes much more options for checking parameters. This is what the previous test using Truth looks like:
@Test
public void getName_isSasha() throws Exception {
String name = nameRepository.getName();
assertThat(name).isEqualTo("Sasha");
}
@Test
public void getName_notMia() throws Exception {
String name = nameRepository.getName();
assertThat(name).isNotEqualTo("Mia");
}
It can be seen that the code reads almost like text in spoken English.
Our component does two different things: it reads a file and parses it. To adhere to the principle of sole responsibility, let's separate the logic for reading a file into a separate component:
public class FileReader {
private final File file;
public FileReader(File file) {
this.file = file;
}
public String readFile() throws IOException {
byte[] bytes = new byte[(int) file.length()];
try (FileInputStream in = new FileInputStream(file)) {
in.read(bytes);
}
return new String(bytes, Charset.defaultCharset());
}
}
Now we want to test it NameRepository
, but in fact we are testing reading the file in FileReader
. To avoid this and thereby increase the insulation, reliability and speed of the test, we can replace the real one FileReader
with its mock.
Mockito is a library for creating stubs (mocks) instead of real objects for use in tests. Some actions that can be performed using Mockito:
create stubs for classes and interfaces;
check method calls and values passed to this method;
connection to a real “spy” object spy
to control method calls.
Create a mock FileReader
and configure it so that the method readFile()
returns the string we need:
public class NameRepositoryTest {
FileReader fileReader = mock(FileReader.class);
NameRepository nameRepository = new NameRepository(fileReader);
@Before
public void setUp() throws IOException {
when(fileReader.readFile()).thenReturn("{name : Sasha}");
}
@Test
public void getName_isSasha() throws Exception {
String name = nameRepository.getName();
assertThat(name).isEqualTo("Sasha");
}
}
Now no reading of the file occurs. Instead, the mock gives the value configured in the test.
The use of mok has its advantages:
- tests check only the tested class for errors, errors of other classes do not affect the testing of the tested class in any way
- sometimes shorter and more readable code
- it is possible to check method calls and passed values to methods of a frozen object
and disadvantages:
- By default, unconfigured methods return null, because all methods used must be explicitly configured.
- if the real object has a state, then each time it is supposed to change, you need to reconfigure its mock, which is why the test code is sometimes bloated.
There is an easier and more convenient way to create mok - use a special annotation @Mock
:
@Mock File file;
There are three ways to initialize such mokas:
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}
- Use MockitoJUnitRunner to run tests:
@RunWith(MockitoJUnitRunner.class)
- Add the MockitoRule rule to the test :
@Rule public final MockitoRule rule = MockitoJUnit.rule();
The second option is the most declarative and compact, but requires the use of a special test runner, which is not always convenient. The latter option is devoid of this drawback and is more declarative than using the method initMocks()
.
@RunWith(MockitoJUnitRunner.class)
public class NameRepositoryTest {
@Mock FileReader fileReader;
NameRepository nameRepository;
@Before
public void setUp() throws IOException {
when(fileReader.readFile()).thenReturn("{name : Sasha}");
nameRepository = new NameRepository(fileReader);
}
@Test
public void getName_isSasha() throws Exception {
String name = nameRepository.getName();
assertThat(name).isEqualTo("Sasha");
}
}
Host Java VM vs Android Java VM
Android tests can be divided into two types: those that can be run on a regular Java VM, and those that need to be run on an Android Java VM. Let's look at both types of tests.
Tests run on a regular Java VM
Tests for code that does not require the operation of Android API components that require an Android emulator or a real device can be run directly on your computer and on any Java machine. Mostly these are business logic unit tests that test in isolation of a single class. Integration tests are written much less often, since it is far from always possible to create real class objects with which the tested class interacts.
To write a class with Host Java tests, you need the java file to have a path ${moduleName}/src/test/java/...
. Also, using the @RunWith
annotation, specify Runner
which is responsible for running the tests, the correct call and processing of all methods:
@RunWith(MockitoJUnitRunner.class)
public class TestClass {...}
Using these tests has many advantages:
- do not require running an emulator or a real device, this is especially important when passing tests in Continuous integration , where the emulator can work very slowly and there is no real device
- pass very quickly, since you don’t need to run the application, display the UI, etc.
- stable, since there are no problems associated with the fact that the emulator may freeze, etc.
on the other hand, with these tests:
- you cannot fully test the interaction of classes with the operating system
- in particular, you cannot test clicks on UI elements and gestures
In order to be able to use the Android API classes in Host Java tests, there is a Robolectric library that emulates the Android environment and gives access to its main functions. However, testing Android classes with Roboelectric often works unstable: it takes time for Robolectric to support the latest Android API, there are problems getting resources, etc. Therefore, real classes are almost never used, and their moki are used for unit testing.
To run tests using Roboelectric, you need to install a custom TestRunner . In it, you can configure the SDK version (the latest stable version is 23), indicate the main class Application
and other parameters for the emulated Android environment.
public class MainApplication extends Application {}
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 21, application = MainApplication.class)
public class MainApplicationTest {
@Test
public void packageName() {
assertThat(RuntimeEnvironment.application)
.isInstanceOf(MainApplication.class);
}
}
Tests run on Android Java VM
For instrumental tests, the presence of a device or an emulator is mandatory, since we will test button presses, text input, and other actions.
To write a test for Android Java VM, you need to put the java file along the path ${moduleName}/src/androidTest/java/...
, and also use the @RunWith
annotation to specify AndroidJUnit4
which will allow you to run tests on your Android device.
@RunWith(AndroidJUnit4.class)
public class TestClass {...}
UI tests
To test the UI, the Espresso framework is used , which provides an API for testing the user interface of the program. In Espresso, tests work in a background stream, and interaction with UI elements in a UI stream. Espresso has several main classes for testing:
- Espresso is the main class. It contains static methods, such as pressing the system buttons (Back, Home), call / hide the keyboard, open the menu, and access the component.
- ViewMatchers - allows you to find the component on the screen in the current hierarchy.
- ViewActions - allows you to interact with the component (click, longClick, doubleClick, swipe, scroll, etc.).
- ViewAssertions - allows you to check the status of the component.
First UI test
We will write the simplest Android application, which we will test:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main_activity);
}
}
Testing our application. When testing the UI, you first need to start the Activity. To do this, there is an ActivityTestRule that starts an Activity before each test and closes after:
@Rule public ActivityTestRule activityTestRule =
new ActivityTestRule<>(MainActivity.class);
Let's write a simple test that checks that the element with id is R.id.container
shown on the screen:
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {
@Rule
public ActivityTestRule activityTestRule =
new ActivityTestRule<>(MainActivity.class);
@Test
public void checkContainerIsDisplayed() {
onView(ViewMatchers.withId(R.id.container))
.check(matches(isDisplayed()));
}
}
Unlock and turn on the screen
The emulator on slow or busy machines can run slowly. Therefore, between the launch of the emulator and the end of the build with installing the application on the emulator, enough time may pass so that the screen is blocked from inactivity. Thus, the test can be run with the screen locked, which will cause an error java.lang.RuntimeException: Could not launch activity within 45 seconds
. Therefore, before starting the Activity, you need to unlock and turn on the screen. Since this needs to be done in each UI test, in order to avoid code duplication, we will create a rule that will unlock and turn on the screen before the test:
class UnlockScreenRule implements TestRule {
ActivityTestRule activityRule;
UnlockScreenRule(ActivityTestRule activityRule) {
this.activityRule = activityRule;
}
@Override
public Statement apply(Statement statement, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
activityRule.runOnUiThread(() -> activityRule
.getActivity()
.getWindow()
.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON));
statement.evaluate();
}
};
}
}
Let's write a custom ActivityTestRule
one that unlocks the emulator screen and launches activity before running the tests:
public class ActivityTestRule implements TestRule {
private final android.support.test.rule.ActivityTestRule activityRule;
private final RuleChain ruleChain;
public ActivityTestRule(Class activityClass) {
this.activityRule = new ActivityTestRule<>(activityClass, true, true);
ruleChain = RuleChain
.outerRule(activityRule)
.around(new UnlockScreenRule(activityRule));
}
public android.support.test.rule.ActivityTestRule getActivityRule() {
return activityRule;
}
public void runOnUiThread(Runnable runnable) throws Throwable {
activityRule.runOnUiThread(runnable);
}
public A getActivity() {
return activityRule.getActivity();
}
@Override
public Statement apply(Statement statement, Description description) {
return ruleChain.apply(statement, description);
}
}
Using this rule instead of the standard one can greatly reduce the number of random crashes of UI tests in CI.
Fragment Testing
Typically, the layout and logic of the application UI is not put all into activity, but is divided into windows, for each of which a fragment is created. Let's create a simple snippet to display the name with NameRepository
:
public class UserFragment extends Fragment {
private TextView textView;
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
textView = new TextView(getActivity());
try {
textView.setText(createNameRepository().getName());
} catch (IOException exception) {
throw new RuntimeException(exception);
}
return textView;
}
private NameRepository createNameRepository() {
return new NameRepository(
new FileReader(
new File(
getContext().getFilesDir().getAbsoluteFile()
+ File.separator
+ "test_file")));
}
@Override
public void onDestroyView() {
super.onDestroyView();
textView = null;
}
}
When opening a fragment, the UI may freeze for some time, and if animations of transitions between fragments are used, the test can begin before the fragment appears. Therefore, you need not just open the fragment, but wait until it is launched. The Awaitility library , which has a very simple and clear syntax , is excellent for waiting for the result of actions . We will write a rule that starts a fragment and expects it to be launched using this library:
class OpenFragmentRule implements TestRule {
private final ActivityTestRule activityRule;
private final Fragment fragment;
OpenFragmentRule(ActivityTestRule activityRule, Fragment fragment) {
this.activityRule = activityRule;
this.fragment = fragment;
}
@Override
public Statement apply(Statement statement, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
openFragment(fragment);
await().atMost(5, SECONDS).until(fragment::isResumed);
statement.evaluate();
}
};
}
}
In this case, the expression means that if the fragment does not start within five seconds, the test will not pass. It should be noted that as soon as the fragment starts, the test will immediately continue execution and will not wait all five seconds.
Similar to the rule that launches the activity, it is logical to create a rule that launches the fragment:
public class FragmentTestRule
implements TestRule {
private ActivityTestRule activityRule;
private F fragment;
private RuleChain ruleChain;
public FragmentTestRule(Class activityClass, F fragment) {
this.fragment = fragment;
this.activityRule = new ActivityTestRule<>(activityClass);
ruleChain = RuleChain
.outerRule(activityRule)
.around(new OpenFragmentRule<>(activityRule, fragment));
}
public ActivityTestRule getActivityRule() {
return activityRule;
}
public F getFragment() {
return fragment;
}
public void runOnUiThread(Runnable runnable) throws Throwable {
activityRule.runOnUiThread(runnable);
}
public A getActivity() {
return activityRule.getActivity();
}
@Override
public Statement apply(Statement statement, Description description) {
return ruleChain.apply(statement, description);
}
}
A fragment test using this rule will look like this:
@RunWith(AndroidJUnit4.class)
public class UserFragmentTest {
@Rule
public final RuleChain rules = RuleChain
.outerRule(new CreateFileRule(getTestFile(), "{name : Sasha}"))
.around(new FragmentTestRule<>(MainActivity.class, new UserFragment()));
@Test
public void nameDisplayed() {
onView(withText("Sasha")).check(matches(isDisplayed()));
}
private File getTestFile() {
return new File(
InstrumentationRegistry.getTargetContext()
.getFilesDir()
.getAbsoluteFile() + File.separator + "test_file");
}
}
Asynchronous data loading in fragments
Так как операции с диском, а именно получение имени из файла, может выполняться сравнительно долго, то следует эту операцию выполнять асинхронно. Для асинхронного получения имени из файла используем библиотеку RxJava. Можно уверенно сказать, что RxJava сейчас используется в большинстве Android приложений. Практически каждая задача, которую нужно выполнить асинхронно, выполняется с помощью RxJava, потому что это пожалуй одна из самых удобных и понятных библиотек для асинхронного выполнения кода.
Изменим наш репозиторий так, чтобы он работал асинхронно:
public class NameRepository {
...
public Single getName() {
return Single.create(
emitter -> {
Gson gson = new Gson();
emitter.onSuccess(
gson.fromJson(fileReader.readFile(), User.class).getName());
});
}
}
Для тестирования RX-кода существует специальный класс TestObserver
, который автоматически подпишется на Observable
и мгновенно получит результат. Тест репозитория будет выглядеть следующим образом:
@RunWith(MockitoJUnitRunner.class)
public class NameRepositoryTest {
...
@Test
public void getName() {
TestObserver observer = nameRepository.getName().test();
observer.assertValue("Sasha");
}
}
Обновим наш фрагмент, используя новый реактивный репозиторий:
public class UserFragment extends Fragment {
...
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
textView = new TextView(getActivity());
createNameRepository()
.getName()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(name -> textView.setText(name));
return textView;
}
}
Так как теперь имя получается асинхронно, то для проверки результата работы нужно дождаться завершения асинхронного действия с помощью Awaitility:
@RunWith(AndroidJUnit4.class)
public class UserFragmentTest {
...
@Test
public void nameDisplayed() {
await()
.atMost(5, SECONDS)
.ignoreExceptions()
.untilAsserted(
() ->
onView(ViewMatchers.withText("Sasha"))
.check(matches(isDisplayed())));
}
}
Когда во фрагменте или активити выполняются асинхронные действия, в данном случае — чтение имени из файла, нужно иметь ввиду, что фрагмент может быть закрыт пользователем до того, как асинхронное действие выполнится. В текущей версии фрагмента допущена ошибка, так как если при выполнении асинхронной операции фрагмент будет уже закрыт, то textView
будет уже удален и равен null
. Чтобы не допустить краша приложения с NullPointerException
при доступе к textView
в subscribe()
, остановим асинхронное действие при закрытии фрагмента:
public class UserFragment extends Fragment {
private TextView textView;
private Disposable disposable;
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
textView = new TextView(getActivity());
disposable =
createNameRepository()
.getName()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(name -> textView.setText(name));
return textView;
}
@Override
public void onDestroyView() {
super.onDestroyView();
disposable.dispose();
textView = null;
}
}
Для тестирования подобных ошибок, связанных с асинхронными действиям во фрагменте, нужно закрыть фрагмент сразу же после его открытия. Это можно сделать просто заменив его на другой фрагмент. Тогда при завершении асинхронного действия onCreateView
в закрытом фрагменте textView
будет null
и если допустить ошибку и не отменить подписку, приложение упадет. Напишем правило для тестирования на эту ошибку:
public class FragmentAsyncTestRule
implements TestRule {
private final ActivityTestRule activityRule;
private final Fragment fragment;
public FragmentAsyncTestRule(Class activityClass, Fragment fragment) {
this.activityRule = new ActivityTestRule<>(activityClass);
this.fragment = fragment;
}
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
try {
base.evaluate();
} finally {
activityRule.launchActivity(new Intent());
openFragment(fragment);
openFragment(new Fragment());
}
}
};
}
}
Добавим это правило в класс тестов фрагмента:
@RunWith(AndroidJUnit4.class)
public class UserFragmentTest {
@ClassRule
public static TestRule asyncRule =
new FragmentAsyncTestRule<>(MainActivity.class, new UserFragment());
...
}
Теперь тест упадет, если асинхронные действия будут обращаться к полям фрагмента после его завершения.
Юнит-тестирование Rx кода
Создадим презентер, куда мы вынесем логику подписки на возвращаемый репозиторием Observable
из фрагмента, а также добавим timeout
для получения имени из файла:
public class UserPresenter {
public interface Listener {
void onUserNameLoaded(String name);
void onGettingUserNameError(String message);
}
private final Listener listener;
private final NameRepository nameRepository;
public UserPresenter(Listener listener, NameRepository nameRepository) {
this.listener = listener;
this.nameRepository = nameRepository;
}
public void getUserName() {
nameRepository
.getName()
.timeout(2, SECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
listener::onUserNameLoaded,
error -> listener.onGettingUserNameError(error.getMessage()));
}
}
В данном случае при тестировании презентера уже нужно протестировать конечный результат подписки, которая получает данные асинхронно. Напишем наивную версию такого теста:
@RunWith(RobolectricTestRunner.class)
public class UserPresenterTest {
@Rule public final MockitoRule rule = MockitoJUnit.rule();
@Mock UserPresenter.Listener listener;
@Mock NameRepository nameRepository;
UserPresenter presenter;
@Before
public void setUp() {
when(nameRepository.getName()).thenReturn(Observable.just("Sasha"));
presenter = new UserPresenter(listener, nameRepository);
}
@Test
public void getUserName() {
presenter.getUserName();
verifyNoMoreInteractions(listener);
}
}
В данном тесте презентер не вызовет никакой метод объекта listener
, так как тест проходит прежде, чем выполняется асинхронное действие. В тестах на эмуляторе Awaitility решает эту проблему. В юнит-тестах тестирование асинхронной природы кода не совсем к месту, а потому в них можно заменить стандартные RxJava Schedulers
на синхронные. Используем для этого TestScheduler, который позволяет произвольно установить время, которое якобы прошло с момента подписки на Observable
, чтобы протестировать корректную установку таймаута. Как обычно, напишем для этого правило:
public class RxImmediateSchedulerRule implements TestRule {
private static final TestScheduler TEST_SCHEDULER = new TestScheduler();
private static final Scheduler IMMEDIATE_SCHEDULER = new Scheduler() {
@Override
public Disposable scheduleDirect(Runnable run, long delay, TimeUnit unit) {
return super.scheduleDirect(run, 0, unit);
}
@Override
public Worker createWorker() {
return new ExecutorScheduler.ExecutorWorker(Runnable::run);
}
};
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
RxJavaPlugins.setIoSchedulerHandler(scheduler -> TEST_SCHEDULER);
RxJavaPlugins.setComputationSchedulerHandler(
scheduler -> TEST_SCHEDULER);
RxJavaPlugins.setNewThreadSchedulerHandler(
scheduler -> TEST_SCHEDULER);
RxAndroidPlugins.setMainThreadSchedulerHandler(
scheduler -> IMMEDIATE_SCHEDULER);
try {
base.evaluate();
} finally {
RxJavaPlugins.reset();
RxAndroidPlugins.reset();
}
}
};
}
public TestScheduler getTestScheduler() {
return TEST_SCHEDULER;
}
}
Тест презентера с новым правилом будет выглядеть следующим образом:
@RunWith(RobolectricTestRunner.class)
public class UserPresenterTest {
static final int TIMEOUT_SEC = 2;
static final String NAME = "Sasha";
@Rule public final MockitoRule rule = MockitoJUnit.rule();
@Rule public final RxImmediateSchedulerRule timeoutRule =
new RxImmediateSchedulerRule();
@Mock UserPresenter.Listener listener;
@Mock NameRepository nameRepository;
PublishSubject nameObservable = PublishSubject.create();
UserPresenter presenter;
@Before
public void setUp() {
when(nameRepository.getName()).thenReturn(nameObservable.firstOrError());
presenter = new UserPresenter(listener, nameRepository);
}
@Test
public void getUserName() {
presenter.getUserName();
timeoutRule.getTestScheduler().advanceTimeBy(TIMEOUT_SEC - 1, SECONDS);
nameObservable.onNext(NAME);
verify(listener).onUserNameLoaded(NAME);
}
@Test
public void getUserName_timeout() {
presenter.getUserName();
timeoutRule.getTestScheduler().advanceTimeBy(TIMEOUT_SEC + 1, SECONDS);
nameObservable.onNext(NAME);
verify(listener).onGettingUserNameError(any());
}
}
Тестирование кода, использующего Dagger 2
Для облегчения работы с графом зависимостей объектов отлично подходит паттерн Dependency Injection. Dagger 2 — это библиотека, которая поможет в реализации этого паттерна. Поэтому в большинстве наших Android приложений все компоненты предоставляются с помощью Dagger. Об использовании и преимуществах этой библиотеки можно написать отдельную статью, а тут мы рассмотрим, как тестировать приложения, её использующие.
To begin with, almost always when using Dagger, there exists ApplicationComponent
one that provides all the main dependencies of the application, and is initialized in the application class Application
, which, in turn, has a method for obtaining this component.
@Singleton
@Component(modules = {ContextModule.class})
public interface ApplicationComponent {
UserComponent createUserComponent();
}
public class MainApplication extends Application {
private ApplicationComponent component;
@Override
public void onCreate() {
super.onCreate();
component = DaggerApplicationComponent.builder()
.contextModule(new ContextModule(this))
.build();
}
public ApplicationComponent getComponent() {
return component;
}
}
We also create a Dagger module that will provide the repository:
@Module
public class UserModule {
@Provides
NameRepository provideNameRepository(@Private FileReader fileReader) {
return new NameRepository(fileReader);
}
@Private
@Provides
FileReader provideFileReader(@Private File file) {
return new FileReader(file);
}
@Private
@Provides
File provideFile(Context context) {
return new File(context.getFilesDir().getAbsoluteFile()
+ File.separator
+ "test_file");
}
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
private @interface Private {}
}
Change the fragment as follows to get the repository using Dagger:
public class UserFragment extends Fragment {
...
@Inject NameRepository nameRepository;
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
((MainApplication) getActivity().getApplication())
.getComponent()
.createUserComponent()
.injectsUserFragment(this);
textView = new TextView(getActivity());
disposable = nameRepository
.getName()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(name -> textView.setText(name));
return textView;
}
}
In addition to functional UI tests, it is good to have unit tests with locked dependencies. In order to provide moored objects with Dagger, you need to replace it with ApplicationComponent
a specially created component for tests. First of all, create a method for replacing the main component in Application
:
public void setComponentForTest(ApplicationComponent component) {
this.component = component;
}
In order not to replace the component in each class with fragment tests, we create a rule for this:
class TestDaggerComponentRule implements TestRule {
private final ActivityTestRule activityRule;
private final ApplicationComponent component;
TestDaggerComponentRule(
ActivityTestRule activityRule, ApplicationComponent component) {
this.activityRule = activityRule;
this.component = component;
}
@Override
public Statement apply(Statement statement, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
MainApplication application =
((MainApplication) activityRule.getActivity().getApplication());
ApplicationComponent originalComponent = application.getComponent();
application.setComponentForTest(component);
try {
statement.evaluate();
} finally {
application.setComponentForTest(originalComponent);
}
}
};
}
}
Note that you need to return the original component after the test, since Application is created one for all tests and it is worth returning it to its default state after each. Now create a rule that will carry out all the preparations for testing the fragment described above. Before each test, the screen will be unlocked, activity will be launched, the fragment we need will be opened, and the test Dagger component will be installed, which will provide dependency mobs.
public class FragmentTestRule
implements TestRule {
private ActivityTestRule activityRule;
private F fragment;
private RuleChain ruleChain;
public FragmentTestRule(
Class activityClass, F fragment, ApplicationComponent component) {
this.fragment = fragment;
this.activityRule = new ActivityTestRule<>(activityClass);
ruleChain = RuleChain
.outerRule(activityRule)
.around(new TestDaggerComponentRule<>(activityRule, component))
.around(new OpenFragmentRule<>(activityRule, fragment));
}
...
}
Install the test component in the test of our fragment:
@RunWith(AndroidJUnit4.class)
public class UserFragmentTest {
...
@Rule
public final FragmentTestRule fragmentRule =
new FragmentTestRule<>(
MainActivity.class,
new UserFragment(),
createTestApplicationComponent());
private ApplicationComponent createTestApplicationComponent() {
ApplicationComponent component = mock(ApplicationComponent.class);
when(component.createUserComponent())
.thenReturn(DaggerUserFragmentTest_TestUserComponent.create());
return component;
}
@Singleton
@Component(modules = {TestUserModule.class})
interface TestUserComponent extends UserComponent {}
@Module
static class TestUserModule {
@Provides
public NameRepository provideNameRepository() {
NameRepository nameRepository = mock(NameRepository.class);
when(nameRepository.getName()).thenReturn(
Single.fromCallable(() -> "Sasha"));
return nameRepository;
}
}
}
Tests run only for Debug applications
It happens that you need to add logic or go to the UI elements that developers need for more convenient testing and should only be displayed if the application is being built in debug mode. For example, let's make the presenter not only pass the name to the subscriber in the debug assembly, but also output it to the log:
class UserPresenter {
...
public void getUserName() {
nameRepository
.getName()
.timeout(TIMEOUT_SEC, SECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
name -> {
listener.onUserNameLoaded(name);
if (BuildConfig.DEBUG) {
logger.info(String.format("Name loaded: %s", name));
}
}, error -> listener.onGettingUserNameError(error.getMessage()));
}
}
This logic also needs to be tested, but tests should only be run with the appropriate type of application assembly. We will write a rule DebugTestRule
that will check the type of application assembly and run tests only for debug version:
public class DebugRule implements TestRule {
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
if (BuildConfig.DEBUG) {
base.evaluate();
}
}
};
}
}
A test with this rule will look like this:
class UserPresenterDebugTest {
...
@Rule public final DebugTestsRule debugRule = new DebugTestsRule();
@Test
public void userNameLogged() {
presenter.getUserName();
timeoutRule.getTestScheduler().triggerActions();
nameObservable.onNext(NAME);
verify(logger).info(contains(NAME));
}
}
Conclusion
In this article, we sorted out the basic libraries for writing tests and developed a set of tools based on TestRule and designed to solve the problems of activating activity and fragments, working with asynchronous code, a dagger, debugging code and an android emulator. The use of these tools allowed us to test non-obvious problems, reduce duplication of code, and generally improve readability of tests.
A complete example of an application and tests using all of the above libraries and utilities.
public class NameRepository {
private final FileReader fileReader;
public NameRepository(FileReader fileReader) {
this.fileReader = fileReader;
}
public Single getName() {
return Single.create(
emitter -> {
Gson gson = new Gson();
emitter.onSuccess(
gson.fromJson(fileReader.readFile(), User.class).name);
});
}
private static final class User {
String name;
}
}
@RunWith(MockitoJUnitRunner.class)
public class NameRepositoryTest {
@Mock FileReader fileReader;
NameRepository nameRepository;
@Before
public void setUp() throws IOException {
when(fileReader.readFile()).thenReturn("{name : Sasha}");
nameRepository = new NameRepository(fileReader);
}
@Test
public void getName() {
TestObserver observer = nameRepository.getName().test();
observer.assertValue("Sasha");
}
}
public class UserPresenter {
public interface Listener {
void onUserNameLoaded(String name);
void onGettingUserNameError(String message);
}
private final Listener listener;
private final NameRepository nameRepository;
private final Logger logger;
private Disposable disposable;
public UserPresenter(
Listener listener, NameRepository nameRepository, Logger logger) {
this.listener = listener;
this.nameRepository = nameRepository;
this.logger = logger;
}
public void getUserName() {
disposable =
nameRepository
.getName()
.timeout(2, SECONDS)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
name -> {
listener.onUserNameLoaded(name);
if (BuildConfig.DEBUG) {
logger.info(String.format("Name loaded: %s", name));
}
},
error -> listener.onGettingUserNameError(error.getMessage()));
}
public void stopLoading() {
disposable.dispose();
}
}
@RunWith(RobolectricTestRunner.class)
public class UserPresenterTest {
static final int TIMEOUT_SEC = 2;
static final String NAME = "Sasha";
@Rule public final MockitoRule rule = MockitoJUnit.rule();
@Rule public final RxImmediateSchedulerRule timeoutRule =
new RxImmediateSchedulerRule();
@Mock UserPresenter.Listener listener;
@Mock NameRepository nameRepository;
@Mock Logger logger;
PublishSubject nameObservable = PublishSubject.create();
UserPresenter presenter;
@Before
public void setUp() {
when(nameRepository.getName()).thenReturn(nameObservable.firstOrError());
presenter = new UserPresenter(listener, nameRepository, logger);
}
@Test
public void getUserName() {
presenter.getUserName();
timeoutRule.getTestScheduler().advanceTimeBy(TIMEOUT_SEC - 1, SECONDS);
nameObservable.onNext(NAME);
verify(listener).onUserNameLoaded(NAME);
}
@Test
public void getUserName_timeout() {
presenter.getUserName();
timeoutRule.getTestScheduler().advanceTimeBy(TIMEOUT_SEC + 1, SECONDS);
nameObservable.onNext(NAME);
verify(listener).onGettingUserNameError(any());
}
}
@RunWith(RobolectricTestRunner.class)
public class UserPresenterDebugTest {
private static final String NAME = "Sasha";
@Rule public final DebugRule debugRule = new DebugRule();
@Rule public final MockitoRule mockitoRule = MockitoJUnit.rule();
@Rule public final RxImmediateSchedulerRule timeoutRule =
new RxImmediateSchedulerRule();
@Mock UserPresenter.Listener listener;
@Mock NameRepository nameRepository;
@Mock Logger logger;
PublishSubject nameObservable = PublishSubject.create();
UserPresenter presenter;
@Before
public void setUp() {
when(nameRepository.getName()).thenReturn(nameObservable.firstOrError());
presenter = new UserPresenter(listener, nameRepository, logger);
}
@Test
public void userNameLogged() {
presenter.getUserName();
timeoutRule.getTestScheduler().triggerActions();
nameObservable.onNext(NAME);
verify(logger).info(contains(NAME));
}
}
public class UserFragment extends Fragment implements UserPresenter.Listener {
private TextView textView;
@Inject UserPresenter userPresenter;
@Override
public View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
((MainApplication) getActivity().getApplication())
.getComponent()
.createUserComponent(new UserModule(this))
.injectsUserFragment(this);
textView = new TextView(getActivity());
userPresenter.getUserName();
return textView;
}
@Override
public void onUserNameLoaded(String name) {
textView.setText(name);
}
@Override
public void onGettingUserNameError(String message) {
textView.setText(message);
}
@Override
public void onDestroyView() {
super.onDestroyView();
userPresenter.stopLoading();
textView = null;
}
}
@RunWith(AndroidJUnit4.class)
public class UserFragmentIntegrationTest {
@ClassRule
public static TestRule asyncRule =
new FragmentAsyncTestRule<>(MainActivity.class, new UserFragment());
@Rule
public final RuleChain rules = RuleChain
.outerRule(new CreateFileRule(getTestFile(), "{name : Sasha}"))
.around(new FragmentTestRule<>(MainActivity.class, new UserFragment()));
@Test
public void nameDisplayed() {
await()
.atMost(5, SECONDS)
.ignoreExceptions()
.untilAsserted(
() ->
onView(ViewMatchers.withText("Sasha"))
.check(matches(isDisplayed())));
}
private static File getTestFile() {
return new File(
InstrumentationRegistry.getTargetContext()
.getFilesDir()
.getAbsoluteFile() + File.separator + "test_file");
}
}
@RunWith(AndroidJUnit4.class)
public class UserFragmentTest {
@ClassRule
public static TestRule asyncRule =
new FragmentAsyncTestRule<>(MainActivity.class, new UserFragment());
@Rule
public final FragmentTestRule fragmentRule =
new FragmentTestRule<>(
MainActivity.class,
new UserFragment(),
createTestApplicationComponent());
@Test
public void getNameMethodCalledOnCreate() {
verify(fragmentRule.getFragment().userPresenter).getUserName();
}
private ApplicationComponent createTestApplicationComponent() {
ApplicationComponent component = mock(ApplicationComponent.class);
when(component.createUserComponent(any(UserModule.class)))
.thenReturn(DaggerUserFragmentTest_TestUserComponent.create());
return component;
}
@Singleton
@Component(modules = {TestUserModule.class})
interface TestUserComponent extends UserComponent {}
@Module
static class TestUserModule {
@Provides
public UserPresenter provideUserPresenter() {
return mock(UserPresenter.class);
}
}
}
Acknowledgments
This article was written in collaboration with Evgeny Aseev . He wrote a significant part of the code of our libraries. Thank you for the review of the article text and code - Andrei Tarashkevich , Ruslan Login . Thanks to the sponsor of the project, AURA Devices.