转自:http://makingiants.github.io/blog/gitlabci-toolbox-for-android/

You can setup your own CI server with real devices on gitlab with these scripts and setups. I found this issues that are common between any CI server:

  • Copy env/secret variables to local project before each run.
  • Run commands on every device connected.
  • Run tests with test coverage and see it inside gitlab-ci.
  • Install app dependencies before each run if needed.
  • You need to setup (How to) your gitlab runner executor to shell to make it run processes “natively” instead of start a new Docker instance for every test.

1. Gitlab will run everything you put in .gitlab-ci.yml, check this sample:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
stages:
- build
- test
cache:
key: ${CI_PROJECT_ID}
paths:
- .gradle/
before_script:
# You just need to have all your setted env variables in the local file (not necesarily just the needed ones making it simple but maybe insecure)
- sh scripts/cp-env-to-properties.sh
# to avoid INSTALL_FAILED_UPDATE_INCOMPATIBLE
- sh scripts/adb-all.sh uninstall your.app.package
- sh scripts/adb-all.sh uninstall your.app.package.tests # .test is setted in app/build.gradle with testApplicationId
build:
type: build
script:
- sh scripts/install-android-dependencies.sh
- ./gradlew assemble --stacktrace # You can add -scan from Gradle Scan Plugin to improve profiling
unit_tests:
type: test
script:
- ./gradlew testDebugUnitTestCoverage --stacktrace
# Print on terminal just the total of the coverage
- grep -Eo "Total.*?([0–9]{1,3})%" app/build/reports/jacoco/testDebugUnitTestCoverage/html/index.html
artifacts:
when: always
paths:
- app/build/reports
ui_tests:
type: test
script:
# Wake up screen
- sh scripts/adb-all.sh shell input keyevent 26 # KEYCODE_POWER
- sh scripts/adb-all.sh shell input keyevent 3 # HOME
# Wait until the devices are ready
- sh scripts/adb-all.sh adb wait-for-device
- sh scripts/adb-all.sh adb shell 'while [[ -z $(getprop sys.boot_completed) ]]; do sleep 1; done; input keyevent 82'
- ./gradlew spoonDebugAndroidTest --stacktrace
- ./gradlew createDebugCoverageReport --stacktrace
# Print on terminal just the total of the coverage
- grep -Eo "Total.*?([0–9]{1,3})%" app/build/reports/coverage/debug/index.html
artifacts:
when: always
paths:
- app/build/outputs
- app/build/spoon
- app/build/reports
  • I use spoon to run UI tests, update the gradle task in case you use another thing.
  • All scripts in scripts/ will be explained in 3. .

2. Android project setup:

  • Setup code coverage, tests and spoon:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//...
apply plugin: 'spoon'
apply from: "../test-setup.gradle"
apply from: '../jacoco.gradle'
android {
//...
defaultConfig {
//...
testApplicationId "${appId}.tests"
}
buildTypes {
debug {
//...
testCoverageEnabled true
}
}
}
spoon {
// debug = true
grantAllPermissions = true
shard = true
codeCoverage = true
// ignoreFailures = true
// failIfNoDeviceConnected = true
}
  • Setup jacoco tasks to generate code coverage files:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// Merge of
// https://github.com/mgouline/android-samples/blob/master/jacoco/app/build.gradle
// and https://github.com/pushtorefresh/storio/blob/master/gradle/jacoco-android.gradle
// Requires Jacoco plugin in build classpath.
apply plugin: 'jacoco'
// Enables code coverage for JVM tests.
// Android Gradle Plugin out of the box supports only code coverage for instrumentation tests.
project.afterEvaluate {
// Grab all build types and product flavors
def buildTypes = android.buildTypes.collect { type -> type.name }
def productFlavors = android.productFlavors.collect { flavor -> flavor.name }
// When no product flavors defined, use empty
if (!productFlavors) productFlavors.add('')
productFlavors.each { productFlavorName ->
buildTypes.each { buildTypeName ->
def sourceName, sourcePath
if (!productFlavorName) {
sourceName = sourcePath = "${buildTypeName}"
} else {
sourceName = "${productFlavorName}${buildTypeName.capitalize()}"
sourcePath = "${productFlavorName}/${buildTypeName}"
}
def testTaskName = "test${sourceName.capitalize()}UnitTest"
def coverageTaskName = "${testTaskName}Coverage"
// Create coverage task of form 'testFlavorTypeCoverage' depending on 'testFlavorTypeUnitTest'
task "${coverageTaskName}"(type: JacocoReport, dependsOn: "$testTaskName") {
group = 'Reporting'
description = "Generate Jacoco coverage reports for the ${sourceName.capitalize()} build."
classDirectories = fileTree(
dir: "${project.buildDir}/intermediates/classes/${sourcePath}",
excludes: ['**/R.class',
'**/R$*.class',
'**/*$ViewInjector*.*',
'**/*$ViewBinder*.*',
'**/BuildConfig.*',
'**/Manifest*.*',
'**/*$Lambda$*.*', // Jacoco can not handle several "$" in class name.
'**/*$inlined$*.*', // Kotlin specific, Jacoco can not handle several "$" in class name.
'**/*Module.*', // Modules for Dagger.
'**/*Dagger*.*', // Dagger auto-generated code.
'**/*MembersInjector*.*', // Dagger auto-generated code.
'**/*_Provide*Factory*.*'] // Dagger auto-generated code.
)
def coverageSourceDirs = [
'src/main/java',
"src/$productFlavorName/java",
"src/$buildTypeName/java"
]
additionalSourceDirs = files(coverageSourceDirs)
sourceDirectories = files(coverageSourceDirs)
executionData = files("${project.buildDir}/jacoco/${testTaskName}.exec")
reports {
xml.enabled = true
html.enabled = true
}
}
build.dependsOn "${coverageTaskName}"
}
}
}
  • Setup tests to print each test result (to see in the job results which ones failed)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
/*
* Print test results on terminal.
* From: http://stackoverflow.com/a/36199263/273119
*/
tasks.withType(Test) {
testLogging {
events TestLogEvent.FAILED,
TestLogEvent.PASSED,
TestLogEvent.SKIPPED,
TestLogEvent.STANDARD_ERROR,
TestLogEvent.STANDARD_OUT
exceptionFormat TestExceptionFormat.FULL
showCauses true
showExceptions true
showStackTraces true
}
}

3. Usefull scripts:

  • Install your project dependencies on the machine by terminal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#!/usr/bin/env bash
#
# Install required dependencies
# sdkmanager can be found in $ANDROID_HOME/tools/bin/sdkmanager
#
#
# Accept licences
# src http://vgaidarji.me/blog/2017/05/31/automatically-accept-android-sdkmanager-licenses/
#
/usr/bin/expect -c '
set timeout -1;
spawn '"${ANDROID_HOME}"'/tools/bin/sdkmanager --licenses;
expect {
"y/N" { exp_send "y\r" ; exp_continue }
eof
}
'
# Install every dependency
for I in "platforms;android-25" \
"platforms;android-23" \
"platforms;android-21" \
"build-tools;25.0.3" \
"build-tools;25.0.2" \
"extras;google;m2repository" \
"extras;android;m2repository" \
"extras;google;google_play_services"; do
echo "Trying to update with tools/bin/sdkmanager: " $I
sdkmanager $I
done
sdkmanager --update
  • Run the same command on every connected device (avoid INSTALL_FAILED_UPDATE_INCOMPATIBLE)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env bash
#
# Run a command on each adb connected device (adb fail with INSTALL_FAILED_UPDATE_INCOMPATIBLE if multiple devices are connected)
#
# Sample:
# To uninstall an apk on every device:
# $ sh adb-all.sh uninstall my.apk
adb devices | while read line
do
if [ ! "$line" = "" ] && [ `echo $line | awk '{print $2}'` = "device" ]
then
device=`echo $line | awk '{print $1}'`
echo "$device $@ ..."
adb -s $device $@
fi
done
  • Copy every env/secret variable into gradle.propertes file inside the project (gmaps key or something like that)
1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env bash
#
# Copy env variables to app module gradle properties file
#
PROPERTIES_FILE_PATH=gradle.properties
set +x // dont print the next lines on run script
printenv | tr ' ' '\n' > $PROPERTIES_FILE_PATH
set -x

NOTE: you can use System.getenv("VAR_NAME") in your gradle files if you dont need a local .properties file for your project. (thanks to reddit/Atraac)

Gitlab-ci Advantages:

  • “Free” testing farm (your own devices, maybe uncle/grandmother/mom old devices).
  • “Free” Parallel Job Executions based on your machine (you can setup your gitlab-ci runner to allow this).
  • “Secure” handling of signing keys (it depends to you).
  • “Secure” handling of services config keys (like google console config jsons).
  • Fastest test/build/everything jobs since gradle daemon can run in your machine (on CircleCi/Travis/Etc you need to disable it since they start a new virtual machine for every test).

Links