Unit tests with custom JUnit rules, annotations and resources
Background
Many, if not most Android apps act as API clients thus require conversion between data-interchange format (usually JSON) and POJO (data model classes). There is no need to implement conversion engine in our code since it can done by external libraries like GSON or moshi.
Good, well known libraries usually have unit tests with high coverage so it does not make sense to test them like that:
1 2 3 4 5 6 7 8 9 | @Test public void testGson() { //given Gson gson = new Gson(); //when String result = gson.fromJson("\"test\"", String.class); //then assertThat(result).isEqualTo("test"); } |
On the other hand it may be useful to test parsing (JSON to POJO) and generating (POJO to JSON) logic related to model classes. Consider the following POJO:
1 2 3 4 5 | public class Contributor { public String login; public boolean siteAdmin; public long id; } |
and corresponding JSON:
1 2 3 4 5 | { "login": "koral--", "id": 3340954, "site_admin": true } |
We may want to test if fields are mapped correctly. Note that siteAdmin
field uses different casing styles – camelCase in Java and snake_case in JSON.
Simple solution
One of the simplest unit test methods may look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | @Test public void testParseHardcodedContributors() throws Exception { //given String json = "[\n" + " {\n" + " \"login\": \"koral--\",\n" + " \"id\": 3340954,\n" + " \"site_admin\": true\n" + " },\n" + " {\n" + " \"login\": \"Wavesonics\",\n" + " \"id\": 406473,\n" + " \"site_admin\": false\n" + " }\n" + "]\n"; GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); Gson gson = gsonBuilder.create(); //when Contributor[] contributors; try (Reader reader = new BufferedReader(new StringReader(json))) { contributors = gson.fromJson(reader, Contributor[].class); } //then assertThat(contributors).hasSize(2); Contributor contributor = contributors[0]; assertThat(contributor.login).isEqualTo("koral--"); assertThat(contributor.siteAdmin).isTrue(); } |
That approach has several disadvantages. The most noticeable is poor readability of JSON, there is a lot of escaping characters and no syntax highlighting. Additionally there is little bit of boilerplate code, which will be duplicated if there is more JSONs to test. Let’s think how can it be written in more convenient way, improve readability and eliminate code duplication.
Improvements
Firstly Gson
object can be instantiated outside test method, eg. using some DI (dependency injection) mechanism like Dagger or a simple constant. DI is out of the scope of this article and we will use the latter in sample code. After extracting it may look like that:
1 2 3 4 5 6 7 8 9 | public final class Constants { public static final Gson GSON; static { final GsonBuilder gsonBuilder = new GsonBuilder(); gsonBuilder.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES); GSON = gsonBuilder.create(); } } |
Secondly JSON in textual form can be located in a resource file. This will give us syntax highlighting and indentation (pretty print), by default Android Studio and IntelliJ IDEA have those features built-in. Escaping the quotes is not needed so there will be no problem with readability. What is more line and column numbers in text file are consistent with those seen by GSON, so it will be easier to debug exceptions like this: MalformedJsonException: Unterminated array at line 4 column 5 path $[2]
. If JSON is located in separate file, numbers are matching exactly, in contradiction to aforementioned example with hardcoded JSON where location needs to be adjusted by offset of the text in java source file. Here is the file which will be used in this example:
1 2 3 4 5 6 7 8 9 10 11 12 | [ { "login": "koral--", "id": 3340954, "site_admin": true }, { "login": "Wavesonics", "id": 406473, "site_admin": false } ] |
Finally code performing conversion can be extracted from test method and generalized so it will be easier to use it with different test cases. It can be achieved using Java and JUnit features discussed in next chapters.
Goodies
Java resources
Java resource is a data file needed by program and located outside source code. Note that we are talking about Java resources, by default located in src/<source set>/resources
, not Android App Resources (drawables, layouts, etc.). There is no Android-specific features in this example. So everything is unit-testable without using frameworks like Robolectric.
If JSON file from listing 6. is saved as src/test/resources/pl/droidsonroids/modeltesting/api/contributors.json
it can be accessed from unit test code by calling TestClass.getResourceAsStream("contributors.json")
. Mentioned class needs to be located in matching package, in this example it is pl.droidsonroids.modeltesting.api
. See #getResourceAsStream()
javadoc for more details.
Annotations
Annotation is a metadata attached to source code elements (eg. method or class). There are well-known built-in annotations like @Override or @Deprecated. Custom ones can also be defined and we will use them to bind test methods to particular resources.
Annotation looks very similar to interface:
1 2 3 4 5 6 | @Retention(RUNTIME) @Target(METHOD) public @interface JsonFileResource { String fileName(); Class<?> clazz(); } |
Note @
sign before interface
keyword. Our custom annotation is annotated by 2 meta-annotations. We set Retention to RUNTIME because annotation need to be readable during unit test execution (runtime) so default retention (CLASS) is not sufficient. We also set Target to METHOD since we want to annotate only methods (bind them with particular resource). Misplaced annotation causes compilation error. Without specifying a target, annotation can be placed anywhere.
JUnit rules
In short rule is a hook triggered when test (method) is run. We will use rule to add additional behavior before test method execution. Namely we will parse JSON from resources and give access to corresponding POJO inside test method. Our goal is to support unit test like this one:
1 2 3 4 5 6 7 8 9 | @Rule public JsonParsingRule jsonParsingRule = new JsonParsingRule(Constants.GSON); @Test @JsonFileResource(fileName = "contributors.json", clazz = Contributor[].class) public void testGetContributors() throws Exception { Contributor[] contributors = jsonParsingRule.getValue(); assertThat(contributors).hasSize(2); assertThat(contributors[0].login).isEqualTo("koral--"); } |
As you can see boilerplate code is significantly reduced in comparison to listing 4. Only necessary parts are typed explicitly:
- GSON instance used to parse JSONs –
jsonParsingRule = new JsonParsingRule(Constants.GSON)
- resource where JSON string is located –
@JsonFileResource(fileName = "contributors.json"
- class of the POJO –
, clazz = Contributor[].class
- POJO instance receiving –
contributors = jsonParsingRule.getValue()
Note that only one instance of JsonParsingRule
for test class is needed. Rule is evaluated for each test method independently and jsonParsingRule.getValue()
result in particular method is not affected by previous tests. clazz is not a typo but intended name since class
is java language keyword and cannot be used as identifier. It is also important that field annotated with @Rule has to be public and non-static.
Rule implementation
Take a look at the rule implementation draft:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | public class JsonParsingRule implements TestRule { private final Gson mGson; private Object mValue; public JsonParsingRule(Gson gson) { mGson = gson; } @SuppressWarnings("unchecked") public T getValue() { return (T) mValue; } @Override public Statement apply(final Statement base, final Description description) { return new Statement() { @Override public void evaluate() throws Throwable { //TODO set mValue according to annotation base.evaluate(); } }; } } |
Our rule implements TestRule so it can be used with @Rule
annotation. We are using generic getter so its return value can be directly assigned to variable of particular type without casting in test methods. In apply()
method we are creating a wrapper around original Statement (test method). Call to base.evaluate()
is located at the end (after annotation processing) so effects of the rule can be visible during test method execution.
Now take a closer look at key part of statement wrapper (implementation of TODO
from listing 9.):
1 2 3 4 5 6 7 8 9 10 11 12 | JsonFileResource jsonFileResource = description.getAnnotation(JsonFileResource.class); if (jsonFileResource != null) { Class<?> clazz = jsonFileResource.clazz(); String resourceName = jsonFileResource.fileName(); Class<?> testClass = description.getTestClass(); InputStream in = testClass.getResourceAsStream(resourceName); assert in != null : "Failed to load resource: " + resourceName + " from " + testClass; try (Reader reader = new BufferedReader(new InputStreamReader(in))) { mValue = mGson.fromJson(reader, clazz); } } |
description
parameter is essential here, it gives us access to test method metadata including annotations. Rule is applied to all test methods including not annotated ones, in such case getAnnotation() returns null
and we can conditionally skip rest of customization. So test methods without @JsonFileResource
annotation (eg. testing something not involving JSON) can be placed in the test class using JsonParsingRule
. Line 8. is a shortened equivalent of the following code:
1 2 3 | if (in != null) { throw new AssertionError("Failed to load resource: " + resourceName + " from " + testClass); } |
At the end we pass resource wrapped with Reader to GSON engine. Try-with-resources statement is used here so Reader will be automatically closed after reading even if exception occurs. There is need to explicitly type finally
block.
Note that try-with-resources is available in Android since API level 19 (Kitkat). If test code is located inside Android gradle module and your minSdkVersion
is lower than 19 then you may want to annotate evaluate()
method with @TargetApi(Build.VERSION_CODES.KITKAT)
to avoid lint error. Unit tests are executed on development machine (Mac, PC, etc.) not on Android device or emulator so only compileSdkVersion
matters here.
Such kind of unit tests (not using any Android-specific API) can also be located in java module (apply plugin: 'java'
in build.gradle
). Theoretically it is the best idea, however there is an issue in Android Stuido/IntelliJ IDEA preventing that configuration from work out of the box in case of executing unit tests from IDE.
Complete project with example can be found on Droids On Roids’ Github.
About the author
Ready to take your business to the next level with a digital product?
We'll be with you every step of the way, from idea to launch and beyond!