Monday, May 24, 2010

Writing unit tests when using GWT's Static String Internationalization (I18n) feature

The Google Web Toolkit (GWT) has a fairly simple infrastructure for managing internationalization. While there are a number of different options, the easiest one to use is called Static String Internationalization The basic idea is that you create a properties file for each language and GWT's deferred binding process creates an instance of a Interface which will be loaded depending on the locale. It works something like this:

  1. Create a properties file (e.g. FooConstants.properties). This will contain definitions of the form bar=A string that I want to i18n.
  2. Use the i18nCreator script to generate the Interface definition: i18nCreator -eclipse Foo com.example.foo.client.FooConstants
  3. In your code call GWT.create() to instantiate an instance. e.g:
    public static class MessageDisplayer {
        public MessageDisplayer(Alerter alerter) {
            FooConstants constants = (FooConstants) GWT.create(FooConstants.class);
                alerter.alert(FooConstants.bar());
            }
        }
    }
    
The problem with this is that calling GWT.create from within your code makes it difficult to unit test. If your code calls this directly then you will have to create your unit tests using GWT's Junit3 hack. Running unit tests this way is very slow, and I find it much better to try and factor out as much GWT specific code as possible so that you can write normal boring tests (for example using JUnit 4, or using mocks, or whatever else that GWT tests don't support that takes your fancy). The trick is that if you have any code that relies on an instance of these Constants/Messages files then you are screwed. The solution is to use a DynamicProxy to generate an instance of the interface and then inject these into your code. First we need to refactor our class to have the Constants interface injected, e.g:
public static class MessageDisplayer {
    public MessageDisplayer(Alerter alerter, FooConstants constants) {
        alerter.alert(FooConstants.bar());
    }
}
Next we need to write some code to generate the constants. When the i18nCreator generates the interface it helpfully annotates it with the default text it needs. We can exploit this to generate an instance for testing:
public class ConstantsMocker implements InvocationHandler {
     @SuppressWarnings("unchecked")
     public static <T> T get(Class<? extends T> i18nInterface) {
         return (T) Proxy.newProxyInstance(i18nInterface.getClassLoader(),
                  new Class<?>[] { i18nInterface },
                  new ConstantsMocker());
     }
     public static final String NO_DEFAULT_MESSAGE = "[No Default Message Defined]";

     @Override
     public Object invoke(Object proxy, Method method, Object[] args)
             throws Throwable {
         DefaultMessage message = method.getAnnotation(DefaultMessage.class);
         if (message == null) {
             return NO_DEFAULT_MESSAGE;
         }
         return message.value();
     }
} 
Now when we write our test we can pass down an instance, e.g
@Test
    public void whenConstructorIsCalledAlerterAlertIsCalled {
        AtomicBoolean wasAlerted = new AtomicBoolean(false);
        FooConstants fooConstants = ConstantsMocker.get(FooConstants.class);
        final String expected = fooConstants.bar();
        Alerter alerter = new Alerter() {
            void alert(String msg) {
                assertEquals(expected, msg);
                wasAlerted.set(true);
            }
        }
        new MessageDisplayer(alerter, fooConstants);
        assertTrue(wasAlerted.get());
    }
Whilst this is a toy example it can be a very useful technique if used carefully. Note that regular GWT code just injects the instance from GWT.create() as follows:
MessageDisplayer displayer = 
      new MessageDisplayer(Alerter, (FooConstants)GWT.create(FooConstants.java));

2 comments:

Benjamin Peter said...

Thanks for the help, it works! :)

Regards,

Benjamin Peter

Howard Yeh said...

Thanks! This solved a problem with Mockito not able to mock interface with too many methods.