| name | test-quality |
| description | Write high-quality JUnit 5 tests with AssertJ assertions. Use when user says "add tests", "write tests", "improve test coverage", or when reviewing/creating test classes for Java code. |
Test Quality Skill (JUnit 5 + AssertJ)
Write high-quality, maintainable tests for Java projects using modern best practices.
When to Use
- Writing new test classes
- Reviewing/improving existing tests
- User asks to "add tests" / "improve test coverage"
- Code review mentions missing tests
Framework Preferences
JUnit 5 (Jupiter)
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import static org.assertj.core.api.Assertions.*;
AssertJ over standard assertions
✅ Use AssertJ:
assertThat(plugin.getState())
.as("Plugin should be started after initialization")
.isEqualTo(PluginState.STARTED);
assertThat(plugins)
.hasSize(3)
.extracting(Plugin::getId)
.containsExactly("plugin1", "plugin2", "plugin3");
❌ Avoid JUnit assertions:
assertEquals(PluginState.STARTED, plugin.getState());
assertTrue(plugins.size() == 3);
Test Structure (AAA Pattern)
Always use Arrange-Act-Assert pattern:
@Test
@DisplayName("Should load plugin from valid directory")
void shouldLoadPluginFromValidDirectory() {
Path pluginDir = Paths.get("test-plugins/valid-plugin");
PluginLoader loader = new DefaultPluginLoader();
Plugin plugin = loader.load(pluginDir);
assertThat(plugin)
.isNotNull()
.extracting(Plugin::getId, Plugin::getVersion)
.containsExactly("test-plugin", "1.0.0");
}
Naming Conventions
Test class names
PluginManagerTest
PluginManagerShould
TestPluginManager
Test method names
Option 1: should_expectedBehavior_when_condition (descriptive)
@Test
void should_throwException_when_pluginDirectoryNotFound() { }
@Test
void should_returnEmptyList_when_noPluginsAvailable() { }
@Test
void should_loadPluginsInDependencyOrder_when_multipleDependencies() { }
Option 2: Natural language with @DisplayName (cleaner code)
@Test
@DisplayName("Should load all plugins from directory")
void loadAllPlugins() { }
@Test
@DisplayName("Should throw exception when plugin descriptor is invalid")
void invalidPluginDescriptor() { }
AssertJ Power Features
Collection assertions
assertThat(plugins)
.isNotEmpty()
.hasSize(2)
.doesNotContainNull();
assertThat(plugins)
.filteredOn(p -> p.getState() == PluginState.STARTED)
.extracting(Plugin::getId)
.containsExactlyInAnyOrder("plugin-a", "plugin-b");
assertThat(plugins)
.allMatch(p -> p.getVersion() != null, "All plugins have version");
Exception assertions
assertThatThrownBy(() -> loader.load(invalidPath))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Invalid plugin descriptor");
assertThatThrownBy(() -> manager.startPlugin("missing-plugin"))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Plugin not found")
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasNoCause();
assertThatExceptionOfType(PluginException.class)
.isThrownBy(() -> loader.load(invalidPath))
.withMessageContaining("Invalid")
.withMessageMatching("Invalid .* descriptor");
Object assertions
assertThat(plugin)
.isNotNull()
.extracting("id", "version", "state")
.containsExactly("my-plugin", "1.0", PluginState.STARTED);
assertThat(plugin)
.extracting(Plugin::getId, Plugin::getVersion, Plugin::getState)
.containsExactly("my-plugin", "1.0", PluginState.STARTED);
assertThat(actualPlugin)
.usingRecursiveComparison()
.isEqualTo(expectedPlugin);
Soft assertions (multiple checks)
@Test
void shouldHaveValidPluginDescriptor() {
SoftAssertions softly = new SoftAssertions();
softly.assertThat(descriptor.getId())
.as("Plugin ID")
.isNotBlank()
.matches("[a-z0-9-]+");
softly.assertThat(descriptor.getVersion())
.as("Plugin version")
.matches("\\d+\\.\\d+\\.\\d+");
softly.assertThat(descriptor.getDependencies())
.as("Dependencies")
.isNotNull()
.doesNotContainNull();
softly.assertAll();
}
String assertions
assertThat(errorMessage)
.startsWith("Error:")
.contains("plugin", "failed")
.doesNotContain("success")
.matches("Error: .* failed")
.hasLineCount(3);
Test Organization
Nested tests for clarity
@DisplayName("PluginManager")
class PluginManagerTest {
private PluginManager manager;
@BeforeEach
void setUp() {
manager = new DefaultPluginManager();
}
@Nested
@DisplayName("when starting plugins")
class WhenStartingPlugins {
@Test
@DisplayName("should start all plugins in dependency order")
void shouldStartInDependencyOrder() {
}
@Test
@DisplayName("should skip disabled plugins")
void shouldSkipDisabledPlugins() {
}
@Test
@DisplayName("should fail if circular dependency detected")
void shouldFailOnCircularDependency() {
}
}
@Nested
@DisplayName("when stopping plugins")
class WhenStoppingPlugins {
@Test
@DisplayName("should stop plugins in reverse dependency order")
void shouldStopInReverseOrder() {
}
}
}
Parameterized tests
@ParameterizedTest
@ValueSource(strings = {"1.0.0", "2.1.3", "10.0.0-SNAPSHOT"})
@DisplayName("Should accept valid semantic versions")
void shouldAcceptValidVersions(String version) {
assertThat(VersionParser.parse(version))
.isNotNull()
.hasFieldOrPropertyWithValue("valid", true);
}
@ParameterizedTest
@CsvSource({
"plugin-a, 1.0, STARTED",
"plugin-b, 2.0, STOPPED",
"plugin-c, 1.5, DISABLED"
})
@DisplayName("Should load plugin with expected state")
void shouldLoadPluginWithState(String id, String version, PluginState expectedState) {
Plugin plugin = createPlugin(id, version);
assertThat(plugin.getState()).isEqualTo(expectedState);
}
@ParameterizedTest
@MethodSource("invalidPluginDescriptors")
@DisplayName("Should reject invalid plugin descriptors")
void shouldRejectInvalidDescriptors(PluginDescriptor descriptor, String expectedError) {
assertThatThrownBy(() -> validator.validate(descriptor))
.hasMessageContaining(expectedError);
}
static Stream<Arguments> invalidPluginDescriptors() {
return Stream.of(
Arguments.of(descriptorWithoutId(), "Missing plugin ID"),
Arguments.of(descriptorWithInvalidVersion(), "Invalid version format"),
Arguments.of(descriptorWithEmptyId(), "Plugin ID cannot be empty")
);
}
Common Patterns
Testing with mocks (Mockito)
@ExtendWith(MockitoExtension.class)
class PluginManagerTest {
@Mock
private PluginRepository repository;
@Mock
private PluginValidator validator;
@InjectMocks
private DefaultPluginManager manager;
@Test
@DisplayName("Should load plugins from repository")
void shouldLoadPluginsFromRepository() {
List<PluginDescriptor> descriptors = List.of(
createDescriptor("plugin1"),
createDescriptor("plugin2")
);
when(repository.findAll()).thenReturn(descriptors);
List<Plugin> plugins = manager.loadAll();
assertThat(plugins).hasSize(2);
verify(repository).findAll();
verify(validator, times(2)).validate(any(PluginDescriptor.class));
}
}
Test fixtures with @BeforeEach
@BeforeEach
void setUp() throws IOException {
pluginDir = Files.createTempDirectory("test-plugins");
PluginConfig config = PluginConfig.builder()
.pluginDirectory(pluginDir)
.enableValidation(true)
.build();
pluginManager = new DefaultPluginManager(config);
}
@AfterEach
void tearDown() throws IOException {
if (pluginManager != null) {
pluginManager.stopAll();
}
if (pluginDir != null) {
FileUtils.deleteDirectory(pluginDir.toFile());
}
}
Testing async operations
@Test
@DisplayName("Should complete async plugin loading")
void shouldCompleteAsyncLoading() {
CompletableFuture<Plugin> future = manager.loadAsync(pluginPath);
assertThat(future)
.succeedsWithin(Duration.ofSeconds(5))
.satisfies(plugin -> {
assertThat(plugin.getState()).isEqualTo(PluginState.STARTED);
assertThat(plugin.getId()).isNotBlank();
});
}
Token Optimization
When writing tests:
1. Generate test skeleton first
2. Implement incrementally
- One test at a time
- Verify compilation after each
- Run tests to validate
- Refactor if needed
3. Reuse patterns
private Plugin createTestPlugin(String id, String version) {
return Plugin.builder()
.id(id)
.version(version)
.build();
}
Code Coverage Guidelines
- Aim for: 80%+ line coverage on core logic
- Focus on: Business logic, complex algorithms, edge cases
- Skip: Trivial getters/setters, POJOs, generated code
- Test: Happy paths + error conditions + boundary cases
What to test
✅ High priority:
- Public APIs
- Complex business logic
- Error handling
- Edge cases and boundaries
- Integration points
❌ Low priority:
public String getId() { return id; }
public void setId(String id) { this.id = id; }
public class PluginInfo {
private String id;
private String version;
}
Anti-patterns
❌ Avoid:
@Test void test1() { }
@Test void testPlugin() { }
assertThat(plugin.internalState.flag).isTrue();
assertThat(message).isEqualTo("Error at 2024-01-26 10:30:15");
@Test void testEverything() {
assertThat(plugin.getId()).isNotNull();
assertThat(manager.getCount()).isEqualTo(5);
assertThat(config.isEnabled()).isTrue();
}
@Test void shouldFail() {
try {
loader.load(invalidPath);
fail("Should have thrown exception");
} catch (Exception e) {
}
}
✅ Prefer:
@Test
@DisplayName("Should reject plugin with missing dependencies")
void shouldRejectPluginWithMissingDependencies() {
PluginDescriptor descriptor = PluginDescriptor.builder()
.id("test-plugin")
.dependencies(List.of("missing-dep"))
.build();
assertThatThrownBy(() -> manager.load(descriptor))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Missing dependencies: missing-dep");
}
Integration with Coverage Tools
Maven configuration
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
After test generation, suggest:
mvn clean test jacoco:report
open target/site/jacoco/index.html
mvn verify
Quick Reference
assertThat(value).isEqualTo(expected);
assertThat(value).isNotNull();
assertThat(value).isInstanceOf(String.class);
assertThat(number).isPositive().isGreaterThan(5);
assertThat(list).hasSize(3);
assertThat(list).contains(item);
assertThat(list).containsExactly(item1, item2, item3);
assertThat(list).containsExactlyInAnyOrder(item2, item1, item3);
assertThat(list).doesNotContain(item);
assertThat(list).allMatch(predicate);
assertThat(str).isNotBlank();
assertThat(str).startsWith("prefix");
assertThat(str).endsWith("suffix");
assertThat(str).contains("substring");
assertThat(str).matches("regex\\d+");
assertThatThrownBy(() -> code())
.isInstanceOf(PluginException.class)
.hasMessageContaining("error");
assertThatNoException().isThrownBy(() -> code());
assertThat(userId)
.as("User ID should be positive")
.isPositive();
assertThat(actual)
.usingRecursiveComparison()
.ignoringFields("timestamp", "id")
.isEqualTo(expected);
Best Practices Summary
- Use AssertJ for all assertions
- Follow AAA pattern (Arrange-Act-Assert)
- Descriptive names with @DisplayName
- One concept per test
- Test behavior, not implementation
- Extract helpers for common setup
- Use @Nested for logical grouping
- Parameterize similar tests
- Soft assertions for multiple checks
- Coverage on business logic, not boilerplate
References