From e750748404c25a3b1128f6bfeaa65e2bc5550f81 Mon Sep 17 00:00:00 2001 From: Jason Monk Date: Thu, 2 Feb 2017 13:00:05 -0500 Subject: [PATCH] Add better method test handlers/loopers in sysui Test: runtest systemui Change-Id: I94014afab12d385f38a9d1d4a8595bb37cfe0688 --- .../com/android/systemui/DependencyTest.java | 7 +- .../src/com/android/systemui/SysUIRunner.java | 87 ++++++ .../systemui/utils/TestableLooper.java | 260 ++++++++++++++++++ .../systemui/utils/TestableLooperTest.java | 184 +++++++++++++ 4 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 packages/SystemUI/tests/src/com/android/systemui/SysUIRunner.java create mode 100644 packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooper.java create mode 100644 packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooperTest.java diff --git a/packages/SystemUI/tests/src/com/android/systemui/DependencyTest.java b/packages/SystemUI/tests/src/com/android/systemui/DependencyTest.java index fb4b6bdf3f1b8..7d1e8b0d422cf 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/DependencyTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/DependencyTest.java @@ -22,8 +22,13 @@ import static org.mockito.Mockito.verify; import android.os.Looper; +import com.android.systemui.ConfigurationChangedReceiver; +import com.android.systemui.Dependency; +import com.android.systemui.Dumpable; +import com.android.systemui.SysuiTestCase; import com.android.systemui.statusbar.policy.FlashlightController; +import org.junit.Assert; import org.junit.Test; import java.io.PrintWriter; @@ -34,7 +39,7 @@ public class DependencyTest extends SysuiTestCase { public void testClassDependency() { FlashlightController f = mock(FlashlightController.class); injectTestDependency(FlashlightController.class, f); - assertEquals(f, Dependency.get(FlashlightController.class)); + Assert.assertEquals(f, Dependency.get(FlashlightController.class)); } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/SysUIRunner.java b/packages/SystemUI/tests/src/com/android/systemui/SysUIRunner.java new file mode 100644 index 0000000000000..1607b7086c615 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/SysUIRunner.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2017 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. + */ + +package com.android.systemui; + +import android.support.test.internal.runner.junit4.statement.RunAfters; +import android.support.test.internal.runner.junit4.statement.RunBefores; +import android.support.test.internal.runner.junit4.statement.UiThreadStatement; + +import com.android.systemui.utils.TestableLooper.LooperStatement; +import com.android.systemui.utils.TestableLooper.RunWithLooper; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.internal.runners.statements.FailOnTimeout; +import org.junit.runners.BlockJUnit4ClassRunner; +import org.junit.runners.model.FrameworkMethod; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.Statement; + +import java.util.List; + +public class SysUIRunner extends BlockJUnit4ClassRunner { + + private final long mTimeout; + private final Class mKlass; + + public SysUIRunner(Class klass) throws InitializationError { + super(klass); + mKlass = klass; + // Can't seem to get reference to timeout parameter from here, so set default to 10 mins. + mTimeout = 10 * 60 * 1000; + } + + @Override + protected Statement methodInvoker(FrameworkMethod method, Object test) { + return UiThreadStatement.shouldRunOnUiThread(method) ? new UiThreadStatement( + methodInvokerInt(method, test), true) : methodInvokerInt(method, test); + } + + protected Statement methodInvokerInt(FrameworkMethod method, Object test) { + RunWithLooper annotation = method.getAnnotation(RunWithLooper.class); + if (annotation == null) annotation = mKlass.getAnnotation(RunWithLooper.class); + if (annotation != null) { + return new LooperStatement(super.methodInvoker(method, test), + annotation.setAsMainLooper(), test); + } + return super.methodInvoker(method, test); + } + + protected Statement withBefores(FrameworkMethod method, Object target, Statement statement) { + List befores = this.getTestClass().getAnnotatedMethods(Before.class); + return befores.isEmpty() ? statement : new RunBefores(method, statement, + befores, target); + } + + protected Statement withAfters(FrameworkMethod method, Object target, Statement statement) { + List afters = this.getTestClass().getAnnotatedMethods(After.class); + return afters.isEmpty() ? statement : new RunAfters(method, statement, afters, + target); + } + + protected Statement withPotentialTimeout(FrameworkMethod method, Object test, Statement next) { + long timeout = this.getTimeout(method.getAnnotation(Test.class)); + if (timeout <= 0L && mTimeout > 0L) { + timeout = mTimeout; + } + + return timeout <= 0L ? next : new FailOnTimeout(next, timeout); + } + + private long getTimeout(Test annotation) { + return annotation == null ? 0L : annotation.timeout(); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooper.java b/packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooper.java new file mode 100644 index 0000000000000..d27597331aa20 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooper.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2017 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. + */ + +package com.android.systemui.utils; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.util.ArrayMap; + +import org.junit.runners.model.Statement; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; + +/** + * Creates a looper on the current thread with control over if/when messages are + * executed. Warning: This class works through some reflection and may break/need + * to be updated from time to time. + */ +public class TestableLooper { + + private final Method mNext; + private final Method mRecycleUnchecked; + + private Looper mLooper; + private MessageQueue mQueue; + private boolean mMain; + private Object mOriginalMain; + private MessageHandler mMessageHandler; + + private int mParsedCount; + private Handler mHandler; + private Message mEmptyMessage; + + public TestableLooper() throws Exception { + this(true); + } + + public TestableLooper(boolean setMyLooper) throws Exception { + setupQueue(setMyLooper); + mNext = mQueue.getClass().getDeclaredMethod("next"); + mNext.setAccessible(true); + mRecycleUnchecked = Message.class.getDeclaredMethod("recycleUnchecked"); + mRecycleUnchecked.setAccessible(true); + } + + public Looper getLooper() { + return mLooper; + } + + private void clearLooper() throws NoSuchFieldException, IllegalAccessException { + Field field = Looper.class.getDeclaredField("sThreadLocal"); + field.setAccessible(true); + ThreadLocal sThreadLocal = (ThreadLocal) field.get(null); + sThreadLocal.set(null); + } + + private boolean setForCurrentThread() throws NoSuchFieldException, IllegalAccessException { + if (Looper.myLooper() != mLooper) { + Field field = Looper.class.getDeclaredField("sThreadLocal"); + field.setAccessible(true); + ThreadLocal sThreadLocal = (ThreadLocal) field.get(null); + sThreadLocal.set(mLooper); + return true; + } + return false; + } + + private void setupQueue(boolean setMyLooper) throws Exception { + if (setMyLooper) { + clearLooper(); + Looper.prepare(); + mLooper = Looper.myLooper(); + } else { + Constructor constructor = Looper.class.getDeclaredConstructor( + boolean.class); + constructor.setAccessible(true); + mLooper = constructor.newInstance(true); + } + + mQueue = mLooper.getQueue(); + mHandler = new Handler(mLooper); + } + + public void setAsMainLooper() throws NoSuchFieldException, IllegalAccessException { + mMain = true; + setAsMainInt(); + } + + private void setAsMainInt() throws NoSuchFieldException, IllegalAccessException { + Field field = mLooper.getClass().getDeclaredField("sMainLooper"); + field.setAccessible(true); + if (mOriginalMain == null) { + mOriginalMain = field.get(null); + } + field.set(null, mLooper); + } + + /** + * Must be called if setAsMainLooper is called to restore the main looper when the + * test is complete, otherwise the main looper will not be available for any subsequent + * tests. + */ + public void destroy() throws NoSuchFieldException, IllegalAccessException { + if (Looper.myLooper() == mLooper) { + clearLooper(); + } + if (mMain && mOriginalMain != null) { + Field field = mLooper.getClass().getDeclaredField("sMainLooper"); + field.setAccessible(true); + field.set(null, mOriginalMain); + mOriginalMain = null; + } + } + + public void setMessageHandler(MessageHandler handler) { + mMessageHandler = handler; + } + + /** + * Parse num messages from the message queue. + * + * @param num Number of messages to parse + */ + public int processMessages(int num) { + for (int i = 0; i < num; i++) { + if (!parseMessageInt()) { + return i + 1; + } + } + return num; + } + + public void processAllMessages() { + while (processQueuedMessages() != 0) ; + } + + private int processQueuedMessages() { + int count = 0; + mEmptyMessage = mHandler.obtainMessage(1); + mHandler.sendMessageDelayed(mEmptyMessage, 1); + while (parseMessageInt()) count++; + return count; + } + + private boolean parseMessageInt() { + try { + Message result = (Message) mNext.invoke(mQueue); + if (result != null) { + // This is a break message. + if (result == mEmptyMessage) { + mRecycleUnchecked.invoke(result); + return false; + } + + if (mMessageHandler != null) { + if (mMessageHandler.onMessageHandled(result)) { + result.getTarget().dispatchMessage(result); + mRecycleUnchecked.invoke(result); + } else { + mRecycleUnchecked.invoke(result); + // Message handler indicated it doesn't want us to continue. + return false; + } + } else { + result.getTarget().dispatchMessage(result); + mRecycleUnchecked.invoke(result); + } + } else { + // No messages, don't continue parsing + return false; + } + } catch (Exception e) { + throw new RuntimeException(e); + } + return true; + } + + /** + * Runs an executable with myLooper set and processes all messages added. + */ + public void runWithLooper(RunnableWithException runnable) throws Exception { + boolean set = setForCurrentThread(); + runnable.run(); + processAllMessages(); + if (set) clearLooper(); + } + + public interface RunnableWithException { + void run() throws Exception; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.METHOD, ElementType.TYPE}) + public @interface RunWithLooper { + boolean setAsMainLooper() default false; + } + + private static final Map sLoopers = new ArrayMap<>(); + + public static TestableLooper get(Object test) { + return sLoopers.get(test); + } + + public static class LooperStatement extends Statement { + private final boolean mSetAsMain; + private final Statement mBase; + private final TestableLooper mLooper; + + public LooperStatement(Statement base, boolean setAsMain, Object test) { + mBase = base; + try { + mLooper = new TestableLooper(false); + sLoopers.put(test, mLooper); + mSetAsMain = setAsMain; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public void evaluate() throws Throwable { + mLooper.setForCurrentThread(); + if (mSetAsMain) { + mLooper.setAsMainLooper(); + } + + mBase.evaluate(); + + mLooper.destroy(); + } + } + + public interface MessageHandler { + /** + * Return true to have the message executed and delivered to target. + * Return false to not execute the message and stop executing messages. + */ + boolean onMessageHandled(Message m); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooperTest.java b/packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooperTest.java new file mode 100644 index 0000000000000..2416e1d8438fe --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/utils/TestableLooperTest.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2017 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. + */ + +package com.android.systemui.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import com.android.systemui.SysUIRunner; +import com.android.systemui.SysuiTestCase; +import com.android.systemui.utils.TestableLooper.MessageHandler; +import com.android.systemui.utils.TestableLooper.RunWithLooper; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(SysUIRunner.class) +@RunWithLooper +public class TestableLooperTest extends SysuiTestCase { + + private TestableLooper mTestableLooper; + + @Before + public void setup() throws Exception { + mTestableLooper = TestableLooper.get(this); + } + + @After + public void tearDown() throws Exception { + mTestableLooper.destroy(); + } + + @Test + public void testMessageExecuted() throws Exception { + Handler h = new Handler(); + Runnable r = mock(Runnable.class); + h.post(r); + verify(r, never()).run(); + mTestableLooper.processAllMessages(); + verify(r).run(); + } + + @Test + public void testMessageCallback() throws Exception { + Handler h = new Handler(); + Message m = h.obtainMessage(3); + Runnable r = mock(Runnable.class); + MessageHandler messageHandler = mock(MessageHandler.class); + when(messageHandler.onMessageHandled(any())).thenReturn(false); + mTestableLooper.setMessageHandler(messageHandler); + + m.sendToTarget(); + h.post(r); + + mTestableLooper.processAllMessages(); + + verify(messageHandler).onMessageHandled(eq(m)); + // This should never be run becaus the mock returns false on the first message, and + // the second will get skipped. + verify(r, never()).run(); + } + + @Test + public void testProcessNumberOfMessages() throws Exception { + Handler h = new Handler(); + Runnable r = mock(Runnable.class); + h.post(r); + h.post(r); + h.post(r); + + mTestableLooper.processMessages(2); + + verify(r, times(2)).run(); + } + + @Test + public void testProcessAllMessages() throws Exception { + Handler h = new Handler(); + Runnable r = mock(Runnable.class); + Runnable poster = () -> h.post(r); + h.post(poster); + + mTestableLooper.processAllMessages(); + verify(r).run(); + } + + @Test + public void test3Chain() throws Exception { + Handler h = new Handler(); + Runnable r = mock(Runnable.class); + Runnable poster = () -> h.post(r); + Runnable poster2 = () -> h.post(poster); + h.post(poster2); + + mTestableLooper.processAllMessages(); + verify(r).run(); + } + + @Test + public void testProcessAllMessages_2Messages() throws Exception { + Handler h = new Handler(); + Runnable r = mock(Runnable.class); + Runnable r2 = mock(Runnable.class); + h.post(r); + h.post(r2); + + mTestableLooper.processAllMessages(); + verify(r).run(); + verify(r2).run(); + } + + @Test + public void testMainLooper() throws Exception { + assertNotEquals(Looper.myLooper(), Looper.getMainLooper()); + + Looper originalMain = Looper.getMainLooper(); + mTestableLooper.setAsMainLooper(); + assertEquals(Looper.myLooper(), Looper.getMainLooper()); + Runnable r = mock(Runnable.class); + + new Handler(Looper.getMainLooper()).post(r); + mTestableLooper.processAllMessages(); + + verify(r).run(); + mTestableLooper.destroy(); + + assertEquals(originalMain, Looper.getMainLooper()); + } + + @Test + public void testNotMyLooper() throws Exception { + TestableLooper looper = new TestableLooper(false); + + assertEquals(Looper.myLooper(), mTestableLooper.getLooper()); + assertNotEquals(Looper.myLooper(), looper.getLooper()); + + Runnable r = mock(Runnable.class); + Runnable r2 = mock(Runnable.class); + new Handler().post(r); + new Handler(looper.getLooper()).post(r2); + + looper.processAllMessages(); + verify(r2).run(); + verify(r, never()).run(); + + mTestableLooper.processAllMessages(); + verify(r).run(); + } + + @Test + public void testNonMainLooperAnnotation() { + assertNotEquals(Looper.myLooper(), Looper.getMainLooper()); + } + + @Test + @RunWithLooper(setAsMainLooper = true) + public void testMainLooperAnnotation() { + assertEquals(Looper.myLooper(), Looper.getMainLooper()); + } +}