I’d had my eyes on the Citrus framework, a test automation framework dedicated to testing messaging between applications and services, for a while in terms of integrating it into the IntelliJ-based IDEs. Finally, at the beginning of August 2023, I started a two-month journey of making and releasing the first version of the plugin called Citric.
Creating integration for a library/framework from scratch was not my first rodeo, I’d had already done it for a couple of them, like for Mockito and WireMock. However, this one came with an extra challenge.
The stable, major version of Citrus was 3.x at the time, and 4.x was on its way in its beta phase then. But, 4.x introduced a different base package, switching from com.consol.citrus
to org.citrusframework
, so in order to support both major versions, I had to figure out a way to handle two different package structures of Citrus classes.
NOTE: I’m not going to share exact implementation details because the plugin is not open-source, only exemplified snippets to demonstrate the ideas.
The challenge
To give you a glimpse, one example of the problem is when one would have to check if a Java method is annotated with a certain annotation.
Normally, in a Java reflection-like manner, in the IntelliJ Platform, you could call someJavaMethod.hasAnnotation("fully.qualified.name.of.SomeClass")
(see com.intellij.psi.PsiJvmModifiersOwner
), but for the two package structures this would mean an OR condition, like below, for every occurrence of a Citrus class in the plugin’s code base:
someJavaMethod.hasAnnotation("com.consol.citrus.junit.jupiter.CitrusSupport") || jsomeJavaMethod.hasAnnotation("org.citrusframework.junit.jupiter.CitrusSupport")
And, it would not just be for checking annotations on methods, but also for a bunch of stuff where fully qualified names of Citrus classes would have to be used.
Obviously, using it this way would not have been ok for maintenance, readability, re-usability and for other reasons. I had to come up with a more feasible solution.
Using the relative qualified name
The documentation and blog posts of the Citrus framework mention that between the two major versions, in terms of the package structure, the only change is from com.consol.citrus
to org.citrusframework
, so a find and replace of them should be sufficient when migrating to the new version. This means that anything succeeding these base packages in the fully qualified name can be used as unique identifiers of the Citrus classes.
So, for example, in case of the FQN com.consol.citrus.junit.jupiter.CitrusSupport
, the relative qualified name is junit.jupiter.CitrusSupport
. This uniquely identifies that specific CitrusSupport
class for both major versions of Citrus.
Building on this fact, I implemented a class/type (let’s call it CitrusClass
) that, among other properties, stores the qualified name of classes relative to these base packages.
Also, having a method like
public boolean isPresentOn(PsiMethod someJavaMethod) { }
in the CitrusClass
type means that the aforementioned hasAnnotation(...) || hasAnnotation(...)
condition can be replaced with an inverse version, something like
citrusSupportAnnotation.isPresentOn(someJavaMethod)
where citrusSupportAnnotation
is of type CitrusClass
by our example. This way we don’t have to worry about the base packages in plugin feature implementations, only the Citrus class itself. Determining the appropriate base package is handled by CitrusClass
.
Identifying the Citrus major version
Although, the plugin feature implementations are cleaner, and Citrus classes are managed in a central type, we still have to determine which base package of the classes should the plugin use during execution.
For this, I decided to look up the Citrus version used in the currently open project, and use the base package based on that version.
The first step of this was considering a single-module project and storing the Citrus version used in that project. If it is 3.x, the plugin would use com.consol.citrus
as the base package, if 4.x, it would use org.citrusframework
. 3.x is also treated as the fallback version in case none is found for some reason.
This seemed good, but I also had to account for multi-module projects where different test suites might use different versions of Citrus. This may happen e.g. when migrating modules/test suites from Citrus 3.x to 4.x to investigate potential migration issues, but keeping the rest of them on 3.x.
The solution to this was to cache the major version(s) of Citrus (the citrus-base jar) for each module of the project, at project startup and later when project dependencies change, so that this info remains up-to-date and reflects the current status of the project.
Then, I can query if only 3.x, only 4.x (both are boolean flags calculated based on the cached versions), or both major versions are used throughout all modules. In the latter case, plugin features retrieve the Citrus version for the module in which the currently edited file resides.
This approach eliminates the need for users to specify the used Citrus versions manually, instead the plugin can just simply auto-detect.
Further functionality
The aforementioned isPresentOn()
method is only one of many examples where the Citrus version must be determined. At this point it is only a matter of extending the CitrusClass
type with more methods to deal with other things concerning differing Citrus base packages and fully qualified names.
Testing
Of course, implementing tests for your plugin features, and in general, is important, I think that should go without saying.
Since I wanted to and I had to cover two major versions, that meant that I had essentially double the amount of test cases plus/minus some depending on the features.
The first approach I went with (that I didn’t really like, but didn’t have a better idea at the time) was to have two separate test classes with essentially the same set of test cases/methods, but one test class validating functionality for Citrus 3.x, the other for Citrus 4.x. That meant a whole lot more test classes and test methods, but it fortunately wasn’t much of an issue during implementation and maintenance.
class SomeCompletion3XTest {
@Test void testCodeCompletion1() { }
@Test void testCodeCompletion2() { }
}
class SomeCompletion4XTest {
@Test void testCodeCompletion1() { }
@Test void testCodeCompletion2() { }
}
But, I knew something had to be done, because as time goes on, and I implement new features for the Citrus Java DSL, there would be even more stuff to maintain and I didn’t like the sound of that.
Sometime after the first few releases I started to read through JUnit 5’s documentation, and started migrating the tests to use JUnit 5 base classes. That is when I discovered JUnit 5’s so-called dynamic tests that came quite handy.
The plan was to have one test class for each feature (i.e. inspection, intention action, etc.) covered (of course with a few exceptions) and each test method would execute the validation against both the 3.x and 4.x version of the same set of input test data.
The solution was two-fold:
- I had to merge and convert my test methods into
@TestFactory
test methods, - Replace the Citrus version specific parts of the test data with a placeholder to use it as a template for both Citrus versions.
A resulting test method looks something like this:
@TestFactory
Stream<DynamicTest> testCodeCompletion() {
return createTests("""
import [basePackage].actions.JavaAction;
import [basePackage].junit.jupiter.CitrusSupport;
import java.util.stream.Stream;
@CitrusSupport
class SomeCitrusTest {
//some actual test data with a caret placed at an element
}
""", resolvedCode -> validateCompletion(resolvedCode));
}
In the code snippet above
- the
createTests()
method returns twoDynamicTest
s (callingDynamicTest.dynamicTest()
) as a JavaStream
, one for Citrus 3.x, one for Citrus 4.x - before the test is executed, the
[basePackage]
placeholder is replaced with the actual base package (com.consol.citrus
ororg.citrusframework
)
based on the version currently tested - the
validateCompletion()
method does the actual validation against the inputresolvedCode
String in which the placeholder is already resolved, e.g.:
import org.citrusframework.actions.JavaAction;
import org.citrusframework.junit.jupiter.CitrusSupport;
import java.util.stream.Stream;
@CitrusSupport
class SomeCitrusTest {
//some actual test data with a caret placed at an element
}
As a result, this and similar approaches derived from this idea helped decrease the amount of test code by a large amount. Also, the fact that JUnit lifecycle methods are not executed for dynamic tests, I could skip some unnecessarily duplicate setup and tear down logic which then made the test execution a lot faster.
Final thoughts
Are these the best solution for both the production code and the automated tests? Probably not. Are they good enough to make my life easier? Definitely.
I’m fairly sure that others have come across having to support multiple different package structures for the same library/framework, but I have yet to find someone with such story. If you faced such a challenge in the past, feel free to share it with me on LinkedIn or X. We might be able to exchange some interesting ideas.