Canonical workspace rules for test sources live in
../workspace/guides/test/TEST_WRITING_GUIDE-8.md(JUnit Jupiter framework choices, AAA structure with// pre-assertsemantics, both<editor-fold>and@Nestedgrouping styles, naming pattern, Hamcrest assertions, exception testing, parameterized tests via@MethodSource, logger mocking, import grouping, DRY constants per group). This repo is Java 8, so only the-8.mdbaseline applies. Derived from a full pass oversrc/test/java/net/ladenthin/maven/llamacpp/aiindex/. This file contains only plugin-specific applications: MavenLogmocking (org.apache.maven.plugin.logging.Logvia Mockito),MockAiGenerationProviderpatterns, the bundledSmolLM2-135M-Instruct-Q3_K_M.ggufmodel used by real-JNI integration tests, and theLlamaCppJniAvailability.isAvailable()guard.
Every test file must start with the formatter-off block enclosing the Apache 2.0 license header, exactly as shown:
// @formatter:off
/**
* Copyright 2026 Bernard Ladenthin bernard.ladenthin@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// @formatter:on
package net.ladenthin.maven.srcmorph;- The
// @formatter:off/// @formatter:onpair wraps only the license block. - The year must match the file creation year (not the current year).
| Concern | Choice |
|---|---|
| Test runner | JUnit Jupiter (@Test, @BeforeEach, @TempDir, etc.) from org.junit.jupiter.api.* |
| Parameterized tests | @ParameterizedTest + @MethodSource / @ValueSource / @CsvSource |
| Assertions | Hamcrest only — assertThat(actual, is(equalTo(expected))) |
| Mocking | Mockito (mock(), verify(), when(), ArgumentCaptor) |
| Temp file system | Files.createTempDirectory(...) or @TempDir Path folder (JUnit Jupiter) |
| Maven logger in tests | new SystemStreamLog() or mock(Log.class) |
Do NOT use:
Assertions.assertEquals/Assertions.assertTrue/Assertions.assertFalse— use Hamcrest equivalents.- TestNG or JUnit 4 (
org.junit.*).
public class FooTest {No runner annotation is required — JUnit Jupiter discovers @Test and @ParameterizedTest methods automatically.
Declare shared utilities as private final instance fields:
private final AiMdDocumentCodec documentCodec = new AiMdDocumentCodec();
private final AiMdHeaderCodec headerCodec = new AiMdHeaderCodec();Mocks that need fresh state per test are declared at field level but initialized in @BeforeEach:
private Log mockLog;
@BeforeEach
public void setUp() {
mockLog = mock(Log.class);
}An empty @BeforeEach method should be omitted entirely. Only keep it if it does meaningful work.
Prefer Files.createTempDirectory(...) for one-off temp directories within a single test. Use @TempDir when multiple tests in the same class share the same temporary root — JUnit Jupiter resolves a fresh directory per test instance.
@TempDir
public Path folder;Tests within a class must be grouped using NetBeans-style editor fold regions, one fold per method/feature under test:
// <editor-fold defaultstate="collapsed" desc="methodName">
@Test
public void methodName_conditionA_expectedResultA() { ... }
@Test
public void methodName_conditionB_expectedResultB() { ... }
// </editor-fold>Rules:
- The
descattribute equals the method name (or a short feature label for non-method groups). defaultstate="collapsed"is mandatory on every fold.- All tests for the same method go inside a single fold.
- Tests that exercise different methods must be in different folds.
- The fold order in the file should match logical reading order (simple cases first, edge cases and exceptions last).
Pattern: methodUnderTest_inputOrCondition_expectedBehavior
indexSourceRoot_emptyDirectory_returnsZero
indexSourceRoot_existingSummaryForceIsFalse_skipsFile
indexSourceRoot_forceIsTrue_regeneratesSummary
read_validDocument_parsesHeaderAndBody
write_documentWithMetadata_roundtripsCorrectly
preparePrompt_sourceLongerThanMax_trimmedFlagIsTrue
Rules:
- All three segments are required and separated by underscores.
- Use camelCase within each segment.
- The
expectedsegment describes the observable outcome, not the implementation step.- Good:
_returnsZero,_throwsException,_skipsFile,_roundtripsCorrectly - Bad:
_works,_correct,_test
- Good:
- Exception tests: the segment ends with
_throwsExceptionor_exceptionThrown. - No-op / smoke tests: use
_noExceptionThrown.
Every test body must follow the Arrange / Act / Assert structure with explicit section comments:
@Test
public void indexSourceRoot_emptyDirectory_returnsZero() throws Exception {
// arrange
final Path temp = Files.createTempDirectory("ai-test");
final SourceFileIndexer indexer = new SourceFileIndexer(
new SystemStreamLog(), temp, temp, List.of(".java"),
"0.1.0", "0.0.0", List.of(), false,
new MockAiGenerationProvider(), List.of(), new AiPromptSupport(List.of())
);
// act
final int result = indexer.indexSourceRoot(temp);
// assert
assertThat(result, is(equalTo(0)));
}// pre-assert is a named section that asserts a condition without it being the primary assertion of the test.
1. Before // act — to verify a precondition of the input:
// arrange
final AiMdDocument document = documentCodec.read(aiFile);
// pre-assert
assertThat(document.header().s(), is(emptyOrNullString()));
// act
final int count = indexer.indexSourceRoot(sourceRoot);
// assert
assertThat(count, is(equalTo(1)));2. Between // act and // assert — as a guard before accessing fields:
// act
final AiMdDocument updated = documentCodec.read(aiFile);
// pre-assert
assertThat(updated, is(notNullValue()));
// assert
assertThat(updated.header().s(), is(equalTo("Mock summary for Test.java")));Rules:
// arrangemay be omitted only when there is genuinely nothing to arrange.- Keep the act to a single method call whenever possible.
- Do not use
Objects.requireNonNull(...)as a guard in tests; use a// pre-assertwithassertThat(x, is(notNullValue())).
All assertions use the Hamcrest assertThat form:
// equality
assertThat(result, is(equalTo(expected)));
// null / not null
assertThat(result, is(nullValue()));
assertThat(result, is(notNullValue()));
// boolean
assertThat(flag, is(true));
assertThat(flag, is(false));
// negation
assertThat(result, is(not(equalTo(unexpected))));
// strings
assertThat(message, containsString("substring"));
assertThat(output, not(emptyOrNullString()));
// collections
assertThat(list, hasSize(3));
assertThat(list, is(empty()));
// numbers
assertThat(count, is(greaterThan(0)));
assertThat(count, is(equalTo(0)));Imports to use:
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*; // or import specific matchersDo not use:
org.junit.Assert.assertEqualsorg.junit.Assert.assertTrue/assertFalseAssert.assertNotNull
@Test
public void preparePrompt_unsupportedTarget_throwsException() {
// act / assert
assertThrows(IllegalArgumentException.class, () -> summarizer.someMethod(null));
}Use assertThrows and inspect the returned exception when the message must be asserted:
@Test
public void create_unknownProvider_throwsWithMessage() {
// arrange
final AiGenerationProviderFactory factory = new AiGenerationProviderFactory();
// act
final IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> factory.create("unknown-provider", config, promptSupport));
// assert
assertThat(e.getMessage(), containsString("unknown-provider"));
}When multiple test cases share the same test logic with different inputs, use JUnit Jupiter's @ParameterizedTest with a @MethodSource:
// 1. Constant for the source method name
public static final String SOURCE_NODE_TYPES = "nodeTypes";
// 2. Javadoc linking to which test it serves
/**
* For {@link AiMdHeaderCodecTest}.
*/
static Stream<String> nodeTypes() {
return Stream.of(
AiMdHeaderCodec.NODE_TYPE_FILE,
AiMdHeaderCodec.NODE_TYPE_PACKAGE
);
}Consuming a source:
public class AiMdHeaderCodecTest {
@ParameterizedTest
@MethodSource("nodeTypes")
public void roundtrip_validNodeType_preservesValue(final String nodeType) {
// arrange
final AiMdHeader header = buildHeader(nodeType);
// act
final String encoded = headerCodec.encode(header);
final AiMdHeader decoded = headerCodec.decode(encoded);
// assert
assertThat(decoded.x(), is(equalTo(nodeType)));
}
}For sources shared across multiple test classes, reference a fully-qualified method:
@MethodSource("net.ladenthin.maven.srcmorph.CommonDataProvider#nodeTypes").
Inject a mock Log to verify that a class logs expected messages:
@Before
public void setUp() {
mockLog = mock(Log.class);
}
@Test
public void indexSourceRoot_missingSourceFile_logsWarning() throws Exception {
// arrange
final SourceFileIndexer indexer = new SourceFileIndexer(mockLog, ...);
// act
indexer.indexSourceRoot(sourceRoot);
// assert
verify(mockLog, atLeastOnce()).warn(contains("Skipping missing subtree"));
}Use ArgumentCaptor<String> when the full message content must be asserted:
final ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(mockLog).warn(captor.capture());
assertThat(captor.getValue(), containsString("expected fragment"));Tests that exercise the real LlamaCppJniAiGenerationProvider must:
- Be annotated with a marker (e.g.,
@LlamaJniTestif introduced) or clearly named with_realProvider_in the method name. - Guard with an availability check as the first statement, so they skip gracefully when the native library is absent.
- Use the bundled
SmolLM2-135M-Instruct-Q3_K_M.gguftest model fromsrc/test/resources/.
@Test
public void generate_realProvider_returnsNonEmptyResponse() {
// arrange — skip if native lib is unavailable
Assume.assumeTrue("llama JNI library not available",
LlamaCppJniAvailability.isAvailable());
final Path modelPath = Paths.get("src/test/resources/SmolLM2-135M-Instruct-Q3_K_M.gguf");
Assume.assumeTrue("test model not found", Files.exists(modelPath));
// arrange
final LlamaCppJniConfig config = new LlamaCppJniConfig(null, modelPath.toString(), 512, 32, 0.0f, 2);
// act / assert — omitted for brevity
}All tests that do not need real inference must use MockAiGenerationProvider.
Group imports in this order (no blank lines within groups, blank line between groups):
- Standard Java (
java.*,javax.*) - Third-party libraries (alphabetical)
- Project classes (
net.ladenthin.*) - Static imports (last, alphabetical)
Prefer specific static imports over wildcards when only one or two matchers are used:
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;Use wildcard static import when many matchers are used:
import static org.hamcrest.Matchers.*;When the same literal appears in two or more tests within the same fold, extract it into a private static final constant at the class level.
// GOOD — one definition; both tests derive from it
private static final String FIXED_CHECKSUM = "AAAAAAAA"; // arbitrary 8-char hex for header tests
assertThat(updated.header().c(), is(equalTo(FIXED_CHECKSUM)));// BAD — same literal repeated across tests
assertThat(updated.header().c(), is(equalTo("AAAAAAAA")));
// ... in another test ...
assertThat(updated.header().c(), is(equalTo("AAAAAAAA")));Constants belong to their logical fold. Do not share a constant between different folds even when the underlying value is identical — different folds test independent methods and should not be coupled.
| Anti-pattern | Correct alternative |
|---|---|
Assert.assertEquals(expected, actual) |
assertThat(actual, is(equalTo(expected))) |
Assert.assertTrue(condition) |
assertThat(condition, is(true)) |
Assert.assertNotNull(x) |
assertThat(x, is(notNullValue())) |
Objects.requireNonNull(x) as guard in tests |
// pre-assert with assertThat(x, is(notNullValue())) |
System.out.println(...) in tests |
Remove; use logger assertions instead |
Missing // arrange / act / assert comments |
Add the section comments always |
| Missing editor fold | Wrap each method group in <editor-fold> |
Non-conforming test name like shouldSummarizeFile() |
Rename to summarizeFiles_condition_expectation() |
Empty @Before method |
Remove it |
@RunWith(DataProviderRunner.class) without @UseDataProvider |
Remove the @RunWith |
Hard-coded path strings like "/tmp/test" |
Use Files.createTempDirectory(...) |
| Real llama JNI without availability guard | Add Assume.assumeTrue(...) as first statement |
| Removing existing correct inline comments during a fix | Preserve all correct comments; only remove factually wrong ones |
When modifying existing test code:
- Keep all existing inline comments that are correct and descriptive.
- Only remove a comment if it is factually wrong, misleading, or describes code that no longer exists.
- Add new comments where added code is not self-explanatory.
- When adding AAA section comments, place them around existing inline comments — do not replace them.
The goal is to minimize the diff to only lines that actually need changing.
// @formatter:off
/**
* Copyright 2026 Bernard Ladenthin bernard.ladenthin@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// @formatter:on
package net.ladenthin.maven.srcmorph;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.maven.plugin.logging.SystemStreamLog;
import org.junit.jupiter.api.Test;
public class SourceFileIndexerTest {
private final AiMdDocumentCodec documentCodec = new AiMdDocumentCodec();
// shared constant used across multiple tests in the same fold
private static final String PLUGIN_VERSION = "0.1.0-SNAPSHOT";
private static final String AI_VERSION = "0.0.0";
// <editor-fold defaultstate="collapsed" desc="indexSourceRoot">
@Test
public void indexSourceRoot_emptyDirectory_returnsZero() throws Exception {
// arrange
final Path temp = Files.createTempDirectory("ai-test");
final SourceFileIndexer indexer = new SourceFileIndexer(
new SystemStreamLog(), temp, temp, List.of(".java"),
PLUGIN_VERSION, AI_VERSION, List.of(), false,
new MockAiGenerationProvider(), List.of(), new AiPromptSupport(List.of())
);
// act
final int result = indexer.indexSourceRoot(temp);
// assert
assertThat(result, is(equalTo(0)));
}
@Test
public void indexSourceRoot_existingSummaryForceIsFalse_skipsFile() throws Exception {
// arrange
final Path temp = Files.createTempDirectory("ai-test");
// ... set up files ...
final SourceFileIndexer indexer = buildIndexer(temp, false);
// act
final int result = indexer.indexSourceRoot(temp);
// assert
assertThat(result, is(equalTo(0)));
}
// </editor-fold>
// <editor-fold defaultstate="collapsed" desc="indexSourceRoot — round-trip">
@Test
public void indexSourceRoot_newFile_writesExpectedSummary() throws Exception {
// arrange
final Path temp = Files.createTempDirectory("ai-test");
final Path sourceFile = setupSourceFile(temp, "Test.java");
final SourceFileIndexer indexer = buildIndexer(temp, false);
// act
final int result = indexer.indexSourceRoot(temp);
// pre-assert
assertThat(result, is(equalTo(1)));
// assert
final Path aiFile = temp.resolve("Test.java" + AiMdHeaderCodec.AI_MD_EXTENSION);
final AiMdDocument written = documentCodec.read(aiFile);
assertThat(written, is(notNullValue()));
assertThat(written.header().s(), is(not(emptyOrNullString())));
}
// </editor-fold>
}