Skip to content

Commit

Permalink
Support for Android Test Orchestrator
Browse files Browse the repository at this point in the history
With this change, FailTestOnLeakRunListener now hooks into AndroidJUnitRunner when a test run starts to detect if the tests are run by Android Test Orchestrator, in which case we take over the binder callback to send optionally send an extra failure when a leak is detected in test.

This works whether newRunListenerMode is false or true (ie whether OrchestratedInstrumentationListener runs before or after FailTestOnLeakRunListener).

When a test finishes without any failure, we intercept any "test finished" message, check for memory leaks, optionally send a "test failure" message then send the "test finished message". This intercepting is done assuming a single thread calls both listeners (see `org.junit.runner.notification.RunNotifier#wrapIfNotThreadSafe`).

As before with `Instrumentation.sendStatus()`, the current approach is tied to the test runner implementation details and might have to change to adapt to new `androidx.test:runner` versions.

Other change: FailTestOnLeakRunListener will wait up to 2 seconds for all activities to be destroyed before running leak detection, and otherwise proceed.

Fixes #1046
  • Loading branch information
pyricau committed Jun 8, 2020
1 parent 384dbdd commit d2492f8
Show file tree
Hide file tree
Showing 8 changed files with 255 additions and 31 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ buildscript {
espresso: 'androidx.test.espresso:espresso-core:3.1.0',
rules: 'androidx.test:rules:1.1.0',
runner: 'androidx.test:runner:1.1.0',
orchestrator: 'androidx.test:orchestrator:1.1.0',
],
],
android_support: 'com.android.support:support-v4:28.0.0',
Expand Down
7 changes: 6 additions & 1 deletion docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,12 @@ android {
// ...
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArgument "listener", "leakcanary.FailTestOnLeakRunListener"
testInstrumentationRunnerArgument "listener", "leakcanary.FailTestOnLeakRunListener"
// If you're using Android Test Orchestrator
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
}
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package androidx.test.orchestrator.instrumentationlistener

import android.os.Bundle
import androidx.test.orchestrator.callback.OrchestratorCallback

internal fun OrchestratedInstrumentationListener.delegateSendTestNotification(
onSendTestNotification: (Bundle, (Bundle) -> Unit) -> Unit
) {
val realCallback = odoCallback

val sendTestNotificationCallback: (Bundle) -> Unit = { bundle ->
realCallback.sendTestNotification(bundle)
}

odoCallback = object : OrchestratorCallback by realCallback {
override fun sendTestNotification(bundle: Bundle) {
onSendTestNotification(bundle, sendTestNotificationCallback)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
*/
package leakcanary

import android.app.Instrumentation
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import android.util.Log
import androidx.test.internal.runner.listener.InstrumentationResultPrinter
import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_VALUE_RESULT_FAILURE
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import leakcanary.InstrumentationLeakDetector.Result.AnalysisPerformed
import leakcanary.internal.TestResultPublisher
import org.junit.runner.Description
import org.junit.runner.Result
import org.junit.runner.notification.Failure
Expand All @@ -30,6 +31,10 @@ import shark.HeapAnalysis
import shark.HeapAnalysisFailure
import shark.HeapAnalysisSuccess
import shark.SharkLog
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Proxy
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit.SECONDS

/**
*
Expand All @@ -43,23 +48,58 @@ import shark.SharkLog
* @see InstrumentationLeakDetector
*/
open class FailTestOnLeakRunListener : RunListener() {
private lateinit var bundle: Bundle
private var _currentTestDescription: Description? = null
private val currentTestDescription: Description
get() = _currentTestDescription!!

private var skipLeakDetectionReason: String? = null

private lateinit var testResultPublisher: TestResultPublisher

@Volatile
private var allActivitiesDestroyedLatch: CountDownLatch? = null

override fun testRunStarted(description: Description) {
InstrumentationLeakDetector.updateConfig()
testResultPublisher = TestResultPublisher.install()
trackActivities()
}

private fun trackActivities() {
val instrumentation = getInstrumentation()!!
val application = instrumentation.targetContext.applicationContext as Application
application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks by noOpDelegate() {

var activitiesWaitingForDestroyed = 0

override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
if (activitiesWaitingForDestroyed == 0) {
allActivitiesDestroyedLatch = CountDownLatch(1)
}
activitiesWaitingForDestroyed++
}

override fun onActivityDestroyed(activity: Activity) {
activitiesWaitingForDestroyed--
if (activitiesWaitingForDestroyed == 0) {
allActivitiesDestroyedLatch!!.countDown()
}
}
})
}

override fun testRunFinished(result: Result) {
}

override fun testStarted(description: Description) {
_currentTestDescription = description
skipLeakDetectionReason = skipLeakDetectionReason(description)
if (skipLeakDetectionReason != null) {
return
}
val testClass = description.className
val testName = description.methodName

bundle = Bundle()
bundle.putString(
Instrumentation.REPORT_KEY_IDENTIFIER, FailTestOnLeakRunListener::class.java.name
)
bundle.putString(InstrumentationResultPrinter.REPORT_KEY_NAME_CLASS, testClass)
bundle.putString(InstrumentationResultPrinter.REPORT_KEY_NAME_TEST, testName)
}

/**
Expand All @@ -84,21 +124,21 @@ open class FailTestOnLeakRunListener : RunListener() {
}

override fun testFinished(description: Description) {
detectLeaks()
if (skipLeakDetectionReason == null) {
detectLeaks()
} else {
SharkLog.d { "Skipping leak detection because the test $skipLeakDetectionReason" }
skipLeakDetectionReason = null
}
AppWatcher.objectWatcher.clearWatchedObjects()
_currentTestDescription = null
testResultPublisher.publishTestFinished()
}

override fun testRunStarted(description: Description) {
InstrumentationLeakDetector.updateConfig()
}

override fun testRunFinished(result: Result) {}

private fun detectLeaks() {
if (skipLeakDetectionReason != null) {
SharkLog.d { "Skipping leak detection because the test $skipLeakDetectionReason" }
skipLeakDetectionReason = null
return
val allActivitiesDestroyed = allActivitiesDestroyedLatch?.await(2, SECONDS) ?: true
if (!allActivitiesDestroyed) {
SharkLog.d { "Leak detection proceeding with some activities still not in destroyed state" }
}

val leakDetector = InstrumentationLeakDetector()
Expand Down Expand Up @@ -130,10 +170,19 @@ open class FailTestOnLeakRunListener : RunListener() {
}

/**
* Reports that the test has failed, with the provided [message].
* Reports that the test has failed, with the provided [trace].
*/
protected fun failTest(message: String) {
bundle.putString(InstrumentationResultPrinter.REPORT_KEY_STACK, message)
getInstrumentation().sendStatus(REPORT_VALUE_RESULT_FAILURE, bundle)
protected fun failTest(trace: String) {
testResultPublisher.publishTestFailure(currentTestDescription, trace)
}

private inline fun <reified T : Any> noOpDelegate(): T {
val javaClass = T::class.java
val noOpHandler = InvocationHandler { _, _, _ ->
// no op
}
return Proxy.newProxyInstance(
javaClass.classLoader, arrayOf(javaClass), noOpHandler
) as T
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package leakcanary.internal

import android.app.Instrumentation.REPORT_KEY_IDENTIFIER
import android.os.Bundle
import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_KEY_NAME_CLASS
import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_KEY_NAME_TEST
import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_KEY_STACK
import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_VALUE_RESULT_FAILURE
import androidx.test.platform.app.InstrumentationRegistry
import leakcanary.FailTestOnLeakRunListener
import org.junit.runner.Description

internal class InstrumentationTestResultPublisher : TestResultPublisher {
override fun publishTestFinished() {
}

override fun publishTestFailure(
description: Description,
trace: String
) {
val bundle = Bundle()
bundle.putString(REPORT_KEY_IDENTIFIER, FailTestOnLeakRunListener::class.java.name)
bundle.putString(REPORT_KEY_NAME_CLASS, description.className)
bundle.putString(REPORT_KEY_NAME_TEST, description.methodName)
bundle.putString(REPORT_KEY_STACK, trace)

val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.sendStatus(REPORT_VALUE_RESULT_FAILURE, bundle)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package leakcanary.internal

import android.os.Bundle
import androidx.test.orchestrator.instrumentationlistener.OrchestratedInstrumentationListener
import androidx.test.orchestrator.instrumentationlistener.delegateSendTestNotification
import androidx.test.orchestrator.junit.ParcelableDescription
import androidx.test.orchestrator.junit.ParcelableFailure
import androidx.test.orchestrator.junit.ParcelableResult
import androidx.test.orchestrator.listeners.OrchestrationListenerManager.KEY_TEST_EVENT
import androidx.test.orchestrator.listeners.OrchestrationListenerManager.TestEvent.TEST_FAILURE
import androidx.test.orchestrator.listeners.OrchestrationListenerManager.TestEvent.TEST_FINISHED
import androidx.test.orchestrator.listeners.OrchestrationListenerManager.TestEvent.TEST_RUN_FINISHED
import org.junit.runner.Description

internal class OrchestratorTestResultPublisher(listener: OrchestratedInstrumentationListener) :
TestResultPublisher {

private var sendTestFinished: (() -> Unit)? = null

private var failureBundle: Bundle? = null

private var receivedTestFinished: Boolean = false

init {
val failures = mutableListOf<ParcelableFailure>()
listener.delegateSendTestNotification { testEventBundle, sendTestNotification ->

when (testEventBundle.getString(KEY_TEST_EVENT)) {
TEST_FINISHED.toString() -> {
sendTestFinished = {
failureBundle?.let { failureBundle ->
failures += failureBundle.get("failure") as ParcelableFailure
sendTestNotification(failureBundle)
}
sendTestNotification(testEventBundle)
// reset for next test if any.
sendTestFinished = null
failureBundle = null
receivedTestFinished = false
}
if (receivedTestFinished) {
sendTestFinished!!.invoke()
}
}
TEST_RUN_FINISHED.toString() -> {
if (failures.isNotEmpty()) {
val result = testEventBundle.get("result") as ParcelableResult
result.failures += failures
}
sendTestNotification(testEventBundle)
}
else -> sendTestNotification(testEventBundle)
}
}
}

override fun publishTestFinished() {
receivedTestFinished = true
sendTestFinished?.invoke()
}

override fun publishTestFailure(
description: Description,
trace: String
) {
val result = Bundle()
val failure = ParcelableFailure(
ParcelableDescription(description),
RuntimeException(trace)
)
result.putParcelable("failure", failure)
result.putString(KEY_TEST_EVENT, TEST_FAILURE.toString())
this.failureBundle = result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package leakcanary.internal

import androidx.test.orchestrator.instrumentationlistener.OrchestratedInstrumentationListener
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.AndroidJUnitRunner
import org.junit.runner.Description
import shark.SharkLog

internal interface TestResultPublisher {

fun publishTestFinished()

fun publishTestFailure(
description: Description,
trace: String
)

companion object {
fun install(): TestResultPublisher {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val orchestratorListener = if (instrumentation is AndroidJUnitRunner) {
AndroidJUnitRunner::class.java.getDeclaredField("orchestratorListener")
.run {
isAccessible = true
get(instrumentation) as OrchestratedInstrumentationListener?
}
} else null
return if (orchestratorListener != null) {
SharkLog.d { "Android Test Orchestrator detected, failures will be sent via binder callback" }
OrchestratorTestResultPublisher(orchestratorListener)
} else {
SharkLog.d { "Failures will be sent via Instrumentation.sendStatus()" }
InstrumentationTestResultPublisher()
}
}
}
}
11 changes: 9 additions & 2 deletions leakcanary-android-sample/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies {
androidTestImplementation deps.androidx.test.espresso
androidTestImplementation deps.androidx.test.rules
androidTestImplementation deps.androidx.test.runner
androidTestUtil deps.androidx.test.orchestrator
}

android {
Expand All @@ -44,8 +45,14 @@ android {
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArgument "listener",
"leakcanary.FailTestOnLeakRunListener"
testInstrumentationRunnerArgument "listener", "leakcanary.FailTestOnLeakRunListener"

// Run ./gradlew leakcanary-android-sample:connectedCheck -Porchestrator
if (project.hasProperty('orchestrator')) {
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
}
}
}

buildTypes {
Expand Down

0 comments on commit d2492f8

Please sign in to comment.