diff --git a/README.md b/README.md index ccf3ab9..4e1b20c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,32 @@ The options to enable some features on testing are same as [adb instrument](http - coverage - coverageFile -If you need to run tests from Android Studio, please use [Android Tests](https://www.jetbrains.com/help/idea/2016.1/run-debug-configuration-android-test.html) Configuration. +If you need to run tests from Android Studio, please use [Android Tests](https://www.jetbrains.com/help/idea/2016.1/run-debug-configuration-android-test.html) Configuration. Running the following Gradle tasks from Android Studio will typically work with these tests, where YourBuildName is your build name in camel-case: + + - :build + - :installYourBuildNameAndroidTest + - :connectedYourBuildNameAndroidTest + +Compatability with Android UI testing frameworks +------------------------------------------------ + +This framework has been tested to be fully compatable with the UIAutomator framework; just make sure to import the AndroidTestNGSupport class' getInstrumentation() and getContext() methods. It's also compatable with the Espresso framework. Make sure to set up your Espresso tests with methods such as the following (this is just the code I got to work; it may be suboptimal!): + + @Inject + public YourTestNameHere(Instrumentation instrumentation) { + this.mInstrumentation = instrumentation; + } + + @BeforeTest + public void setUp() { + InstrumentationRegistry.registerInstance(mInstrumentation, + new Bundle()); + Intent intent = new Intent(getContext(), YourAppActivityNameHere.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mActivity = YourAppActivityNameHere.class.cast(mInstrumentation.startActivitySync(intent)); + mInstrumentation.waitForIdleSync(); + } + License ------- diff --git a/build.gradle b/build.gradle index f9e9f5c..1b188b9 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { exclude group: 'aopalliance', module: 'aopalliance' } + // Support annotations required for ActivityLifeCycleMonitor + compile('com.android.support.test:runner:0.5') + androidTestCompile('com.google.inject:guice:3.0:no_aop') { exclude group: 'org.sonatype.sisu.inject', module: 'cglib' exclude group: 'aopalliance', module: 'aopalliance' diff --git a/src/main/java/de/lemona/android/testng/ActivityLifecycleMonitorImpl.java b/src/main/java/de/lemona/android/testng/ActivityLifecycleMonitorImpl.java new file mode 100644 index 0000000..e1ac449 --- /dev/null +++ b/src/main/java/de/lemona/android/testng/ActivityLifecycleMonitorImpl.java @@ -0,0 +1,182 @@ +package de.lemona.android.testng; +/* + * This class is appropriated from the Android Open Source Project's JUnitTestRunner + * + * Their license is as follows: + * + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import static android.support.test.internal.util.Checks.checkNotNull; +import android.app.Activity; +import android.os.Looper; +import android.support.test.runner.lifecycle.ActivityLifecycleCallback; +import android.support.test.runner.lifecycle.ActivityLifecycleMonitor; +import android.support.test.runner.lifecycle.Stage; +import android.util.Log; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +public final class ActivityLifecycleMonitorImpl implements ActivityLifecycleMonitor { + + private static final String TAG = "LifecycleMonitor"; + private final boolean mDeclawThreadCheck; + public ActivityLifecycleMonitorImpl() { + this(false); + } + // For Testing + public ActivityLifecycleMonitorImpl(boolean declawThreadCheck) { + this.mDeclawThreadCheck = declawThreadCheck; + } + // Accessed from any thread. + private List> mCallbacks = + new ArrayList>(); + // Only accessed on main thread. + private List mActivityStatuses = new ArrayList(); + @Override + public void addLifecycleCallback(ActivityLifecycleCallback callback) { + // there will never be too many callbacks, so iterating over a list will probably + // be faster then the constant time costs of setting up and maintaining a map. + checkNotNull(callback); + synchronized (mCallbacks) { + boolean needsAdd = true; + Iterator> refIter = mCallbacks.iterator(); + while (refIter.hasNext()) { + ActivityLifecycleCallback storedCallback = refIter.next().get(); + if (null == storedCallback) { + refIter.remove(); + } else if (storedCallback == callback) { + needsAdd = false; + } + } + if (needsAdd) { + mCallbacks.add(new WeakReference(callback)); + } + } + } + @Override + public void removeLifecycleCallback(ActivityLifecycleCallback callback) { + checkNotNull(callback); + synchronized (mCallbacks) { + Iterator> refIter = mCallbacks.iterator(); + while (refIter.hasNext()) { + ActivityLifecycleCallback storedCallback = refIter.next().get(); + if (null == storedCallback) { + refIter.remove(); + } else if (storedCallback == callback) { + refIter.remove(); + } + } + } + } + @Override + public Stage getLifecycleStageOf(Activity activity) { + checkMainThread(); + checkNotNull(activity); + Iterator statusIterator = mActivityStatuses.iterator(); + while (statusIterator.hasNext()) { + ActivityStatus status = statusIterator.next(); + Activity statusActivity = status.mActivityRef.get(); + if (null == statusActivity) { + statusIterator.remove(); + } else if (activity == statusActivity) { + return status.mLifecycleStage; + } + } + throw new IllegalArgumentException("Unknown activity: " + activity); + } + @Override + public Collection getActivitiesInStage(Stage stage) { + checkMainThread(); + checkNotNull(stage); + List activities = new ArrayList(); + Iterator statusIterator = mActivityStatuses.iterator(); + while (statusIterator.hasNext()) { + ActivityStatus status = statusIterator.next(); + Activity statusActivity = status.mActivityRef.get(); + if (null == statusActivity) { + statusIterator.remove(); + } else if (stage == status.mLifecycleStage) { + activities.add(statusActivity); + } + } + return activities; + } + /** + * Called by the runner after a particular onXXX lifecycle method has been called on a given + * activity. + */ + public void signalLifecycleChange(Stage stage, Activity activity) { + // there are never too many activities in existence in an application - so we keep + // track of everything in a single list. + Log.d(TAG, "Lifecycle status change: " + activity + " in: " + stage); + boolean needsAdd = true; + Iterator statusIterator = mActivityStatuses.iterator(); + while (statusIterator.hasNext()) { + ActivityStatus status = statusIterator.next(); + Activity statusActivity = status.mActivityRef.get(); + if (null == statusActivity) { + statusIterator.remove(); + } else if (activity == statusActivity) { + needsAdd = false; + status.mLifecycleStage = stage; + } + } + if (needsAdd) { + mActivityStatuses.add(new ActivityStatus(activity, stage)); + } + synchronized (mCallbacks) { + Iterator> refIter = mCallbacks.iterator(); + while (refIter.hasNext()) { + ActivityLifecycleCallback callback = refIter.next().get(); + if (null == callback) { + refIter.remove(); + } else { + try { + Log.d(TAG, "running callback: " + callback); + callback.onActivityLifecycleChanged(activity, stage); + Log.d(TAG, "callback completes: " + callback); + } catch (RuntimeException re) { + Log.e(TAG, String.format( + "Callback threw exception! (callback: %s activity: %s stage: %s)", + callback, + activity, + stage), + re); + } + } + } + } + } + private void checkMainThread() { + if (mDeclawThreadCheck) { + return; + } + if (!Thread.currentThread().equals(Looper.getMainLooper().getThread())) { + throw new IllegalStateException( + "Querying activity state off main thread is not allowed."); + } + } + private static class ActivityStatus { + private final WeakReference mActivityRef; + private Stage mLifecycleStage; + ActivityStatus(Activity activity, Stage stage) { + this.mActivityRef = new WeakReference(checkNotNull(activity)); + this.mLifecycleStage = checkNotNull(stage); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/lemona/android/testng/TestNGRunner.java b/src/main/java/de/lemona/android/testng/TestNGRunner.java index 47e8cde..30da941 100644 --- a/src/main/java/de/lemona/android/testng/TestNGRunner.java +++ b/src/main/java/de/lemona/android/testng/TestNGRunner.java @@ -1,6 +1,10 @@ package de.lemona.android.testng; import android.app.Instrumentation; +import android.app.Activity; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.lifecycle.ActivityLifecycleMonitorRegistry; +import android.support.test.runner.lifecycle.Stage; import android.os.Bundle; import android.os.Debug; import android.util.Log; @@ -16,6 +20,8 @@ import java.io.InputStream; import java.util.Enumeration; import java.util.List; +import java.util.ArrayList; +import java.util.EnumSet; import dalvik.system.DexFile; @@ -27,11 +33,15 @@ public class TestNGRunner extends Instrumentation { private String targetPackage = null; + private ActivityLifecycleMonitorImpl mLifecycleMonitor = new ActivityLifecycleMonitorImpl(); private TestNGArgs args; @Override public void onCreate(Bundle arguments) { super.onCreate(arguments); + InstrumentationRegistry.registerInstance(this, arguments); + ActivityLifecycleMonitorRegistry.registerInstance(mLifecycleMonitor); + args = parseRunnerArgument(arguments); targetPackage = this.getTargetContext().getPackageName(); this.start(); @@ -138,6 +148,13 @@ public void onStart() { } } + @Override + public void finish(int resultCode, Bundle results) { + finishActivities(); + ActivityLifecycleMonitorRegistry.registerInstance(null); + super.finish(resultCode, results); + } + private void setupDexmakerClassloader() { ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); // must set the context classloader for apps that use a shared uid, see @@ -147,4 +164,70 @@ private void setupDexmakerClassloader() { // newClassLoader.toString(), originalClassLoader.toString())); Thread.currentThread().setContextClassLoader(newClassLoader); } + @Override + public void callActivityOnDestroy(Activity activity) { + super.callActivityOnDestroy(activity); + mLifecycleMonitor.signalLifecycleChange(Stage.DESTROYED, activity); + } + + @Override + public void callActivityOnRestart(Activity activity) { + super.callActivityOnRestart(activity); + mLifecycleMonitor.signalLifecycleChange(Stage.RESTARTED, activity); + } + + @Override + public void callActivityOnCreate(Activity activity, Bundle bundle) { + mLifecycleMonitor.signalLifecycleChange(Stage.PRE_ON_CREATE, activity); + super.callActivityOnCreate(activity, bundle); + mLifecycleMonitor.signalLifecycleChange(Stage.CREATED, activity); + } + + @Override + public void callActivityOnStart(Activity activity) { + try { + super.callActivityOnStart(activity); + mLifecycleMonitor.signalLifecycleChange(Stage.STARTED, activity); + } catch (RuntimeException re) { + throw re; + } + } + + @Override + public void callActivityOnStop(Activity activity) { + super.callActivityOnStop(activity); + mLifecycleMonitor.signalLifecycleChange(Stage.STOPPED, activity); + } + + @Override + public void callActivityOnResume(Activity activity) { + super.callActivityOnResume(activity); + mLifecycleMonitor.signalLifecycleChange(Stage.RESUMED, activity); + } + + @Override + public void callActivityOnPause(Activity activity) { + super.callActivityOnPause(activity); + mLifecycleMonitor.signalLifecycleChange(Stage.PAUSED, activity); + } + + private void finishActivities() { + List activities = new ArrayList(); + + for (Stage s : EnumSet.range(Stage.CREATED, Stage.PAUSED)) { + activities.addAll(mLifecycleMonitor.getActivitiesInStage(s)); + } + + Log.i(TAG, "Activities that are still in CREATED to PAUSED: " + activities.size()); + for (Activity activity : activities) { + if (!activity.isFinishing()) { + try { + Log.i(TAG, "Stopping activity: " + activity); + activity.finish(); + } catch (RuntimeException e) { + Log.e(TAG, "Failed to stop activity.", e); + } + } + } + } }