| name | write-test-case |
| description | Guide how to write test cases in this project by first analyzing the target class to decide which test pattern, base class, and test utilities to use. Activate when user asks to write tests, add test coverage, or create test cases for any class. |
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())
}
@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 from src/test/resources/ into the fixture project
loadFile(path, content) — loads inline Java source
findClass(qualifiedName) — finds a PsiClass by FQN
findMethod(psiClass, name) — finds a method on a class
runTest { } — runs a suspend block inside the ActionContext
setSettings(settings) / updateSettings { } — mutate test settings
instance<T>() — resolve a bound type from ActionContext
customizeContext(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 fun createConfigReader() = TestConfigReader.EMPTY
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() or ResultLoader.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")
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 control
withSimpleContext { } — minimal setup with SPI bindings only
createTestContext(...) — 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()
val settings = ApiFixtures.createSettings()
val postEndpoint = ApiFixtures.createPostEndpoint()
val uploadEndpoint = ApiFixtures.createFileUploadEndpoint()
TestConfigReader — Fake config rules
Use to inject .easy.api.config-style rules without touching the filesystem.
TestConfigReader.EMPTY
TestConfigReader.fromRules(
"method.return.main.status" to "200",
"ignore" to "@Deprecated"
)
TestConfigReader.fromConfigText("""
method.return.main.status=200
ignore=@Deprecated
""".trimIndent())
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()
ResultLoader.load("name")
ResultLoader.loadOrNull("name")
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 @Test in 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)