Introduction to JUnit Tests
JUnit is a simple framework to write and run repeatable tests. It is an open source unit testing framework for Java programming language. JUnit has become a de facto standard for writing and running tests in Java. In JUnit we can write test cases using annotations like @Test annotation to identify methods that contain test cases. We can group related tests into classes and each test method checks for a specific behavior or condition. This article discusses in detail how to write JUnit tests with examples.
Why Write Tests?
Here are some of the key reasons why we write tests:
To prevent regressions: Tests prevent existing code from breaking due to codebase changes like code refactoring, dependency updates etc. Run tests before code changes to identify any regressions.
For documentation: Well-written tests document how code is expected to behave. They communicate behaviour to developers.
Quality check: Tests provide confidence that code meets requirements and works as expected. Failing tests during development help identify bugs early.
Speeds up development: Clearly specified tests speed up development by allowing developers to make changes more fearlessly.
Ease of Refactoring: With tests in place code can be safely refactored without worrying about introducing bugs. Tests help refactor code quickly.
Automated verification: Tests automate verification of requirements. They run easily and frequently as part of build process.
Testing edge/corner cases: Manual testing may miss edge or corner cases but automated tests cover more scenarios.
Basic Building Blocks of JUnit Tests
Some core concepts and annotations used in JUnit testing:
@Test : Marks test methods in test classes. Test methods must be public and void. Test methods contain assertions.
Assertions : Methods like assertEquals(), assertNotNull() etc to validate test conditions. Throws AssertionError if validation fails.
Test Fixtures : Methods annotated with @BeforeEach or @Before run before each test. @AfterEach or @After run after each test.
Exceptions: @Test may throw exceptions. Check exceptions with expected parameter in assertThrows()
Parameterized Tests: Tests with different input data sets annotated with @ParameterizedTest and @MethodSource or @CsvSource
Test Classes: Test classes should end with Test or annotated with @Test to be picked up by test runner.
Suites : @RunWith(Suite.class) to group related test classes into suite for bulk execution
Rules: Provide hooks to perform setup/teardown before/after test methods like @Rule, @ClassRule
Assumptions: Methods like assumeTrue(), assumeFalse() to skip test on failure of assumption rather than failing test.
Structure of a Basic JUnit Test Class
A basic test class consists of:
A class annotated with @Test
Test methods annotated with @Test
Arrange-Act-Assert pattern used within test methods
Imports for JUnit annotations and assert methods
Example:
java
Copy
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
public class MathTests {
@Test
public void testAddition() {
//Arrange
int num1 = 10;
int num2 = 5;
//Act
int result = Math.add(num1, num2);
//Assert
assertEquals(15, result);
}
}
The test class MathTests contains a test method testAddition() that tests the add() method. JUnit picks this up and runs the test.
Tips for Organizing Tests
Here are some tips for organizing test classes and methods:
Group tests by functionality not implementation.
One test class per class/module under test.
Descriptive test names indicate what is being tested.
Small focused test methods test single behavior.
Order tests logically like critical paths first, fast tests first etc.
Group related tests using @Nested and @DisplayName
Separate test data from test logic using @Parameters, @MethodSource etc.
For example, tests for a user registration feature could be organized as:
less
Copy
@Test
class RegistrationTests {
@Nested
class UserCreation {
@Test
void createsUser_WhenValidData() {}
@Test
void rejectsDuplicateEmail() {}
}
@Nested
@DisplayName(“Validation Tests”)
class Validation {
@Test
void rejectsInvalidEmail() {}
//other validation tests
}
}
Testing Edge Cases
Here are some approaches to test edge cases:
Pass boundary values to test valid and invalid condition boundaries.
Try empty/null/invalid inputs to validate robustness of code.
Test different order/combination of inputs.
Simulate error conditions by mocking dependencies to fail.
Add delays/timeouts, test concurrency.
Stress test with huge load/volumes of data.
Test date/time inputs at boundaries like start/end of periods.
For example, to test registration validation:
java
Copy
@ParameterizedTest
@ValueSource(strings={“”, “invalid”})
void rejectsInvalidEmail(String email) {
assertThrows(ValidationException.class,
() -> service.register(email, “password”));
}
Testing Spring Boot Apps with JUnit
For Spring Boot app, we can use Spring Test support in JUnit tests:
@SpringBootTest: Start full app context for integration tests
@WebMvcTest: Partial context for web layer tests
@DataJpaTest: Partial context for repository tests
@MockBean: Inject mocks into app context
@AutoConfigureMockMvc: Auto configure MockMvc
Example:
java
Copy
@SpringBootTest
class UserControllerIT {
@Autowired
private MockMvc mvc;
@Test
void registersUser() throws Exception {
mvc.perform(post(“/register”)
.content(“email=..”))
.andExpect(status().isOk());
}
}
This starts the full app and performs integration test of controller.
Other Tips
Add descriptive test names
Fail fast tests over debugging edge cases
Isolate dependencies for flexibility of test logic
Balance between unit and integration tests
Keep tests independent
Fail tests early on issues
Group related tests logically
Run tests frequently as part of builds
Automate reporting of results
Conclusion
Writing tests makes code more maintainable and evolvable. JUnit provides a simple framework for writing tests in Java. Following best practices of structuring and organizing tests ensures tests remain robust and reliable over time. Regular execution of tests as part of build process ensures code quality. Tests improve developer productivity by enforcing design constraints and prevent regressions during development and refactoring.
