Тестування коду: об’єднання звіту покриття для 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. Якщо є запитання чи пропозиції, не вагайтеся та пишіть їх в коментарях.