How to better unit test Looper and Handler code on Android?
I use the android.os.Handler class to perform tasks on the background. When unit testing these, I cal开发者_StackOverflowl Looper.loop()
to make the test thread wait for the background task thread to do its thing. Later, I call Looper.myLooper().quit()
(also in the test thread), to allow the test thread to quit the loop
and resume the testing logic.
It's all fine and dandy until I want to write more than one test method.
The problem is that Looper doesn't seem to be designed to allow quitting and restarting on the same thread, so I am forced to do all of my testing inside a single test method.
I looked into the source code of Looper, and couldn't find a way around it.
Is there any other way to test my Hander/Looper code? Or maybe some more test friendly way to write my background task class?
The source code for Looper reveals that Looper.myLooper().quit() enqueues a null message in the Message queue, which tells Looper that it is done processing messages FOREVER. Essentially, the thread becomes a dead thread at that point, and there is no way to revive it that I know of. You may have seen error messages when attempting to post messages to the Handler after quit() is called to the effect "attempting to send message to dead thread". That is what that means.
This can actually be tested easily if you aren't using AsyncTask
by introducing a second looper thread (other than the main one created for you implicitly by Android). The basic strategy then is to block the main looper thread using a CountDownLatch
while delegating all your callbacks to the second looper thread.
The caveat here is that your code under test must be able to support using a looper other than the default main one. I would argue that this should be the case regardless to support a more robust and flexible design, and it is also fortunately very easy. In general, all that must be done is to modify your code to accept an optional Looper
parameter and use that to construct your Handler
(as new Handler(myLooper)
). For AsyncTask
, this requirement makes it impossible to test it with this approach. A problem that I think should be remedied with AsyncTask
itself.
Some sample code to get you started:
public void testThreadedDesign() {
final CountDownLatch latch = new CountDownLatch(1);
/* Just some class to store your result. */
final TestResult result = new TestResult();
HandlerThread testThread = new HandlerThread("testThreadedDesign thread");
testThread.start();
/* This begins a background task, say, doing some intensive I/O.
* The listener methods are called back when the job completes or
* fails. */
new ThingThatOperatesInTheBackground().doYourWorst(testThread.getLooper(),
new SomeListenerThatTotallyShouldExist() {
public void onComplete() {
result.success = true;
finished();
}
public void onFizzBarError() {
result.success = false;
finished();
}
private void finished() {
latch.countDown();
}
});
latch.await();
testThread.getLooper().quit();
assertTrue(result.success);
}
I've stumbled in the same issue as yours. I also wanted to make a test case for a class that use a Handler
.
Same as what you did, I use the Looper.loop()
to have the test thread starts handling the queued messages in the handler.
To stop it, I used the implementation of MessageQueue.IdleHandler
to notify me when the looper is blocking to wait the next message to come. When it happen, I call the quit()
method. But again, same as you I got a problem when I make more than one test case.
I wonder if you already solved this problem and perhaps care to share it with me (and possibly others) :)
PS: I also would like to know how you call your Looper.myLooper().quit()
.
Thanks!
Inspired by @Josh Guilfoyle's answer, I decided to try to use reflection to get access to what I needed in order to make my own non-blocking and non-quitting Looper.loop()
.
/**
* Using reflection, steal non-visible "message.next"
* @param message
* @return
* @throws Exception
*/
private Message _next(Message message) throws Exception {
Field f = Message.class.getDeclaredField("next");
f.setAccessible(true);
return (Message)f.get(message);
}
/**
* Get and remove next message in local thread-pool. Thread must be associated with a Looper.
* @return next Message, or 'null' if no messages available in queue.
* @throws Exception
*/
private Message _pullNextMessage() throws Exception {
final Field _messages = MessageQueue.class.getDeclaredField("mMessages");
final Method _next = MessageQueue.class.getDeclaredMethod("next");
_messages.setAccessible(true);
_next.setAccessible(true);
final Message root = (Message)_messages.get(Looper.myQueue());
final boolean wouldBlock = (_next(root) == null);
if(wouldBlock)
return null;
else
return (Message)_next.invoke(Looper.myQueue());
}
/**
* Process all pending Messages (Handler.post (...)).
*
* A very simplified version of Looper.loop() except it won't
* block (returns if no messages available).
* @throws Exception
*/
private void _doMessageQueue() throws Exception {
Message msg;
while((msg = _pullNextMessage()) != null) {
msg.getTarget().dispatchMessage(msg);
}
}
Now in my tests (which need to run on the UI thread), I can now do:
@UiThreadTest
public void testCallbacks() throws Throwable {
adapter = new UpnpDeviceArrayAdapter(getInstrumentation().getContext(), upnpService);
assertEquals(0, adapter.getCount());
upnpService.getRegistry().addDevice(createRemoteDevice());
// the adapter posts a Runnable which adds the new device.
// it has to because it must be run on the UI thread. So we
// so we need to process this (and all other) handlers before
// checking up on the adapter again.
_doMessageQueue();
assertEquals(2, adapter.getCount());
// remove device, _doMessageQueue()
}
I'm not saying this is a good idea, but so far it's been working for me. Might be worth trying out! What I like about this is that Exceptions
that are thrown inside some hander.post(...)
will break the tests, which is not the case otherwise.
精彩评论