Тестування коду: об’єднання звіту покриття для android- та unit-тестів з Jacoco і SonarQube

Деякий час тому перед нами постало завдання: як отримати звіт з повного покриття коду тестами? Ми знаємо, що немає жодних проблем з отриманням покриття unit-тестів чи android-тестів окремо, але ж нам потрібен повний, об’єднаний звіт. І це далеко не тривіальне завдання. Саме рішення цієї задачі ми і хочемо описати.

Для прикладу ми зробили невеликий додаток, мета якого — по кліку на кнопку зробити REST-запит, отримати у відповідь свій IP та показати його на екрані:

Для легшого тестування ми використали патерн MVP та DI з Dagger2 в нашому додатку. Структура проекту виглядає так:

Rest API ми зорганізували з допомогою RX + Retrofit2 (ото сюрприз), а ServerAPI ін’єктили, щоб потім було легше його підмінити в UI-тестах:

@Module
public class ServerModule {

	private static final String BASE_URL = "http://httpbin.org";

	@Provides
	@Singleton
	public ServerApi provideServerApi(){
   	return getServerApi();
	}

	protected ServerApi getServerApi() {
   	Retrofit mRetrofit = new Retrofit.Builder()
               .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
               .addConverterFactory(GsonConverterFactory.create())
           	.baseUrl(BASE_URL)
           	.build();

   	return mRetrofit.create(ServerApi.class);
	}

}

Отже, коли юзер клікає кнопку «GET IP», наша activity звертається до метода презентора, який і виконує запит:

public class MainActivity extends AppCompatActivity implements MainActivityContract {

	private MainActivityPresenter mPresenter;
	@Inject
	protected ServerApi mServerApi;
	private ActivityMainBinding mBinding;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
   	super.onCreate(savedInstanceState);
   	mBinding = DataBindingUtil.setContentView(this, R.layout.activity_main);

   	((CoverageApp) CoverageApp.provideAppContext()).dataComponent().inject(this);

   	mPresenter = new MainActivityPresenter(mServerApi, this);
   	mBinding.getIpBtn.setOnClickListener(v -> mPresenter.getIp());
	}

	@Override
	public void onSuccess(String origin) {
   	mBinding.ipTv.setText(origin);
	}

	@Override
	public void onError(String error) {
   	mBinding.ipTv.setText(error);
	}
}

Та коли презентер має якусь відповідь, він викликає відповідні методи колбека:

public class MainActivityPresenter {

	private ServerApi mServerApi;
	private MainActivityContract mCallBack;

	public MainActivityPresenter(ServerApi serverApi, MainActivityContract callBack) {
   	this.mCallBack = callBack;
   	this.mServerApi = serverApi;
	}

	public void getIp() {
   	mServerApi.getIp()
               .subscribeOn(AppSchedulers.io())
           	.observeOn(AppSchedulers.mainThread())
           	.subscribe(ResponseModel -> {
               	if (null != ResponseModel.getOrigin() && !ResponseModel.getOrigin().isEmpty()) {
                   	mCallBack.onSuccess(ResponseModel.getOrigin());
               	} else {
                   	mCallBack.onError("Error");
               	}
           	}, error -> mCallBack.onError(error.getMessage()));
	}
}

Тут треба звернути увагу на кастомний клас AppSchedulers, який ми використовуємо для RX. Його мета — підмінити асинхронні потоки на синхронні при виконанні тестів. Усе інше — тривіальна реалізація запиту.

Написання тестів

Настав час тестів. Ми написали декілька тестів (винятково для прикладу):
— Unit test;
— UI Espresso test;
— Robolectric test — ця група тестів виявилася трохи норовливою, тому ми і виділили її окремо (але про це пізніше).

Давайте почнемо з unit-тестів. Для імітування об’єктів ми використали Mockito:

public class MainActivityPresenterTests {

	private MainActivityPresenter mPresenter;

	@Rule
	public SynchronousSchedulers schedulers = new SynchronousSchedulers();

	@Mock
	private MainActivityContract mCallBack;

	@Mock
	private ServerApi mApi;

	@Before
	public void setUp() {
   	MockitoAnnotations.initMocks(this);
	}

	@Test
	public void test_goodResponse_getIp() throws Exception {
   	ResponseModel response = new ResponseModel();
   	String IP = "192.168.0.1";
   	response.setOrigin(IP);
   	when(mApi.getIp()).thenReturn(Single.just(response));
   	mPresenter = new MainActivityPresenter(mApi, mCallBack);
   	mPresenter.getIp();
   	verify(mCallBack, times(1)).onSuccess(anyString());
	}

	@Test
	public void test_error_getIp() throws Exception {
   	when(mApi.getIp()).thenReturn(Single.error(new ConnectException("Error")));
   	mPresenter = new MainActivityPresenter(mApi, mCallBack);
   	mPresenter.getIp();
   	verify(mCallBack, times(1)).onError("Error");
	}
}

UI Espresso тест:

@RunWith(AndroidJUnit4.class)
public class MainActivityTest {

	private MockServer mMockServer;

	private final MyTestRule component =
       	new MyTestRule(InstrumentationRegistry.getTargetContext());
	private final ActivityTestRule<MainActivity> activityRule =
       	new ActivityTestRule<>(MainActivity.class, false, false);

	@Rule
	public TestRule chain = RuleChain.outerRule(component).around(activityRule);

	@Before
	public void setUp(){
   	CoverageApp mApp = (CoverageApp) getInstrumentation().getTargetContext().getApplicationContext();
   	mMockServer = (MockServer) mApp.dataComponent().getServerApi();
	}

	public void launchActivity(){
   	activityRule.launchActivity(null);
	}

	@Test
	public void test_goodResponse(){
   	ResponseModel response = new ResponseModel();
   	String IP = "192.168.0.1";
   	response.setOrigin(IP);
   	mMockServer.setResponse(response);
   	launchActivity();
   	onView(withId(R.id.get_ip_btn)).perform(click());
   	onView(withId(R.id.ip_tv)).check(matches(withText(IP)));
	}
}

Тут за допомогою кастомного рула ми підмінюємо dataComponent та підставляємо MockServerApi, який буде імітувати нам сервер та його відповіді.

Robolectric тест ми написали лише «для галочки», нашою метою було включити цей тип тестів до проекту, він не тестує нічого корисного.

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, sdk = 21)
public class MainActivityPresenterRobo {

	private MainActivity mActivity;
	private ActivityController<MainActivity> actControl;

	@Before
	public void setUp(){
   	actControl = Robolectric.buildActivity(MainActivity.class);
   	mActivity = actControl.create().get();
	}

	@Test
	public void test_click() throws Exception{
   	actControl.resume();
   	TextView ipTv = (TextView) mActivity.findViewById(R.id.ip_tv);
   	assertEquals("Here will appear your IP", ipTv.getText());
	}
}

Вимірювання покриття

Тепер, коли у нас є усі ці тести, ми можемо перейти до вимірів покриття. Як ми і писали вище, не існує зеленої кнопки «покриття всіх тестів», і все, що можна зробити легко — це сформувати звіти з покриття окремо.

Перейдіть до ваших unit-тестів та просто запускайте їх із покриттям:

Після того, як усі тести будуть пройдені, можна буде побачити покриття:


Тепер давайте зробимо звіт по android-тестах. Для цього доведеться трохи підтюнінгувати gradle файл (як саме, покажемо трошки пізніше), потім йдемо до вкладки gradle в Android Studio:
1) знаходимо задачу «createDebugCoverageTest»;
2) doubleClick (звичайно ж, для любителів писати все руками, це можна зробити і в терміналі);
3) чекаємо прогону усіх тестів.

Увага: має бути запущений емулятор або під’єднаний девайс. Коли студія скаже, що все «ОК», можна знайти звіт за адресою: “YOUR_PROJECT_PATH\app\build\reports\coverage\debug\index.html”.

Відкриваємо і дивимось на веселі малюнки покриття:

Об’єднання звітів

Отже, в нас вже є результати покриття нашого коду unit-, android- та robolectric-тестами, та ми хочемо їх об’єднати, щоб зрозуміти, чи відповідає наше покриття вимогам, а ще було б гарно проаналізувати якість коду.

Все це можна зробити за допомогою двох інструментів — Jacoco та SonarQube. Давайте налаштуємо їх в окремих *.gradle файлах (jacoco.gradle та sonarqube.gradle відповідно) та підключимо їх в нашому build.gradle файлі:

apply from: './fileName.gradle'

Також зверніть увагу, що в app/build.gradle необхідно додати декілька рядків для увімкнення підрахунку покриття (пам’ятаєте, ми говорили про це раніше):

android{
debug {
  		testCoverageEnabled true
}
}

Jacoco — це безкоштовний інструмент для проектів на Java, який вираховує покриття. Jacoco активно розвивається та підтримується — отже, можна знайти багато прикладів та документації).

Для початку наведемо лістинг jacoco таски:

apply plugin: 'jacoco'

jacoco {
  toolVersion "0.7.6.201602180812"
}
// run ./gradlew clean createDebugCoverageReport jacocoTestReport

task jacocoTestReport(type: JacocoReport, dependsOn: "testDebugUnitTest") {
  group = "Reporting"
  description = "Generate Jacoco coverage reports"

  reports {
      xml.enabled = true
      html.enabled = true
  }

  def fileFilter = ['**/R.class',
                    '**/R$*.class',
                    '**/BuildConfig.*',
                    '**/Manifest*.*',
                    'android/**/*.*',
                    '**/Lambda$*.class', //Retrolambda
                    '**/Lambda.class',
                    '**/*Lambda.class',
                    '**/*Lambda*.class',
                    '**/*Lambda*.*',
                    '**/*Builder.*',
                    '**/*_MembersInjector.class',  //Dagger2 generated code
                    '**/*_MembersInjector*.*',  //Dagger2 generated code
                    '**/*_*Factory*.*', //Dagger2 generated code
                    '**/*Component*.*', //Dagger2 generated code
                    '**/*Module*.*' //Dagger2 generated code
  ]
  def debugTree = fileTree(dir: "${buildDir}/intermediates/classes/debug", excludes: fileFilter)
  def mainSrc = "${project.projectDir}/src/main/java"

  sourceDirectories = files([mainSrc])
  classDirectories = files([debugTree])
  executionData = fileTree(dir: project.projectDir, includes:
          ['**/*.exec' , '**/*.ec'])
}

Тепер давайте поговоримо, що там таке коїться:
1) Додаємо плагін jacoco для gradle;
2) Встановлюємо версію jacoco;
3) Далі описуємо саму таску, яка запустить тести і з’єднає їх результати. Детальніше про gradle таски можна почитати тут.

Коротенько про саму таску: називаємо її taskName, встановлюємо її тип та таски, від яких вона залежить (чи які залежать від неї), вказуємо її групу та описання. Кажемо, який звіт вона має зробити (згенерувати).

Далі зазначаємо фільтр — список файлів, які потрібно виключити з аналізу. В нашому випадку це згенеровані android-файли, ретролямбди (так, ми майже завжди їх використовуємо) та файли Даггера.

Встановлюємо sourceDirectories, а саме — які файли аналізувати, встановлюємо classDirectories - class files, та не забуваємо додати fileFilter.

А тепер найцікавіша частина — створюємо executionData. Звідси будуть підтягуватися данні для об’єднання.

Звіт про покриття для unit-тестів записується в *.exec file та зберігається за адресою:
YOUR_PROJECT_PATH\app\build\jacoco\*.exec

androidTests зберігаються в *.ec file та знаходяться за адресою:
YOUR_PROJECT_PATH\app\build\outputs\code-coverage\connected\**.ec

Отже, здається, наразі цього достатньо, і ми можемо сформувати об’єднаний звіт з покриття. Для цього відкрийте консоль та введіть наступну команду:

./gradlew clean createDebugCoverageReport jacocoTestReport

Послідовність команд дуже важлива: спочатку android-тести, потім запустяться unit-тести (наша таска сама запустить «testDebugUnitTests»).

І тепер можемо насолоджуватись повним звітом з покриття нашого кода тестами. Йдіть за адресою YOUR_PROJECT_PATH\app\build\reports\jacoco\jacocoTestReport\index.html, там і знайдете цей звіт.

І все б здавалося круто, і ми можемо бути щасливими (та виміряними), але ми звернули увагу на одну не дуже приємну фічу. Пам’ятаєте, ми чогось додали Robotolectric-тести до нашого списку тестів? От і настав їх момент. Виявляється, ці тести не включаються до жодного зі звітів про покриття «з коробки». Але є ліки: додайте до app/build.gradle файлу такі рядки:

android{
testOptions {
  		unitTests.all {
      			jacoco {
          			includeNoLocationClasses = true
      				}
  				}	
}
}

Саме тепер є повний звіт з покриття — треба тільки перезапустити таску.

Аналіз та наочність

На цьому можна було б зупинитись, але нам стало цікаво, і ми пішли далі. Ми хотіли мати весь аналіз коду в одному зручному та наглядному інструменті (покриття, якість, стиль тощо). Ми вирішили «згодувати» наш звіт з покриття в sonarQube.

Якщо коротко: завантажуємо SonarQube, встановлюємо його, стартуємо сервер SonarQube. Якщо щось пішло не так, в чому ми дуже сумніваємось, юзайте google, там дійсно дуже багато інформації та можна швидко знайти відповіді на питання.

Встановивши SonarQube, можна додати безліч різноманітних плагінів до нього. В нашому випадку ми використовували такі плагіни: Android, CheckStyle, FindBugs, Git, Java, XML. Також є можливість створити кастомні правила аналізу коду на будь-який смак та вимоги.

Як тільки вдалося опанувати сервер SonarQube, можна повернутися до свого android-проекту та налаштувати таску SonarQube:

apply plugin: 'org.sonarqube'

ext {
  SONAR_HOST = "http://localhosts:9000/"
}
sonarqube() {
    properties {
      /* SonarQube needs to be informed about your libraries and the android.jar to understand that methods like
   * onResume() is called by the Android framework. Without that information SonarQube will very likely create warnings
   * that those methods are never used and they should be removed. Same applies for libraries where parent classes
   * are required to understand how a class works and is used. */
      def libraries = project.android.sdkDirectory.getPath() + "/platforms/android-24/android.jar," +
              "${project.buildDir}/intermediates/exploded-aar/**/classes.jar"
      property "sonar.projectName", (String) android.defaultConfig.applicationId
      property "sonar.projectKey", android.defaultConfig.applicationId + android.defaultConfig.versionName
      property "sonar.sourceEncoding", "UTF-8"

      property "sonar.sources", "./src/main/"
      property "sonar.libraries", libraries
      property "sonar.binaries", "/intermediates/classes/debug"
      property "sonar.java.binaries", "${project.buildDir}/intermediates/classes/debug"
      property "sonar.java.libraries", libraries
      property "sonar.exclusions", "build/**,**/*.png,*.iml, **/*generated*, "

      property "sonar.import_unknown_files", true
      property "sonar.android.lint.report", "./build/outputs/lint-results.xml"
      property "sonar.host.url", SONAR_HOST
      property "sonar.tests", "./src/test/, ./src/androidTest/"
      property "sonar.jacoco.reportPath", fileTree(dir: project.projectDir, includes: ['**/*.exec'])
      property "sonar.java.test.binaries", "${project.buildDir}/intermediates/classes/debug"
      property "sonar.jacoco.itReportPath", fileTree(dir: project.projectDir, includes: ['**/*.ec'])
      property "sonar.java.test.libraries", libraries

  }
}

Пояснення:
1) Додаємо gradle-плагін;
2) Виносимо SonarQube-хост в розширення для зручності;
3) Описуємо налаштування SonarQube (приклади).

В нашому випадку зверніть увагу на ці налаштування:
— sonar.java.test.binaries
— sonar.java.binaries
— sonar.tests
— sonar.jacoco.reportPath
— sonar.jacoco.itReportPath

Давайте запустимо:
./gradlew clean createDebugCoverageReport jacocoTestReport sonarqube

Тепер йдемо до серверу SonarQube, знаходимо наш проект та аналізуємо:

Є там і такі малюночки:

Також, пірнаючи глибше в ці звіти, можна знайти багато корисної інформації. Наприклад, покриття умов, покриття порядкове і таке інше. Також не забувайте, що SonarQube непогано аналізує код — перевіряйте код і робіть ваш проект кращим.


Стаття написана у співавторстві з моїм колегою Сергієм Гречухою.

Проект доступний на BitBucket. Якщо є запитання чи пропозиції, не вагайтеся та пишіть їх в коментарях.

8 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

Якось відразу не написали, а зараз ще раз наткнувся: jeroenmols.com/.../2016/09/01/coveragecost
бережіть Ваш час

Статья что надо, спасибо


@Mock
private MainActivityContract mCallBack;

@Mock
private ServerApi mApi;

@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
}

@InjectMocks
 — не?

Чому ж одразу

— не?
? Можна і так, але в такому випадку треба не забути ще й раннер вказати. В цілому (згідно із документацією) ці варіанти еквівалентні

Стоит добавить, для дебага

testCoverageEnabled true
нужно отключать, чтоб были видны значения переменных

Вы ведь используете Dagger, зачем инжектить ServerApi в MainActivity и передавать его в конструктор Presenter’а?

Замечание может и существенное для статьи про Dagger, но к сожалению эта статья не про него

Подписаться на комментарии