Agent skill
test-quality
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.
Install this agent skill to your Project
npx add-skill https://github.com/decebals/claude-code-java/tree/main/.claude/skills/test-quality
SKILL.md
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")
void shouldLoadPluginsFromRepository() {
// Given
List<PluginDescriptor> descriptors = List.of(
createDescriptor("plugin1"),
createDescriptor("plugin2")
);
when(repository.findAll()).thenReturn(descriptors);
// When
List<Plugin> plugins = manager.loadAll();
// Then
assertThat(plugins).hasSize(2);
verify(repository).findAll();
verify(validator, times(2)).validate(any(PluginDescriptor.class));
}
}
Test fixtures with @BeforeEach
@BeforeEach
void setUp() throws IOException {
// Create temporary directory for test plugins
pluginDir = Files.createTempDirectory("test-plugins");
// Initialize plugin manager with test config
PluginConfig config = PluginConfig.builder()
.pluginDirectory(pluginDir)
.enableValidation(true)
.build();
pluginManager = new DefaultPluginManager(config);
}
@AfterEach
void tearDown() throws IOException {
// Clean up test resources
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
// Phase 1: List test cases as comments
// @Test void shouldLoadPlugin() { }
// @Test void shouldThrowExceptionForInvalidPlugin() { }
// @Test void shouldHandleMissingDependencies() { }
2. Implement incrementally
- One test at a time
- Verify compilation after each
- Run tests to validate
- Refactor if needed
3. Reuse patterns
// Extract common setup to helper methods
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:
// Simple getters/setters
public String getId() { return id; }
public void setId(String id) { this.id = id; }
// Simple POJOs with no logic
public class PluginInfo {
private String id;
private String version;
// ... only getters/setters
}
Anti-patterns
❌ Avoid:
// 1. Generic test names
@Test void test1() { }
@Test void testPlugin() { }
// 2. Testing implementation details
assertThat(plugin.internalState.flag).isTrue(); // Couples to internals
// 3. Brittle assertions with timestamps
assertThat(message).isEqualTo("Error at 2024-01-26 10:30:15");
// 4. Multiple unrelated assertions
@Test void testEverything() {
// 50 unrelated assertions
assertThat(plugin.getId()).isNotNull();
assertThat(manager.getCount()).isEqualTo(5);
assertThat(config.isEnabled()).isTrue();
// ... mixing multiple concerns
}
// 5. Ignoring exceptions
@Test void shouldFail() {
try {
loader.load(invalidPath);
fail("Should have thrown exception");
} catch (Exception e) {
// Swallowing exception details
}
}
✅ 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:
# Run tests with coverage
mvn clean test jacoco:report
# View coverage report
open target/site/jacoco/index.html
# Check coverage threshold
mvn verify # Fails if below configured threshold
Quick Reference
// ===== Basic Assertions =====
assertThat(value).isEqualTo(expected);
assertThat(value).isNotNull();
assertThat(value).isInstanceOf(String.class);
assertThat(number).isPositive().isGreaterThan(5);
// ===== Collections =====
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);
// ===== Strings =====
assertThat(str).isNotBlank();
assertThat(str).startsWith("prefix");
assertThat(str).endsWith("suffix");
assertThat(str).contains("substring");
assertThat(str).matches("regex\\d+");
// ===== Exceptions =====
assertThatThrownBy(() -> code())
.isInstanceOf(PluginException.class)
.hasMessageContaining("error");
assertThatNoException().isThrownBy(() -> code());
// ===== Custom Descriptions =====
assertThat(userId)
.as("User ID should be positive")
.isPositive();
// ===== Object Comparison =====
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
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
java-code-review
Systematic code review for Java with null safety, exception handling, concurrency, and performance checks. Use when user says "review code", "check this PR", "code review", or before merging changes.
jpa-patterns
JPA/Hibernate patterns and common pitfalls (N+1, lazy loading, transactions, queries). Use when user has JPA performance issues, LazyInitializationException, or asks about entity relationships and fetching strategies.
solid-principles
SOLID principles checklist with Java examples. Use when reviewing classes, refactoring code, or when user asks about Single Responsibility, Open/Closed, Liskov, Interface Segregation, or Dependency Inversion.
design-patterns
Common design patterns with Java examples (Factory, Builder, Strategy, Observer, Decorator, etc.). Use when user asks "implement pattern", "use factory", "strategy pattern", or when designing extensible components.
api-contract-review
Review REST API contracts for HTTP semantics, versioning, backward compatibility, and response consistency. Use when user asks "review API", "check endpoints", "REST review", or before releasing API changes.
git-commit
Generate conventional commit messages for Java projects. Use when user says "commit", "create commit", "commit changes", or after completing code changes that need to be committed.
Didn't find tool you were looking for?