Test Patterns and Conventions¶
Analysis Date: January 13, 2026
Updated: January 28, 2026 (Added ExecutorUtils tests, renamed serializer folders) Total Unit Test Files Analyzed: 247
Framework: JUnit 5 (Jupiter) + AssertJ + Mockito
CRITICAL RULE: ONE METHOD, ONE TEST FILE¶
ABSOLUTE REQUIREMENT: Each test file MUST test exactly ONE method (with permuted input and context setups fo vaious scenarios).
CORRECT Examples:¶
getSslContextTests.java- Tests ONLY thegetSslContext()methodbuildDefaultTests.java- Tests ONLY thebuildDefault()static methodbuildTests.java- Tests ONLY thebuild()method of a builder classcloseTests.java- Tests ONLY theclose()methodserializeTests.java- Tests ONLY theserialize()method
INCORRECT Examples (NEVER DO THIS):¶
sslContextTests.java- Too generic, doesn't specify method namebuilderTests.java- Too generic, builder is a method not a conceptedgeCasesTests.java- Too generic, edge cases belong in method-specific filesutilityMethodsTests.java- Testing multiple methods in one filehelperTests.java- Too vague, no specific method
Rule Application:¶
- Find the method name in the source code
- Create test file:
{methodName}Tests.java(camelCase) - All tests in that file test ONLY that method
- Different scenarios for the same method go in the SAME file
DO NOT TEST: Constants, Abstract Classes, or Lombok-Generated Code¶
RULE: The following elements should NOT have dedicated test files:
| Element | Reason |
|---|---|
Static constants/fields (e.g., MIN_POOL_SIZE, MAX_POOL_SIZE) |
Values are self-evident; no behavior to test |
| Abstract classes directly | Test concrete implementations instead; each impl may have different behavior |
Lombok-generated constructors (@NoArgsConstructor, @AllArgsConstructor, @RequiredArgsConstructor) |
Auto-generated code with no custom logic |
Lombok-generated getters/setters (@Data, @Getter, @Setter) |
Auto-generated code with no custom logic |
EXCEPTION: Test methods that are manually written and contain validation or business logic:
// This constructor SHOULD be tested - it has validation logic
public Provider(EventListenerTransaction transaction) {
this.transaction = ValidationUtils.requireNonNull(transaction, "transaction is required");
}
// This setter SHOULD be tested - it has validation logic
public void setConfiguration(MapConfiguration configuration) {
this.configuration = ValidationUtils.requireNonNull(configuration, "configuration is required");
this.pattern = ValidationUtils.requireNonBlank(configuration.getString(PATTERN, ""), "pattern is required").trim();
}
// This getter SHOULD be tested - it has custom logic
@Override
public String getId() { return Constants.ID; }
CRITICAL RULE: INHERITED METHODS IN IMPLEMENTATION TESTS¶
ABSOLUTE REQUIREMENT: Inherited methods MUST be tested as part of each implementation's test folder, NOT in a separate parent-level test file.
CORRECT Examples:¶
matchers/globmatcher/acceptTests.java ← Tests accept() inherited from Matcher
matchers/globmatcher/setConfigurationTests.java ← Tests setConfiguration() (manually written with validation)
matchers/listmatcher/acceptTests.java ← Same inherited method, tested per implementation
INCORRECT Examples (NEVER DO THIS):¶
matchers/acceptTests.java ← WRONG: Testing inherited method at parent level
serializers/getContentTypeTests.java ← WRONG: Testing all serializers in one file
destinations/setConfigurationTests.java ← WRONG: Parent-level test for inherited method
Rule Application:¶
- Identify inherited methods in base class (e.g.,
Matcher.accept(),Serializer.getContentType()) - Create test file in each implementation folder (e.g.,
globmatcher/acceptTests.java) - Test the inherited behavior using the concrkete implementation
- Each implementation gets its own test file for the inherited method
- No parent-level test files for methods that are inherited by implementations
Rationale:¶
- Coverage is reported per-class, so inherited methods must be exercised through each subclass
- Each implementation may have different setup/configuration requirements
- Ensures inherited behavior works correctly with each implementation's specific state
1. Directory Structure¶
1.1 Root Test Directory¶
1.2 Package Organization Pattern¶
RULE: Test packages mirror the main source class location, with an additional folder named after the class being tested (in lowercase).
Main Source: Test Location:
src/main/.../ProviderFactory.java → src/tests/unit-tests/.../providerfactory/
src/main/.../Provider.java → src/tests/unit-tests/.../provider/
src/main/.../Configuration.java → src/tests/unit-tests/.../configuration/configuration/
src/main/.../Route.java → src/tests/unit-tests/.../routes/route/
src/main/.../utils/ValidationUtils.java → src/tests/unit-tests/.../utils/validationutils/
src/main/.../utils/RouteUtils.java → src/tests/unit-tests/.../routes/routeutils/
src/main/.../destinations/HttpDestination.java → src/tests/unit-tests/.../destinations/httpdestination/
src/main/.../serializers/JsonSerializer.java → src/tests/unit-tests/.../serializers/jsonserializer/
src/main/.../matchers/GlobMatcher.java → src/tests/unit-tests/.../matchers/globmatcher/
1.3 Folder Naming Rules¶
| Element | Naming Pattern |
|---|---|
| Top-level category folders | lowercase plural (e.g., destinations/, serializers/, matchers/, utils/, routes/) |
| Class-under-test folder | lowercase class name (e.g., httpdestination/, jsonserializer/, globmatcher/) |
| Utility class folder | lowercase class name (e.g., validationutils/, templateutils/, retryutils/) |
1.4 Complete Folder Structure¶
src/tests/unit-tests/java/io/github/fortunen/kete/
├── configuration/
│ ├── configuration/
│ │ └── constructorTests.java
│ └── configurationutils/
│ ├── constructorTests.java
│ └── getConfigurationTests.java
├── destinations/
│ ├── amqp091destination/
│ ├── amqp1destination/
│ ├── httpdestination/
│ ├── kafkadestination/
│ ├── mqtt3destination/
│ └── mqtt5destination/
├── matchers/
│ ├── globmatcher/
│ ├── listmatcher/
│ ├── regexmatcher/
│ └── sqlmatcher/
├── provider/
├── providerfactory/
├── retries/
│ └── retryutils/
├── routes/
│ ├── route/
│ └── routeutils/
├── serializers/
│ ├── cborserializer/
│ ├── csvserializer/
│ ├── jsonserializer/
│ ├── propertiesserializer/
│ ├── smileserializer/
│ ├── tomlserializer/
│ ├── xmlserializer/
│ └── yamlserializer/
└── utils/
├── certificateutils/
├── configurationutils/
├── destinationutils/
├── executorutils/
├── fileutils/
├── iocutils/
├── matcherutils/
├── metricsutils/
├── retryutils/
├── routeutils/
├── serializerutils/
├── templateutils/
└── validationutils/
2. File Naming¶
2.1 Pattern¶
RULE: {methodName}Tests.java (camelCase, suffix Tests)
2.2 Examples¶
| Method Being Tested | File Name |
|---|---|
close() |
closeTests.java |
constructor |
constructorTests.java |
serialize() |
serializeTests.java |
accept() |
acceptTests.java |
setConfiguration() |
setConfigurationTests.java |
getConfiguration() |
getConfigurationTests.java |
createEventExecutor() |
createEventExecutorTests.java |
shutdownExecutor() |
shutdownExecutorTests.java |
getSslContext() |
getSslContextTests.java |
requireNonNull() |
requireNonNullTests.java |
tryParseInt() |
tryParseIntTests.java |
isEmpty() for Array |
isEmpty_ArrayTests.java |
isEmpty() for Collection |
isEmpty_CollectionTests.java |
requireGreaterThan() for int |
requireGreaterThan_intTests.java |
requireGreaterThan() for long |
requireGreaterThan_longTests.java |
2.3 Overloaded Method Naming¶
RULE: For overloaded methods, append _{paramketerType} or _{distinguisher} to the file name.
isEmpty(Object[]) → isEmpty_ArrayTests.java
isEmpty(byte[]) → isEmpty_ByteArrayTests.java
isEmpty(Collection<?>) → isEmpty_CollectionTests.java
isEmpty(Map<?,?>) → isEmpty_MapTests.java
isEmpty(String) → isEmpty_StringTests.java
requireInRange(int) → requireInRange_intTests.java
requireInRange(long) → requireInRange_longTests.java
3. Package Declaration¶
3.1 Pattern¶
RULE: Package name matches the folder structure exactly, with class-under-test folder name matching folder case.
3.2 Standard Examples¶
// For providerfactory/closeTests.java
package io.github.fortunen.kete.providerfactory;
// For routes/route/acceptTests.java
package io.github.fortunen.kete.routes.route;
// For utils/validationutils/requireNonNullTests.java
package io.github.fortunen.kete.utils.validationutils;
// For serializers/jsonserializer/serializeTests.java
package io.github.fortunen.kete.serializers.jsonserializer;
// For destinations/httpdestination/setConfigurationTests.java
package io.github.fortunen.kete.destinations.httpdestination;
3.3 Utility Class Package Convention¶
RULE: All utility test packages use lowercase subfolder names matching the convention:
// For retries/retryutils/getRetryTests.java
package io.github.fortunen.kete.unittests.retries.retryutils;
// For routes/routeutils/getRouteTests.java
package io.github.fortunen.kete.unittests.routes.routeutils;
// For matchers/matcherutils/getMatchersTests.java
package io.github.fortunen.kete.unittests.matchers.matcherutils;
// For utils/tlsutils/getSslContextTests.java
package io.github.fortunen.kete.unittests.utils.tlsutils;
NOTE: Always use lowercase for package names to maintain consistency across the codebase.
4. Imports¶
4.1 Static Imports (Always First)¶
RULE: Use static imports with wildcards for test utilities. Order: AssertJ → Mockito.
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
4.2 Common Static Import Combinations¶
// Minimum for simple tests
import static org.assertj.core.api.Assertions.assertThat;
// For exception testing
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
// For exception-free testing
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
// For exception type + message testing
import static org.assertj.core.api.Assertions.assertThatThrownBy;
// For mocking
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mockStatic;
// For matchers
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
4.3 Regular Imports¶
RULE: Standard imports follow static imports. Group by: project classes → external libraries → JDK.
// Project imports
import io.github.fortunen.kete.ProviderFactory;
import io.github.fortunen.kete.utils.ValidationUtils;
// External library imports
import org.apache.commons.configuration2.MapConfiguration;
import org.junit.jupiter.api.Test;
import org.keycloak.events.Event;
// JDK imports
import java.util.HashMap;
import java.util.Map;
5. Class Declaration¶
5.1 Class Visibility¶
RULE: Class visibility is MIXED. Both patterns exist:
// Package-private (no modifier) - COMMON
class closeTests {
// Public - COMMON
public class serializeTests {
public class requireNonNullTests {
public class acceptTests {
OBSERVATION: No clear pattern. Both are acceptable.
5.2 Class Name¶
RULE: Class name matches the file name exactly (camelCase starting lowercase + Tests).
class closeTests { }
class constructorTests { }
public class serializeTests { }
public class requireNonNullTests { }
public class acceptTests { }
5.3 No Class Annotations¶
RULE: Test classes have NO annotations. No @ToString, no @DisplayName, no @TestInstance.
6. Test Method Declaration¶
6.1 Visibility¶
RULE: Method visibility is MIXED. Both patterns exist:
// Package-private (no modifier) - COMMON
@Test
void shouldShutdownEventExecutorOnClose() {
// Public - COMMON
@Test
public void shouldSerializeEventToJson() {
OBSERVATION: Package-private tends to appear in providerfactory/, provider/, configuration/. Public tends to appear in utils/, serializers/, filters/, destinations/.
6.2 Method Naming Pattern¶
RULE: should{ExpectedBehavior}[When{Condition}]
6.3 Examples by Category¶
Success Cases:
shouldCreateInstanceWithNoArgConstructor()
shouldInitializeContainersAfterConstruction()
shouldReturnValueWhenNotNull()
shouldParsePositiveInteger()
shouldMatchExactString()
shouldSetBasicConfiguration()
shouldSerializeEventToJson()
shouldAcceptWhenFiltersEmpty()
shouldSendEvent()
shouldShutdownEventExecutorOnClose()
Failure/Edge Cases:
shouldThrowWhenConfigurationIsNull()
shouldThrowWhenUrlMissing()
shouldThrowWhenNameIsNull()
shouldThrowWhenNameIsEmpty()
shouldThrowWhenNameIsBlank()
shouldReturnEmptyForNull()
shouldReturnEmptyForEmptyString()
shouldReturnEmptyForBlankString()
shouldReturnEmptyForInvalidString()
shouldNotMatchDifferentString()
shouldRejectWhenAnyFilterRejects()
shouldNotSendWhenEventTypeNull()
shouldNotSendWhenEventTypeEmpty()
shouldNotSendWhenEventBodyEmpty()
Conditional Cases:
shouldInvertMatchWithNot()
shouldAllowMultipleCloseCalls()
shouldHandleEmptyPattern()
shouldHandleEmptyEventType()
shouldHandleEventHavingNullType()
shouldResizeThreadPoolWhenConfiguredDifferently()
shouldDisposeRouteOnInitializationFailure()
7. Test Method Structure (AAA Pattern)¶
7.1 Required Sections¶
RULE: Every test method MUST use AAA comments. This structure is MANDATORY and RELIGIOUSLY adhered to without exceptions.
7.2 Standard Structure¶
@Test
void shouldDoSomething() {
// arrange
var instance = new ClassUnderTest();
var input = "test-input";
// act
var result = instance.methodUnderTest(input);
// assert
assertThat(result).isNotNull();
}
7.3 Whitespace Rules (STRICT - NO EXCEPTIONS)¶
| Location | Rule | Mandatory |
|---|---|---|
After method opening brace { |
ONE blank line | |
BEFORE // arrange comment |
ONE blank line (the one after {) |
|
AFTER // arrange comment |
ONE blank line | |
BEFORE // act comment |
ONE blank line | |
AFTER // act comment |
ONE blank line | |
BEFORE // assert comment |
ONE blank line | |
AFTER // assert comment |
ONE blank line | |
Before method closing brace } |
ZERO blank lines |
7.4 Visual Template (Copy This Exactly)¶
@Test
public void shouldDescribeExpectedBehavior() {
// ← blank line after {
// arrange
// ← blank line after // arrange
var instance = new ClassUnderTest();
var input = "test-input";
// ← blank line before // act
// act
// ← blank line after // act
var result = instance.methodUnderTest(input);
// ← blank line before // assert
// assert
// ← blank line after // assert
assertThat(result).isNotNull();
} // ← NO blank line before }
7.4 Section Variations¶
Arrange Only (for constructor tests without method call):
@Test
void shouldCreateInstanceWithNoArgConstructor() {
// act
var instance = new ClassUnderTest();
// assert
assertThat(instance).isNotNull();
}
Act + Assert Combined:
@Test
void shouldDisposeContainerOnClose() {
// arrange
var factory = new ProviderFactory();
// act & assert
assertThatCode(() -> factory.close()).doesNotThrowAnyException();
}
Act & Assert for Exceptions:
@Test
public void shouldThrowWhenUrlMissing() {
// arrange
var destination = new HttpDestination(templateUtils, configUtils);
var config = new MapConfiguration(new HashMap<>());
// act
var thrown = catchThrowable(() -> {
destination.setConfiguration(config);
});
// assert
assertThat(thrown).isInstanceOf(Exception.class);
}
7.5 Cleanup Section (Rare)¶
@Test
void shouldCreateExecutorWithSpecifiedPoolSize() {
// act
ExecutorService executor = ExecutorUtils.createEventExecutor(5);
// assert
assertThat(executor).isNotNull();
// cleanup
executor.shutdownNow();
}
8. Variable Declarations¶
8.1 Primary Pattern: var¶
RULE: Use var for ALL local variable declarations when type is inferrable.
var factory = new ProviderFactory();
var destination = mock(Destination.class);
var config = new MapConfiguration(new HashMap<>());
var result = instance.methodUnderTest();
var thrown = catchThrowable(() -> { ... });
8.2 Explicit Type Exceptions¶
RULE: Use explicit type when: 1. Type cannot be inferred (null assignment) 2. Explicit type provides clarity for complex generics 3. Array initialization
String value = null;
MapConfiguration config = null;
ExecutorService executor = ExecutorUtils.createEventExecutor(5);
Event event = null;
8.3 Static Fields¶
RULE: Static fields use explicit types with private static final.
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final byte[] EMPTY_BYTES = new byte[0];
9. Assertions (AssertJ)¶
9.1 Basic Assertions¶
// Not null
assertThat(result).isNotNull();
// Null
assertThat(result).isNull();
// Equality
assertThat(result).isEqualTo(expected);
assertThat(result).isNotEqualTo(unexpected);
// Same instance
assertThat(result).isSameAs(original);
// Boolean
assertThat(result).isTrue();
assertThat(result).isFalse();
// Empty collections/strings
assertThat(result).isEmpty();
assertThat(result).isNotEmpty();
// Size
assertThat(result).hasSize(5);
assertThat(result.length).isEqualTo(3);
9.2 String Assertions¶
assertThat(result).isEqualTo("expected");
assertThat(result).startsWith("prefix");
assertThat(result).contains("substring");
assertThat(result.contains("text")).isTrue();
9.3 Collection Assertions¶
assertThat(list).hasSize(3);
assertThat(array.length).isEqualTo(0);
assertThat(result).isNotNull();
assertThat(result.length).isEqualTo(0);
9.4 Optional Assertions¶
assertThat(ValidationUtils.tryParseInt("123")).hasValue(123);
assertThat(ValidationUtils.tryParseInt("abc")).isEmpty();
9.5 Exception Assertions¶
Pattern 1: catchThrowable + assert (MOST COMMON)
var thrown = catchThrowable(() -> {
instance.methodThatThrows(badInput);
});
assertThat(thrown).isInstanceOf(IllegalStateException.class);
assertThat(thrown.getMessage()).isEqualTo("expected message");
assertThat(thrown.getMessage()).contains("partial text");
Pattern 2: assertThatThrownBy (for concise exception + message)
assertThatThrownBy(() -> factory.onEvent(null))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("providerEvent is required");
Pattern 3: assertThatCode for no-exception verification
9.6 Compound Boolean in Assertions¶
assertThat(result.length > 0).isTrue();
assertThat(json.contains("\\n") || json.contains("\\t")).isTrue();
10. Mocking (Mockito)¶
10.1 Creating Mocks¶
RULE: Always use mock() method, never @Mock annotations.
var session = mock(KeycloakSession.class);
var destination = mock(Destination.class);
var filter = mock(Filter.class);
10.2 Stubbing¶
// Return value
when(session.realms()).thenReturn(realmProvider);
when(filter.accept(anyString())).thenReturn(true);
// Chain stubbing
when(configUtils.getConfiguration(any())).thenReturn(configuration);
when(configuration.isMetricsEnabled()).thenReturn(true);
// Throw exception
doThrow(new RuntimeException("init failed")).when(route).initialize();
10.3 Verification¶
// Verify called once
verify(destination).sendMessage(any(EventMessage.class));
// Verify called specific times
verify(destination, times(1)).sendMessage(any());
verify(mockTransaction, times(3)).addEvent(any(Event.class));
// Verify never called
verify(destination, never()).sendMessage(any(EventMessage.class));
// Verify with argument matcher
verify(destination, times(1)).sendMessage(argThat(m ->
"eventId".equals(m.eventId()) &&
"LOGIN".equals(m.eventType())));
verify(mockTransaction, times(1)).addEvent(ArgumentMatchers.argThat(arg ->
arg != null && "test-id".equals(arg.getId())
));
10.4 Static Mocking¶
try (var mockedUtils = mockStatic(KeycloakModelUtils.class)) {
mockedUtils.when(() -> KeycloakModelUtils.runJobInTransaction(any(), any()))
.thenAnswer(invocation -> null);
// test code here
mockedUtils.verify(() -> KeycloakModelUtils.runJobInTransaction(sessionFactory, factory));
}
11. Test Data Creation¶
11.1 Inline Object Creation¶
RULE: Create test data inline in the arrange section. No shared fixtures.
// Simple objects
var event = new Event();
event.setType(EventType.LOGIN);
event.setRealmId("test-realm");
// Maps for configuration
var config = new MapConfiguration(Map.of(
"url", "http://localhost:8080/events",
"method", "POST"
));
// Or with HashMap for mutable maps
var map = new HashMap<String, Object>();
map.put(RetryUtils.ENABLED, true);
var config = new MapConfiguration(map);
11.2 Helper Methods¶
RULE: Private helper methods within test class for complex or repeated setup.
private EventMessage createMessage(String eventId, String realm, boolean isAdminEvent,
String eventType, String contentType, byte[] eventBody,
String resourceType, String operationType) {
return EventMessage.get()
.eventId(eventId != null ? eventId : "")
.realm(realm != null ? realm : "")
.isAdminEvent(isAdminEvent)
.eventType(eventType != null ? eventType : "")
.contentType(contentType != null ? contentType : "")
.eventBody(eventBody != null ? eventBody : EMPTY_BYTES)
.resourceType(resourceType != null ? resourceType : "")
.operationType(operationType != null ? operationType : "");
}
11.3 Private Inner Classes¶
RULE: Use private static inner classes for test-specific types.
private static class ComplexObject {
String data;
int number;
ComplexObject(String data, int number) {
this.data = data;
this.number = number;
}
}
private static class CustomCheckedException extends Exception {
public CustomCheckedException(String message) {
super(message);
}
}
private static class TestJsonProcessingException extends JsonProcessingException {
protected TestJsonProcessingException(String msg) {
super(msg);
}
}
12. Test Organization Within File¶
12.1 Logical Grouping¶
RULE: Group related tests. Use comment dividers for sections in large files.
// =========================================================================
// ID
// =========================================================================
@Test
public void shouldHaveCorrectId() { ... }
// =========================================================================
// Header Constants
// =========================================================================
@Test
public void shouldHaveCorrectEventIdHeader() { ... }
@Test
public void shouldHaveCorrectEventTypeHeader() { ... }
12.2 Test Order¶
PATTERN: Happy path first, then edge cases, then error cases.
- Basic success cases
- Variations of success cases
- Empty/null handling (returns default)
- Error cases (throws exception)
- Edge cases
13. Comments¶
13.1 Allowed Comments¶
| Type | Example | Allowed |
|---|---|---|
| AAA section markers | // arrange |
YES (REQUIRED) |
| Combined section | // act & assert |
YES |
| Section dividers | // ========= |
YES |
| Cleanup section | // cleanup |
YES |
| Brief clarification in assert | // assert - Jackson serializes null to "null" |
YES |
13.2 Prohibited Comments¶
| Type | Allowed |
|---|---|
| Method-level Javadoc | NO |
| Inline explanatory comments | NO |
| TODO comments | NO |
| Implementation detail comments | NO |
14. Exception Testing Patterns¶
14.1 Null Paramketer Tests¶
@Test
public void shouldThrowWhenNameNull() {
// arrange
var utils = new SomeUtils();
// act
var thrown = catchThrowable(() -> {
utils.method(null, validParam);
});
// assert
assertThat(thrown).isInstanceOf(IllegalStateException.class);
}
14.2 Empty/Blank String Tests¶
@Test
public void shouldThrowWhenNameEmpty() {
// ...
utils.method("", validParam);
// ...
}
@Test
public void shouldThrowWhenNameBlank() {
// ...
utils.method(" ", validParam);
// ...
}
14.3 Exception Message Verification¶
// Exact message
assertThat(thrown.getMessage()).isEqualTo("name is required");
// Contains
assertThat(thrown.getMessage()).contains("required");
assertThatThrownBy(() -> ...).hasMessageContaining("is required");
15. Complete Test File Template¶
package io.github.fortunen.kete.{category}.{classname};
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.github.fortunen.kete.{ClassUnderTest};
import org.junit.jupiter.api.Test;
public class {methodName}Tests {
@Test
public void shouldSucceedWithValidInput() {
// arrange
var instance = new ClassUnderTest();
var input = "valid-input";
// act
var result = instance.methodUnderTest(input);
// assert
assertThat(result).isNotNull();
assertThat(result).isEqualTo("expected");
}
@Test
public void shouldThrowWhenInputNull() {
// arrange
var instance = new ClassUnderTest();
// act
var thrown = catchThrowable(() -> {
instance.methodUnderTest(null);
});
// assert
assertThat(thrown).isInstanceOf(IllegalStateException.class);
assertThat(thrown.getMessage()).contains("input is required");
}
@Test
public void shouldReturnDefaultWhenInputEmpty() {
// arrange
var instance = new ClassUnderTest();
// act
var result = instance.methodUnderTest("");
// assert
assertThat(result).isEmpty();
}
}
16. Anti-Patterns (NEVER USE)¶
| Anti-Pattern | Reason |
|---|---|
@Mock annotations |
Use mock() method instead |
@InjectMocks |
Manual dependency injection |
@BeforeEach / @AfterEach |
Inline setup in each test (for unit tests) |
@DisplayName |
Method name is self-documenting |
@ToString on test class |
Not needed |
@Nested classes |
One file per method |
| JUnit 5 assertions | Use AssertJ |
| Shared mutable state | Each test independent |
| Test fixtures / builders | Inline data creation |
@ParamketerizedTest |
Separate explicit tests |
| Comments explaining code | Self-documenting tests |
| Testing constants/static fields | No utility - values are self-evident |
| Testing abstract classes directly | Test concrete implementations instead |
| Testing Lombok-generated constructors | Only test manually-written constructors with logic |
| Testing Lombok-generated getters/setters | Only test manually-written accessors with logic |
17. Checklist for New Test Files¶
- [ ] Package matches folder structure
- [ ] File name:
{methodName}Tests.java - [ ] Class name matches file name
- [ ] Static imports for AssertJ and Mockito
- [ ] Each test uses AAA comments
- [ ] Blank line after each section comment
- [ ] Use
varfor local variables - [ ] Method name starts with
should - [ ] No
@BeforeEach/@AfterEach(inline setup) - [ ] No class-level annotations
- [ ] Use
catchThrowablefor exception testing - [ ] Use
assertThat()from AssertJ - [ ] Use
mock()not@Mock - [ ] Test data created inline
18. File Count by Category¶
| Category | Files |
|---|---|
| utils/validationutils/ | 68 |
| serializers/ | 16 |
| matchers/ | 8 |
| providerfactory/ | 9 |
| destinations/ | 10 |
| routes/ | 7 |
| provider/ | 5 |
| configuration/ | 3 |
| retries/ | 2 |
| utils/ (other) | 12 |
| Total | 140 |
19. Testing Philosophy & Quality Standards¶
19.1 Core Principles¶
Tests serve as living documentation of the system. They must:
- Document Behavior: Tests can be read as a book when in doubt about what the code is supposed to do
- Protect Against Regression: Guard the current system against future corruption if maintainers are not careful
- Ensure Quality: Verify the correctness of the current system
- Provide Maximum Confidence: Cover edge cases not just for coverage metrics, but for real-world confidence
19.2 Test Utility Requirements¶
RULE: Every test MUST have actual utility. No contrived scenarios.
| Requirement | Description |
|---|---|
| Real-world scenarios | Test realistic usage patterns, not artificial constructs |
| Meaningful assertions | Assert outcomes that matter to the system's behavior |
| Edge case exploration | Cover boundary conditions that could cause failures in production |
| Failure mode testing | Verify graceful handling of error conditions |
19.3 Assertion Completeness¶
RULE: Assert the FULL state of the outcome. Partial assertions are insufficient.
// BAD - Partial assertion
assertThat(result).isNotNull();
// GOOD - Complete state verification
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo("expected-id");
assertThat(result.getName()).isEqualTo("expected-name");
assertThat(result.isEnabled()).isTrue();
assertThat(result.getItems()).hasSize(3);
assertThat(result.getItems()).containsExactly("item1", "item2", "item3");
19.4 Exception Message Assertion¶
RULE: EXACT exception messages MUST be asserted. This documents the API contract.
// BAD - Only checking exception type
assertThat(thrown).isInstanceOf(IllegalStateException.class);
// BAD - Partial message check
assertThat(thrown.getMessage()).contains("required");
// GOOD - Exact message assertion
assertThat(thrown)
.isInstanceOf(IllegalStateException.class)
.hasMessage("url is required");
19.5 AssertJ Expressiveness¶
WHY AssertJ: Chosen for its expressiveness (inspired by Shouldly in C#). Leverage the fluent API for clarity.
// Leverage AssertJ's expressive assertions
assertThat(result)
.isNotNull()
.isInstanceOf(Configuration.class)
.extracting(Configuration::isMetricsEnabled)
.isEqualTo(false);
assertThat(routes)
.hasSize(3)
.extracting(Route::getName)
.containsExactlyInAnyOrder("route-1", "route-2", "route-3");
assertThat(exception)
.isInstanceOf(IllegalStateException.class)
.hasMessage("bootstrap.servers is required")
.hasNoCause();
20. Serializer Testing Requirements¶
20.1 Round-Trip Verification¶
RULE: Serializer tests MUST parse the result to create a copy that is 100% compared to the input data.
@Test
public void shouldSerializeAndDeserializeEventWithAllFields() {
// arrange
var serializer = new JsonSerializer();
var event = new Event();
event.setId("test-id");
event.setType(EventType.LOGIN);
event.setRealmId("test-realm");
event.setUserId("test-user");
event.setClientId("test-client");
event.setSessionId("test-session");
event.setIpAddress("192.168.1.1");
event.setTime(1704067200000L);
event.setDetails(Map.of("key1", "value1", "key2", "value2"));
// act
var serialized = serializer.serialize(event);
// assert - Parse back and verify Complete equality
var parsed = MAPPER.readTree(serialized);
assertThat(parsed.get("id").asText()).isEqualTo("test-id");
assertThat(parsed.get("type").asText()).isEqualTo("LOGIN");
assertThat(parsed.get("realmId").asText()).isEqualTo("test-realm");
assertThat(parsed.get("userId").asText()).isEqualTo("test-user");
assertThat(parsed.get("clientId").asText()).isEqualTo("test-client");
assertThat(parsed.get("sessionId").asText()).isEqualTo("test-session");
assertThat(parsed.get("ipAddress").asText()).isEqualTo("192.168.1.1");
assertThat(parsed.get("time").asLong()).isEqualTo(1704067200000L);
assertThat(parsed.get("details").get("key1").asText()).isEqualTo("value1");
assertThat(parsed.get("details").get("key2").asText()).isEqualTo("value2");
}
20.2 Edge Cases for Serializers¶
| Edge Case | Must Test |
|---|---|
| All fields populated | |
| All fields null | |
| Empty strings | |
| Unicode characters | |
| Special characters (newlines, tabs, quotes) | |
| Very long strings | |
| Nested structures | |
| Null event/adminEvent object |
21. Destination Testing Requirements¶
21.1 Unit Test Scope for Destinations¶
UNDERSTANDING: Integration tests achieve maximum effect for destinations, but unit tests MUST cover:
| Scenario | Unit Testable | Approach |
|---|---|---|
| Configuration validation | Test setConfiguration with invalid/missing values | |
| Constructor behavior | Verify dependencies are set | |
| Connection failure handling | Mock connection factories to throw | |
| Message send when disconnected | Mock clients in error state | |
| Resource cleanup on close | Verify close methods called on mocks | |
| Retry behavior | Mock failures, verify retry invocation |
21.2 Unconnected Destination Behavior¶
RULE: Test behavior when destinations cannot connect (no RabbitMQ, Kafka, etc.)
@Test
public void shouldThrowWhenBrokerUnreachable() {
// arrange
var destination = new KafkaDestination(templateUtils, configUtils);
var config = new MapConfiguration(Map.of(
"bootstrap.servers", "unreachable:9092",
"topic", "test-topic"
));
destination.setConfiguration(config);
// act
var thrown = catchThrowable(() -> destination.initialize());
// assert
assertThat(thrown)
.isInstanceOf(KafkaException.class)
.hasMessageContaining("Failed to connect");
}
21.3 HTTP Destination - WireMock Testing¶
RULE: Use MockWebServer or WireMock for full HTTP destination testing in unit tests.
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.RecordedRequest;
public class sendMessageTests {
private MockWebServer mockServer;
@BeforeEach
void setUp() throws Exception {
mockServer = new MockWebServer();
mockServer.start();
}
@AfterEach
void tearDown() throws Exception {
mockServer.shutdown();
}
@Test
public void shouldSendPostRequestWithEventBody() throws Exception {
// arrange
mockServer.enqueue(new MockResponse().setResponseCode(200));
var destination = new HttpDestination(templateUtils, configUtils);
var config = new MapConfiguration(Map.of(
"url", mockServer.url("/events").toString(),
"method", "POST"
));
destination.setConfiguration(config);
destination.initialize();
var message = EventMessage.get()
.eventId("test-id")
.eventType("LOGIN")
.contentType("application/json")
.eventBody("{\"type\":\"LOGIN\"}".getBytes());
// act
destination.sendMessage(message);
// assert
RecordedRequest request = mockServer.takeRequest();
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getPath()).isEqualTo("/events");
assertThat(request.getBody().readUtf8()).isEqualTo("{\"type\":\"LOGIN\"}");
assertThat(request.getHeader("Content-Type")).isEqualTo("application/json");
assertThat(request.getHeader("EventId")).isEqualTo("test-id");
assertThat(request.getHeader("EventType")).isEqualTo("LOGIN");
}
@Test
public void shouldHandleServerError() throws Exception {
// arrange
mockServer.enqueue(new MockResponse().setResponseCode(500).setBody("Internal Error"));
var destination = new HttpDestination(templateUtils, configUtils);
// ... setup ...
// act
var thrown = catchThrowable(() -> destination.sendMessage(message));
// assert
assertThat(thrown)
.isInstanceOf(HttpResponseException.class)
.hasMessage("HTTP 500: Internal Error");
}
}
NOTE: For HTTP destinations, @BeforeEach and @AfterEach ARE allowed for MockWebServer lifecycle management.
22. Edge Case Testing Requirements¶
22.1 Mandatory Edge Cases¶
Every method under test MUST have tests for:
| Category | Cases |
|---|---|
| Null handling | null input, null in collections, null nested objects |
| Empty values | empty string "", empty collections, empty arrays |
| Blank values | whitespace-only strings " " |
| Boundary values | 0, -1, Integer.MAX_VALUE, Integer.MIN_VALUE |
| Invalid formats | malformed URLs, invalid JSON, unparseable numbers |
| Concurrent access | thread safety (where applicable) |
| Resource exhaustion | very large inputs, many items |
22.2 Example: Comprehensive Validation Testing¶
// Test NULL
@Test
public void shouldThrowWhenValueNull() { ... }
// Test EMPTY
@Test
public void shouldThrowWhenValueEmpty() { ... }
// Test BLANK
@Test
public void shouldThrowWhenValueBlank() { ... }
// Test VALID edge case
@Test
public void shouldAcceptSingleCharacterValue() { ... }
// Test BOUNDARY
@Test
public void shouldHandleMaxLengthValue() { ... }
// Test UNICODE
@Test
public void shouldHandleUnicodeCharacters() { ... }
// Test SPECIAL CHARS
@Test
public void shouldHandleSpecialCharacters() { ... }
23. Test as Documentation¶
23.1 Reading Tests Like a Book¶
Tests MUST be written so that a reader can understand the system's expected behavior without reading source code.
@Test
public void shouldRejectEventWhenAllFiltersReject() {
// This test documents: When multiple matchers are configured,
// ALL must accept for the event to pass. If ANY rejects, the event is rejected.
// arrange
var rejectFilter1 = mock(Filter.class);
var rejectFilter2 = mock(Filter.class);
when(rejectFilter1.accept(anyString())).thenReturn(false);
when(rejectFilter2.accept(anyString())).thenReturn(false);
var route = new Route();
route.setFilters(new Filter[] { rejectFilter1, rejectFilter2 });
// act
var accepted = route.accept("LOGIN");
// assert
assertThat(accepted)
.as("Event should be rejected when any filter rejects")
.isFalse();
}
23.2 Test Naming as Documentation¶
Method names describe behavior, not implementation:
// BAD - Describes implementation
shouldCallValidateMethod()
shouldSetFieldToNull()
// GOOD - Describes behavior
shouldRejectInvalidUrl()
shouldDefaultToTenSecondsWhenTimeoutNotSpecified()
shouldRetryThreeTimesBeforeFailingPermanently()
24. Regression Protection¶
24.1 Purpose¶
Tests exist to protect against future corruption. Every test should:
- Verify a specific behavior that MUST NOT change
- Fail loudly if a maintainer inadvertently breaks the contract
- Provide clear error messages showing what was expected vs. what happened
24.2 Contract Verification¶
@Test
public void shouldMaintainBackwardCompatibleDefaults() {
// This test protects the default configuration contract.
// Changing these defaults would break existing deployments.
// act
var config = new Configuration();
// assert - These are the documented defaults. DO NOT CHANGE.
assertThat(config.isMetricsEnabled())
.as("Metrics must be disabled by default")
.isFalse();
assertThat(config.getRoutes())
.as("Routes must default to empty array")
.isEmpty();
}
25. Checklist for Comprehensive Tests¶
25.1 Per-Test Checklist¶
- [ ] Test has actual utility (not contrived)
- [ ] Blank line after method opening
{ - [ ]
// arrangewith blank line before and after - [ ]
// actwith blank line before and after - [ ]
// assertwith blank line before and after - [ ] No blank line before closing
} - [ ] Full state of outcome is asserted
- [ ] Exact exception messages are verified
- [ ] AssertJ fluent API is leveraged for expressiveness
25.2 Per-Class Checklist¶
- [ ] Happy path tested
- [ ] Null inputs tested
- [ ] Empty inputs tested
- [ ] Blank string inputs tested (where applicable)
- [ ] Boundary values tested
- [ ] Error conditions tested with exact messages
- [ ] Tests read as documentation of expected behavior
25.3 Serializer-Specific Checklist¶
- [ ] Round-trip verification (serialize → parse → compare)
- [ ] All fields populated test
- [ ] All fields null test
- [ ] Unicode characters test
- [ ] Special characters test
25.4 Destination-Specific Checklist¶
- [ ] Configuration validation tests
- [ ] Unconnected/unreachable broker tests
- [ ] Connection failure handling tests
- [ ] HTTP: MockWebServer tests for full request/response verification