Designing Testable App Architecture (Part 2)
In Designing Testable App Architecture (Part 1), we covered how Booster solves the problem of reusing Transformer across both local unit test and build environments. In this part, let’s explore how to use Booster‘s TransformerClassLoader to make app architectures testable.
Unit Testing Frameworks
In the Java world, the two most popular unit testing frameworks are JUnit and TestNG. Most developers have heard of JUnit but may not be familiar with TestNG. In my view, there isn’t much difference between them. Many developers are familiar with JUnit in theory but have rarely written real unit tests. Let’s start with a basic JUnit example:
1 | public class Calculator { |
1 | import static org.junit.Assert.assertEquals; |
Running evaluatesExpression in the IDE launches JUnit by default. If you need to mock classes, you’ll use a mocking framework like Mockito or PowerMock. When writing unit tests with these frameworks, you’ll inevitably encounter a JUnit component called Test Runner, typically specified via @RunWith on the test class:
MockitoJUnitRunner
1 |
|
PowerMockRunner
1 |
|
For writing local unit tests for Java Library projects targeting the Android platform, Robolectric is commonly used. Like Mockito and PowerMock, it provides its own Test Runner – RobolectricTestRunner:
1 |
|
The Essence of Mocking
All mocking-capable frameworks inevitably rely on a Test Runner. Why?
To mock properties or methods in another class, you need to swap out the real ones. When does the swap happen, and how? There are generally two approaches – compile-time or runtime.
Compile-Time Injection
For regular Java projects, compile-time injection typically uses the Instrumentation mechanism available since Java 6. A Java Agent containing a ClassFileTransformer is provided to the JVM as a plugin via command-line arguments:
1 | java -javaagent:my-agent.jar -jar my-app.jar |
While this works, it requires developers to configure command-line arguments in the IDE’s run configuration, which isn’t a great developer experience.
Runtime Injection
Runtime injection typically uses a custom ClassLoader – like the TransformerClassLoader discussed earlier – to modify classes in memory during loading via a bytecode manipulation framework. Investigation shows that the mocking frameworks mentioned above all use runtime injection.
The Significance of Test Runner
JUnit provides the @RunWith annotation so developers can specify which Runner to use. JUnit also provides several built-in runners:
BlockJUnit4ClassRunnerBlockJUnit4ClassRunnerWithParametersSuite- …
To implement runtime injection, we need a custom Runner. For convenience, we extend BlockJUnit4ClassRunner:
1 | class BoosterTestRunner(clazz: Class<*>) : BlockJUnit4ClassRunner(clazz) { |
How do we make BoosterTestRunner execute when a @Test-annotated method runs? Let’s look at how BlockJUnit4ClassRunner actually works:
Now we understand JUnitTestClassExecutor‘s runtime sequence. Since we want to do something before each @Test-annotated method executes, we need to override BlockJUnit4ClassRunner‘s methodBlock(FrameworkMethod) method, replacing the Method referenced by FrameworkMethod with a modified one.
Where does the modified
Methodcome from?
From the modified
Class, of course:
1 | override fun methodBlock(method: FrameworkMethod): Statement { |
With BoosterTestRunner, the full sequence for running a unit test looks like this:
- Blog Link: https://johnsonlee.io/2021/12/18/testable-app-architecture-design-2.en/
- Copyright Declaration: 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
