com.scvngr.levelup.core.test.TestThreadingUtils.java Source code

Java tutorial

Introduction

Here is the source code for com.scvngr.levelup.core.test.TestThreadingUtils.java

Source

/*
 * Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp
 *
 * 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.
 */
package com.scvngr.levelup.core.test;

import android.app.Activity;
import android.app.Instrumentation;
import android.os.Looper;
import android.os.SystemClock;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.app.Fragment;
import android.support.v4.app.Fragment.SavedState;
import android.support.v4.app.FragmentActivity;
import android.support.v4.app.FragmentManager;
import android.test.AndroidTestCase;
import android.view.View;

import com.scvngr.levelup.core.util.NullUtils;

import java.util.Locale;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

/**
 * Methods to help with some of the intricacies of threading in the test cases.
 */
public final class TestThreadingUtils {

    /**
     * Sentinel value to pass to
     * {@link #validateFragmentAdded(Instrumentation, Activity, FragmentManager, String, int)} if no
     * parent id should be validated.
     */
    public static final int PARENT_ID_UNDEFINED = -1;

    private static final long WAIT_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(4L);

    private static final long WAIT_SLEEP_MILLIS = 20L;

    /**
     * Adds a fragment in a transaction synchronized in the main thread (tagged with the fragment's
     * class name).
     *
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the {@link FragmentActivity} to add it to.
     * @param fragment Fragment to add.
     * @param inView adds the fragment to the view hierarchy if true.
     */
    public static void addFragmentInMainSync(@NonNull final Instrumentation instrumentation,
            @NonNull final FragmentActivity activity, @NonNull final Fragment fragment, final boolean inView) {
        addFragmentInMainSync(instrumentation, activity, fragment, inView,
                NullUtils.nonNullContract(fragment.getClass().getName()));
    }

    /**
     * Adds a fragment in a transaction synchronized in the main thread (tagged with the fragment's
     * class name).
     *
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the {@link FragmentActivity} to add it to.
     * @param fragment Fragment to add.
     * @param inView adds the fragment to the view hierarchy if true.
     * @param tag the Fragment's tag (null tag will fail fast).
     */
    public static void addFragmentInMainSync(@NonNull final Instrumentation instrumentation,
            @NonNull final FragmentActivity activity, @NonNull final Fragment fragment, final boolean inView,
            final String tag) {
        if (null == tag) {
            throw new AssertionError("Cannot add fragment with null tag");
        }

        runOnMainSync(instrumentation, activity, new Runnable() {
            @Override
            public void run() {
                if (!inView) {
                    activity.getSupportFragmentManager().beginTransaction().add(fragment, tag).commit();
                } else {
                    activity.getSupportFragmentManager().beginTransaction()
                            .add(R.id.levelup_activity_content, fragment, tag).commit();
                }

                activity.getSupportFragmentManager().executePendingTransactions();
            }
        });
    }

    /**
     * Saves a {@link Fragment} to instance state, removes it from the activity. Then creates a new
     * one of the same class, restores the instance state, and re-adds it to the activity.
     *
     * @param <T> the type of Fragment
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the {@link FragmentActivity} to add it to.
     * @param fragment Fragment to remove and re-add.
     * @return the new instance of the input fragment created using the saved/restored state.
     */
    @NonNull
    public static <T extends Fragment> T saveAndRestoreFragmentStateSync(
            @NonNull final Instrumentation instrumentation, @NonNull final FragmentActivity activity,
            @NonNull final T fragment) {
        final boolean inView = fragment.isInLayout();

        final FragmentManager fm = activity.getSupportFragmentManager();
        final SavedState savedState = fm.saveFragmentInstanceState(fragment);

        @SuppressWarnings("unchecked")
        final T newInstance = (T) Fragment.instantiate(activity, fragment.getClass().getName());
        newInstance.setInitialSavedState(savedState);

        runOnMainSync(instrumentation, activity, new Runnable() {
            @Override
            public void run() {
                fm.beginTransaction().remove(fragment).commit();
            }
        });

        addFragmentInMainSync(instrumentation, activity, newInstance, inView);

        return newInstance;
    }

    /**
     * Validates that the Activity becomes finished.
     *
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity to check.
     */
    public static void validateActivityFinished(@NonNull final Instrumentation instrumentation,
            @NonNull final Activity activity) {
        final LatchRunnable latchRunnable = new LatchRunnable() {
            @Override
            public void run() {
                if (activity.isFinishing()) {
                    countDown();
                }
            }
        };

        AndroidTestCase.assertTrue(waitForAction(instrumentation, activity, latchRunnable, true));
    }

    /**
     * Validates that a fragment was added successfully. Does not validate the container it was
     * added to.
     *
     * @param <V> the type of Fragment.
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run (null will fail validation).
     * @param fragmentManager the fragment manager the fragment was added to (null will fail
     *        validation).
     * @param tag the tag to check for (null will fail validation).
     * @return the Fragment that is added.
     */
    @NonNull
    public static <V extends Fragment> V validateFragmentAdded(@NonNull final Instrumentation instrumentation,
            final Activity activity, final FragmentManager fragmentManager, final String tag) {
        return validateFragmentAdded(instrumentation, activity, fragmentManager, tag, PARENT_ID_UNDEFINED);
    }

    /**
     * Validates that a fragment was added successfully and validates the ID of the container it was
     * added to.
     *
     * @param <V> the type of Fragment.
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run (null will fail validation).
     * @param fragmentManager the fragment manager the fragment was added to (null will fail
     *        validation).
     * @param tag the tag to check for (null will fail validation).
     * @param parentId the id of the parent container the fragment is expected to be in or pass
     *        {@link #PARENT_ID_UNDEFINED} if no parent id should be validated.
     * @return the Fragment that is added.
     */
    @NonNull
    @SuppressWarnings("unchecked")
    public static <V extends Fragment> V validateFragmentAdded(@NonNull final Instrumentation instrumentation,
            final Activity activity, final FragmentManager fragmentManager, final String tag, final int parentId) {
        AndroidTestCase.assertNotNull(tag);
        AndroidTestCase.assertNotNull(activity);
        AndroidTestCase.assertNotNull(fragmentManager);

        final AtomicReference<Fragment> reference = new AtomicReference<Fragment>();
        final LatchRunnable latchRunnable = new LatchRunnable() {
            @Override
            public void run() {
                final Fragment fragment = fragmentManager.findFragmentByTag(tag);

                if (null != fragment) {
                    if (fragment.isAdded()) {
                        if (PARENT_ID_UNDEFINED != parentId) {
                            final View parent = (View) fragment.getView().getParent();
                            AndroidTestCase.assertEquals("In the proper container", parentId, parent.getId());
                        }

                        reference.set(fragment);
                        countDown();
                    }
                }
            }
        };

        AndroidTestCase.assertTrue(String.format(Locale.US, "%s added", tag),
                waitForAction(instrumentation, NullUtils.nonNullContract(activity), latchRunnable, true));

        return (V) reference.get();
    }

    /**
     * Validates that a fragment was removed.
     *
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run (null will fail validation).
     * @param fragmentManager the fragment manager the fragment was removed from (null will fail
     *        validation).
     * @param tag The tag of the fragment (null will fail validation).
     */
    public static void validateFragmentRemoved(@NonNull final Instrumentation instrumentation,
            final Activity activity, final FragmentManager fragmentManager, final String tag) {
        AndroidTestCase.assertNotNull(tag);
        AndroidTestCase.assertNotNull(activity);
        AndroidTestCase.assertNotNull(fragmentManager);

        final LatchRunnable latchRunnable = new LatchRunnable() {
            @Override
            public void run() {
                final Fragment fragment = fragmentManager.findFragmentByTag(tag);
                if (null == fragment) {
                    countDown();
                }
            }
        };

        AndroidTestCase.assertTrue(String.format(Locale.US, "%s removed", tag),
                waitForAction(instrumentation, NullUtils.nonNullContract(activity), latchRunnable, true));
    }

    /**
     * Validates that a fragment is visible to the user.
     *
     * @param <V> the type of Fragment.
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run (null will fail validation).
     * @param fragmentManager the fragment manager the fragment was added to (null will fail
     *        validation).
     * @param tag the tag to check for (null will fail validation).
     * @return the Fragment that is visible to the user.
     */
    @NonNull
    @SuppressWarnings("unchecked")
    public static <V extends Fragment> V validateFragmentIsVisible(@NonNull final Instrumentation instrumentation,
            final Activity activity, final FragmentManager fragmentManager, final String tag) {
        AndroidTestCase.assertNotNull(tag);
        AndroidTestCase.assertNotNull(activity);
        AndroidTestCase.assertNotNull(fragmentManager);

        final AtomicReference<Fragment> reference = new AtomicReference<Fragment>();
        final LatchRunnable latchRunnable = new LatchRunnable() {
            @Override
            public void run() {
                final Fragment fragment = fragmentManager.findFragmentByTag(tag);

                if (null != fragment) {
                    if (fragment.isVisible()) {
                        reference.set(fragment);
                        countDown();
                    }
                }
            }
        };

        AndroidTestCase.assertTrue(String.format(Locale.US, "%s is visible", tag),
                waitForAction(instrumentation, activity, latchRunnable, true));

        return (V) reference.get();
    }

    /**
     * Validates that a fragment is either not managed by the fragment manager or is not visible to
     * the user.
     *
     * @param <V> the type of Fragment.
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run (null will fail validation).
     * @param fragmentManager the fragment manager the fragment was added to (null will fail
     *        validation).
     * @param tag the tag to check for (null will fail validation).
     * @return the Fragment that is not visible to the user.
     */
    @NonNull
    @SuppressWarnings("unchecked")
    public static <V extends Fragment> V validateFragmentIsNotVisible(
            @NonNull final Instrumentation instrumentation, final Activity activity,
            final FragmentManager fragmentManager, final String tag) {
        AndroidTestCase.assertNotNull(tag);
        AndroidTestCase.assertNotNull(activity);
        AndroidTestCase.assertNotNull(fragmentManager);

        final AtomicReference<Fragment> reference = new AtomicReference<Fragment>();
        final LatchRunnable latchRunnable = new LatchRunnable() {
            @Override
            public void run() {
                final Fragment fragment = fragmentManager.findFragmentByTag(tag);

                if (null == fragment || !fragment.isVisible()) {
                    reference.set(fragment);
                    countDown();
                }
            }
        };

        AndroidTestCase.assertTrue(String.format(Locale.US, "%s is not visible", tag),
                waitForAction(instrumentation, activity, latchRunnable, true));

        return (V) reference.get();
    }

    /**
     * Helper method to wait for an action to occur.
     *
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run.
     * @param latchRunnable the runnable that will check the condition and signal success via its
     * {@link java.util.concurrent.CountDownLatch}.
     * @param isMainThreadRunnable Determine whether or not the runnable must be invoked on the main
     * thread.
     * @return true if the action happened before the timeout, false otherwise.
     */
    public static boolean waitForAction(@NonNull final Instrumentation instrumentation,
            @NonNull final Activity activity, @NonNull final LatchRunnable latchRunnable,
            final boolean isMainThreadRunnable) {
        return waitForAction(instrumentation, activity, latchRunnable, WAIT_TIMEOUT_MILLIS, isMainThreadRunnable);
    }

    /**
     * Helper method to wait for an action to occur.
     *
     * @param instrumentation the test {@link Instrumentation}.
     * @param activity the activity for the test being run.
     * @param latchRunnable the runnable that will check the condition and signal success via its
     * {@link java.util.concurrent.CountDownLatch}.
     * @param timeoutMillis the timeout duration in milliseconds.
     * @param isMainThreadRunnable Determine whether or not the runnable must be invoked on the main
     * thread.
     * @return true if the action happened before the timeout, false otherwise.
     */
    public static boolean waitForAction(@NonNull final Instrumentation instrumentation,
            @NonNull final Activity activity, @NonNull final LatchRunnable latchRunnable, final long timeoutMillis,
            final boolean isMainThreadRunnable) {
        final long endTime = SystemClock.elapsedRealtime() + timeoutMillis;
        boolean result = true;

        while (true) {
            if (isMainThreadRunnable) {
                runOnMainSync(instrumentation, activity, latchRunnable);
            } else {
                latchRunnable.run();
                instrumentation.waitForIdleSync();
            }

            if (latchRunnable.getCount() == 0) {
                break;
            }

            if (SystemClock.elapsedRealtime() >= endTime) {
                result = false;
                break;
            }

            SystemClock.sleep(WAIT_SLEEP_MILLIS);
        }

        return result;
    }

    /**
     * Runs a runnable on the main thread, but also catches any errors thrown on the main thread and
     * re-throws them on the test thread so they can be displayed more easily.
     *
     * @param instrumentation the {@link Instrumentation} for the test.
     * @param activity the {@link Activity} that this this test is running in.
     * @param runnable the runnable to run.
     */
    public static void runOnMainSync(@NonNull final Instrumentation instrumentation,
            @NonNull final Activity activity, @NonNull final Runnable runnable) {
        if (activity.getMainLooper().equals(Looper.myLooper())) {
            runnable.run();
        } else {
            final FutureAssertionError futureError = new FutureAssertionError();
            instrumentation.runOnMainSync(new Runnable() {

                @Override
                public void run() {
                    try {
                        runnable.run();
                    } catch (final AssertionError e) {
                        futureError.setAssertionError(e);
                    }
                }
            });
            futureError.throwPendingAssertionError();

            instrumentation.waitForIdleSync();
        }
    }

    /**
     * This class is used to capture {@link AssertionError}s thrown inside anonymous
     * {@link Runnable}s so they can be re-thrown on the {@link Instrumentation} test thread.
     */
    /* package */ static class FutureAssertionError {

        /**
         * The {@link AssertionError} to be thrown by {@link #throwPendingAssertionError()}.
         */
        @Nullable
        private AssertionError mError;

        /**
         * Set the {@link AssertionError} to be thrown by {@link #throwPendingAssertionError()}.
         *
         * @param error The {@link AssertionError} to be thrown by
         *        {@link #throwPendingAssertionError()}.
         */
        /* package */ void setAssertionError(@NonNull final AssertionError error) {
            mError = error;
        }

        /**
         * If an {@link AssertionError} was set via {@link #setAssertionError(AssertionError)} this
         * method will throw that {@link AssertionError}, otherwise it does nothing.
         */
        /* package */ void throwPendingAssertionError() {
            if (null != mError) {
                throw mError;
            }
        }
    }

    /**
     * Private constructor prevents instantiation.
     */
    private TestThreadingUtils() {
        throw new UnsupportedOperationException("This class is non-instantiable");
    }
}