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()); // Less readable
assertTrue(plugins.size() == 3); // Less descriptive failures
Test Structure (AAA Pattern)
Always use Arrange-Act-Assert pattern:
@Test
@DisplayName("Should load plugin from valid directory")
void shouldLoadPluginFromValidDirectory() {
// Arrange - Setup test data and dependencies
Path pluginDir = Paths.get("test-plugins/valid-plugin");
PluginLoader loader = new DefaultPluginLoader();
// Act - Execute the behavior being tested
Plugin plugin = loader.load(pluginDir);
// Assert - Verify results
assertThat(plugin)
.isNotNull()
.extracting(Plugin::getId, Plugin::getVersion)
.containsExactly("test-plugin", "1.0.0");
}
Naming Conventions
Test class names
// Class under test: PluginManager
PluginManagerTest // ✅ Simple, standard
PluginManagerShould // ✅ BDD style (if team prefers)
TestPluginManager // ❌ Avoid
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
// Basic collection checks
assertThat(plugins)
.isNotEmpty()
.hasSize(2)
.doesNotContainNull();
// Advanced filtering and extraction
assertThat(plugins)
.filteredOn(p -> p.getState() == PluginState.STARTED)
.extracting(Plugin::getId)
.containsExactlyInAnyOrder("plugin-a", "plugin-b");
// All elements match condition
assertThat(plugins)
.allMatch(p -> p.getVersion() != null, "All plugins have version");
Exception assertions
// Basic exception check
assertThatThrownBy(() -> loader.load(invalidPath))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Invalid plugin descriptor");
// Detailed exception verification
assertThatThrownBy(() -> manager.startPlugin("missing-plugin"))
.isInstanceOf(PluginException.class)
.hasMessageContaining("Plugin not found")
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasNoCause(); // or verify cause chain
// With assertThatExceptionOfType (more readable)
assertThatExceptionOfType(PluginException.class)
.isThrownBy(() -> loader.load(invalidPath))
.withMessageContaining("Invalid")
.withMessageMatching("Invalid .* descriptor");
Object assertions
// Extract and verify multiple properties
assertThat(plugin)
.isNotNull()
.extracting("id", "version", "state")
.containsExactly("my-plugin", "1.0", PluginState.STARTED);
// Using method references (type-safe)
assertThat(plugin)
.extracting(Plugin::getId, Plugin::getVersion, Plugin::getState)
.containsExactly("my-plugin", "1.0", PluginState.STARTED);
// Field by field comparison
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(); // All assertions evaluated, even if some fail
}
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 implementation
}
@Test
@DisplayName("should skip disabled plugins")
void shouldSkipDisabledPlugins() {
// Test implementation
}
@Test
@DisplayName("should fail if circular dependency detected")
void shouldFailOnCircularDependency() {
// Test implementation
}
}
@Nested
@DisplayName("when stopping plugins")
class WhenStoppingPlugins {
@Test
@DisplayName("should stop plugins in reverse dependency order")
void shouldStopInReverseOrder() {
// Test implementation
}
}
}
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")
v