Write Test Case
Overview
This project is an IntelliJ IDEA plugin (APilot). Tests use JUnit 4 + Mockito/Mockito-Kotlin + IntelliJ Platform Test Framework. Before writing any test, analyze the target class to pick the right pattern.
Step 1 — Analyze the Target Class
Read the class under test and answer these questions:
| Question | Answer leads to |
|---|---|
| Does it interact with PSI (PsiClass, PsiMethod, PsiField)? | IDE fixture pattern |
Does it need a real IntelliJ Project object? |
IDE fixture pattern |
| Does it export/format API endpoints? | IDE fixture + ResultLoader pattern |
| Is it a pure utility / data class with no IDE dependency? | Simple unit test pattern |
Is it an AnAction subclass? |
Action mock pattern |
Does it involve coroutines / ActionContext? |
ActionContextTestKit pattern |
| Is it testing that multiple implementations share the same interface? | Parity test pattern |
Principle — Test Behavior, Not Implementation
Never test private methods via reflection. Private methods are implementation details. Tests should verify what a class does, not how it does it internally.
If you feel the urge to test a private method directly, treat it as a signal:
- The class is doing too much — extract the logic into a new class with a public interface, then test that class directly.
- Your public tests don't cover enough paths — add more test cases that exercise the private logic through the public API.
Reflection-based tests are brittle: they break on renames, bypass access modifiers intentionally, and make refactoring painful without adding real confidence.
The only meaningful exception is legacy code you genuinely cannot refactor. Even then, prefer extracting over reflecting.
Rule: if a private method is worth testing, it's worth making testable — either by testing it through the public API that calls it, or by extracting it into its own class.
Step 2 — Choose the Right Pattern
Pattern A — Simple Unit Test
When: Pure utility, data model, cache service, or any class with no IntelliJ platform dependency.
Base: None (plain JUnit 4 class)
Annotations: @Test, @Before, @After
Mocking: mock<T>() from mockito-kotlin
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.mockito.kotlin.mock
class MyServiceTest {
private lateinit var service: MyService
@Before
fun setUp() {
service = MyService(mock()) // inject mocks via constructor
}
@Test
fun testSomeBehavior() {
val result = service.doSomething("input")
assertEquals("expected", result)
}
@Test
fun testNullCase() {
assertNull(service.getString("nonexistent"))
}
}
Real examples: CacheServiceTest, GsonUtilsTest, HttpModelsTest
Pattern B — IDE Fixture Test (PSI / Project-aware)
When: Class needs a real Project, reads PSI elements, or is a project-level service.
Base: APilotLightCodeInsightFixtureTestCase
Key methods available:
loadFile(path)— loads a Java file fromsrc/test/resources/into the fixture projectloadFile(path, content)— loads inline Java sourcefindClass(qualifiedName)— finds aPsiClassby FQNfindMethod(psiClass, name)— finds a method on a classrunTest { }— runs a suspend block inside theActionContextsetSettings(settings)/updateSettings { }— mutate test settingsinstance<T>()— resolve a bound type fromActionContextcustomizeContext(builder)— override to add extra bindings
Test method naming: use fun test...() (no @Test annotation — JUnit 3 style inherited from LightJavaCodeInsightFixtureTestCase)
import com.itangcent.apilot.testFramework.APilotLightCodeInsightFixtureTestCase
import com.itangcent.apilot.testFramework.TestConfigReader
class MyExporterTest : APilotLightCodeInsightFixtureTestCase() {
private lateinit var exporter: MyExporter
override fun setUp() {
super.setUp()
loadTestFiles()
exporter = MyExporter(actionContext)
}
// Override to supply config rules for this test class
override fun createConfigReader() = TestConfigReader.EMPTY
// Override to inject extra services into ActionContext
override fun customizeContext(builder: ActionContextBuilder) {
builder.bind(MyHelper::class, MyHelperImpl())
}
private fun loadTestFiles() {
loadFile("spring/RequestMapping.java")
loadFile("spring/GetMapping.java")
loadFile("spring/RestController.java")
loadFile("model/UserInfo.java")
loadFile("api/UserCtrl.java")
}
fun testExportReturnsEndpoints() = runTest {
val psiClass = findClass("com.itangcent.api.UserCtrl")
assertNotNull(psiClass)
val endpoints = exporter.export(psiClass!!)
assertTrue(endpoints.isNotEmpty())
}
}
Real examples: ExportOrchestratorTest, ApiDashboardServiceTest, DefaultConfigReaderTest, EndToEndExportTest
Pattern C — ResultLoader (snapshot / golden-file) Test
When: The output is a large formatted string (Markdown, Postman JSON, curl commands) that should be compared against a saved expected result.
Base: APilotLightCodeInsightFixtureTestCase
Utility: ResultLoader
How it works:
- Run the test once to produce actual output.
- Save the output to
src/test/resources/result/{FullyQualifiedTestClass}.{optionalName}.txt. - In the test, compare with
ResultLoader.load()orResultLoader.load("name").
File naming convention:
- Default:
com.itangcent.apilot.exporter.MyFormatterTest.txt - Named:
com.itangcent.apilot.exporter.MyFormatterTest.testParseRequests.txt
import com.itangcent.apilot.testFramework.APilotLightCodeInsightFixtureTestCase
import com.itangcent.apilot.testFramework.ResultLoader
class MyFormatterTest : APilotLightCodeInsightFixtureTestCase() {
fun testFormatOutput() = runTest {
val endpoints = ApiFixtures.createSampleEndpoints()
val result = MyFormatter().format(endpoints, "Test API")
// Compare against saved golden file
assertEquals(ResultLoader.load("testFormatOutput"), result.trimEnd())
}
}
Real examples: PostmanFormatterTest, CurlFormatterTest, MarkdownApiExporterTest
Pattern D — Action Mock Test
When: Testing an AnAction subclass or classes that receive AnActionEvent.
Base: Plain JUnit 4 (no IDE fixture needed for unit-level action tests)
Mocking: mock(Project::class.java), mock(AnActionEvent::class.java)
import org.junit.Before
import org.junit.Test
import org.mockito.Mockito.*
class MyActionTest {
private lateinit var action: MyAction
private lateinit var mockProject: Project
private lateinit var mockEvent: AnActionEvent
@Before
fun setUp() {
action = MyAction()
mockProject = mock(Project::class.java)
mockEvent = mock(AnActionEvent::class.java)
`when`(mockEvent.project).thenReturn(mockProject)
`when`(mockEvent.presentation).thenReturn(Presentation())
}
@Test
fun testActionUpdate() {
action.update(mockEvent)
verify(mockEvent).presentation
}
}
Real examples: BaseExportActionTest
Pattern E — ActionContextTestKit (coroutine / context-scoped)
When: Testing a class that requires ActionContext but does NOT need a full IDE fixture (no PSI).
Utility: ActionContextTestKit
import com.itangcent.apilot.testFramework.ActionContextTestKit
import com.itangcent.apilot.testFramework.ActionContextTestKit.binding
import com.itangcent.apilot.testFramework.TestConfigReader
import org.junit.Test
class MyContextAwareServiceTest {
@Test
fun testServiceBehavior() {
ActionContextTestKit.withTestContext(
project = mockProject,
configReader = TestConfigReader.fromRules("rule.key" to "rule.value"),
additionalBindings = listOf(binding<MyDep>(MyDepImpl()))
) {
val service = instance<MyContextAwareService>()
val result = service.compute()
assertEquals("expected", result)
}
}
}
Variants:
withTestContext(project, settings, configReader, additionalBindings) { }— full controlwithSimpleContext { }— minimal setup with SPI bindings onlycreateTestContext(...)— manual lifecycle management
Pattern F — Parity / Contract Test
When: Verifying that multiple implementations of the same interface all exist and behave consistently.
Base: APilotLightCodeInsightFixtureTestCase
class ExporterParityTest : APilotLightCodeInsightFixtureTestCase() {
override fun createConfigReader() = TestConfigReader.EMPTY
fun testAllExportersExist() = runTest {
assertNotNull(SpringMvcClassExporter(actionContext))
assertNotNull(FeignClassExporter(actionContext))
assertNotNull(JaxRsClassExporter(actionContext))
}
}
Real examples: FeatureParityTest, FormatterParityTest, SettingBinderParityTest
Step 3 — Use the Right Test Utilities
ApiFixtures — Pre-built test data
Use when you need ApiEndpoint, Settings, or common endpoint lists without PSI.
val endpoint = ApiFixtures.createEndpoint(name = "getUser", path = "/api/users/{id}")
val endpoints = ApiFixtures.createSampleEndpoints() // 5 CRUD endpoints
val settings = ApiFixtures.createSettings() // settings with test token
val postEndpoint = ApiFixtures.createPostEndpoint() // POST with body params
val uploadEndpoint = ApiFixtures.createFileUploadEndpoint()
TestConfigReader — Fake config rules
Use to inject .easy.api.config-style rules without touching the filesystem.
// No config
TestConfigReader.EMPTY
// From key=value pairs
TestConfigReader.fromRules(
"method.return.main.status" to "200",
"ignore" to "@Deprecated"
)
// From config file text
TestConfigReader.fromConfigText("""
method.return.main.status=200
ignore=@Deprecated
""".trimIndent())
// From a map
TestConfigReader.fromMap(mapOf("key" to "value"))
ConstantSettingBinder — Mutable in-memory settings
Already wired into APilotLightCodeInsightFixtureTestCase as testSettingBinder. Use updateSettings { } to change settings mid-test.
updateSettings {
postmanToken = "my-token"
feignEnable = true
httpTimeOut = 5000
}
ProjectWrapper — Replace project services
Use when you need to swap out a specific project service without rebuilding the whole context.
val wrappedProject = wrap(project) {
replaceService(MyProjectService::class, FakeMyProjectService())
}
ResultLoader — Load golden-file expected output
ResultLoader.load() // loads {ClassName}.txt
ResultLoader.load("name") // loads {ClassName}.name.txt
ResultLoader.loadOrNull("name") // returns null if file missing
Step 4 — Test Resource Files
Java source files used as PSI fixtures live in src/test/resources/. Common ones:
| Path | Contents |
|---|---|
spring/ |
Spring MVC annotations (GetMapping, PostMapping, etc.) |
api/UserCtrl.java |
Sample Spring REST controller |
model/UserInfo.java, model/Result.java |
Common model classes |
constant/UserType.java |
Enum example |
jaxrs/ |
JAX-RS annotations |
feign/ |
Feign annotations |
validation/ |
javax.validation annotations |
Always load the minimum set of files needed. Load Spring annotation stubs before loading controllers that use them.
Step 5 — Decision Flowchart
Target class uses PSI / Project?
├── YES → Pattern B (APilotLightCodeInsightFixtureTestCase)
│ Output is a large formatted string?
│ └── YES → also use Pattern C (ResultLoader)
└── NO
├── Has ActionContext dependency (no PSI)?
│ └── YES → Pattern E (ActionContextTestKit)
├── Is an AnAction subclass?
│ └── YES → Pattern D (Action Mock)
├── Testing multiple implementations for parity?
│ └── YES → Pattern F (Parity Test)
└── Pure utility / data class?
└── YES → Pattern A (Simple Unit Test)
Step 6 — Naming & Location Conventions
- Test class:
src/test/kotlin/com/itangcent/apilot/{package}/{ClassName}Test.kt - Golden files:
src/test/resources/result/{FQN}.{name}.txt - Test resources:
src/test/resources/{category}/ - Method names:
fun testSomeBehavior()(no@Testin IDE fixture tests),@Test fun testSomeBehavior()in plain JUnit 4 tests - Assertion messages: always include a descriptive message as the first argument, e.g.
assertEquals("Should return same instance", a, b)