Unit Testing Implementation
Topics to be covered¶
- Understanding the concept of Unit Testing in Java
- Introduction to JUnit, the widely used testing framework
- Industry-standard conventions for writing test cases
- Using the AAA (Arrange-Act-Assert) pattern for structuring tests
- Writing and structuring random test cases
- Deep dive into assertion methods and their applications
- Testing Spring Boot applications using
@SpringBootTest
- Mocking dependencies with Mockito for unit testing
- Best practices for testing service-layer components in Spring
- Handling and testing for exceptions in controller methods
1. Introduction to Unit Testing in Java¶
Unit Testing is a software testing technique in which individual units or components of a software application are tested. A unit is the smallest testable part of any software, usually a function or a method in Java. Unit tests validate that each component of the software functions as expected.
-
In Java, JUnit is the most commonly used library for writing unit tests. It provides simple annotations and methods to structure and execute test cases effectively.
-
The best practice when using JUnit in a project, especially when using Spring, is to organize tests in a separate folder, often named
test
, alongside the main application folder. The directory structure should mirror the structure of the main source code folder to keep tests organized and easy to locate.
Example:
- For a class named
ProductController
in thecontroller
folder ofmain
, a corresponding test class namedProductControllerTest
should be placed in thecontroller
folder withintest
.
2. Writing Random Tests in JUnit¶
In JUnit, each test case is represented by a method, typically annotated with @Test
. These methods contain the logic for checking if the code behaves as expected under certain conditions.
Test cases are generally written following the AAA pattern:
- Arrange: Set up the necessary objects, data, or conditions for the test.
- Act: Perform the actual action that needs to be tested, such as calling a method.
- Assert: Verify that the action performed led to the expected result.
Example: Writing a simple test case:
public class RandomTest {
@Test
void testOnePlusOneIsTwo() {
// Arrange
int i = 1 + 1; // Act
// Assert
assert i == 3; // This test will fail because 1 + 1 != 3
}
}
In the above example, a simple test is performed to check if the sum of 1 + 1 equals 3. The test will fail since the assertion i == 3
is incorrect.
To mark a method as a test case in JUnit, we use the @Test
annotation. Without this annotation, JUnit will not recognize the method as a test. If we change the assertion to assert i == 2
, the test will pass.
- Important Concept: A test case will fail if any of the assertions within that test case fail.
- JUnit also allows more complex assertions, such as checking whether the right type of exception is thrown during execution. For this, libraries like AssertJ can be used.
3. Assertion Methods in JUnit¶
JUnit provides various assertion methods to validate the results of the code being tested. Here’s a list of commonly used assertions:
-
Basic Assertions:
assertEquals(expected, actual)
: Verifies that the expected value matches the actual value.assertNotEquals(expected, actual)
: Confirms that two values are not equal.assertTrue(condition)
: Verifies that a boolean condition is true.assertFalse(condition)
: Checks that a condition is false.assertNull(object)
: Ensures that an object isnull
.
-
Handling Exceptions:
assertThrows(expectedException.class, executable)
: Validates that a specific exception is thrown when a method is executed.
-
Array Comparisons:
assertArrayEquals(expectedArray, actualArray)
: Compares two arrays to ensure they contain the same elements in the same order.
-
Checking Time Constraints:
assertTimeout(Duration.ofSeconds(2), executable)
: Ensures that the test completes within a specific time limit, preventing performance issues.
-
Class Object Assertions:
assertInstanceOf(ExpectedClass.class, object)
: Checks if an object is an instance of a particular class.
Important Rule for Assertions:¶
In any assertion that takes two parameters, the expected value should always come first, followed by the actual value. This helps maintain clarity when reading test results, especially when an assertion fails.
4. Spring Boot Tests¶
Testing Spring Boot applications requires additional setup to properly load and manage the Spring context. When unit testing a component that interacts with Spring, it is crucial to initialize the Spring Boot application context.
To enable this, annotate the test class with @SpringBootTest
. This annotation tells Spring Boot to load the entire application context before running the test, simulating the real behavior of the application during execution.
Example of a basic Spring Boot test class:
@SpringBootTest
class ProductControllerTest {
@Autowired
private ProductController productController;
}
In the above code:
- The
@SpringBootTest
annotation ensures that Spring initializes all necessary components, including controllers, services, and repositories. - The
@Autowired
annotation injects theProductController
instance into the test class, allowing it to be tested.
5. Mocking Dependencies in Unit Tests¶
In real-world applications, components often depend on other services. For example, a controller might rely on a service to retrieve data. When unit testing, it’s not efficient to use the actual service layer because it may interact with databases or other external systems.
Instead, we mock the dependencies using a library called Mockito. This allows us to simulate the behavior of these dependencies, making the test faster and more focused.
Example: Mocking a service in a test case:
@MockBean
private ProductService productService;
@Test
void testProductsSameAsService() {
// Arrange
List<Product> products = new ArrayList<>();
Product p1 = new Product(); p1.setTitle("iPhone 15");
Product p2 = new Product(); p2.setTitle("iPhone 15 Pro");
Product p3 = new Product(); p3.setTitle("iPhone 15 Pro Max");
products.add(p1); products.add(p2); products.add(p3);
when(productService.getAllProducts()).thenReturn(products);
// Act
ResponseEntity<List<Product>> response = productController.getAllProducts();
// Assert
assertEquals(products.size(), response.getBody().size());
}
- The
@MockBean
annotation is used to create a mock object ofProductService
. - The
when(...).thenReturn(...)
statement is part of Mockito’s syntax for specifying what the mocked method should return when called. - This allows us to test the
ProductController
'sgetAllProducts()
method without needing the actual implementation ofProductService
.
6. Avoiding Common Bugs in Test Cases¶
When writing unit tests, it’s important to understand how Java handles references. One common bug occurs when tests modify objects by reference, causing unintended side effects.
For example, if the titles of products are modified in the test case, the original objects may be affected as Java passes values by reference. To avoid this, we should create new instances of objects for comparison.
Correcting the bug by using separate lists:
@Test
void testProductsSameAsService() {
// Arrange
List<Product> products = new ArrayList<>();
Product p1 = new Product(); p1.setTitle("iPhone 15");
Product p2 = new Product(); p2.setTitle("iPhone 15 Pro");
Product p3 = new Product(); p3.setTitle("iPhone 15 Pro Max");
products.add(p1); products.add(p2); products.add(p3);
List<Product> productsToPass = new ArrayList<>();
for (Product p : products) {
Product temp = new Product();
temp.setTitle(p.getTitle());
productsToPass.add(temp);
}
when(productService.getAllProducts()).thenReturn(productsToPass);
// Act
ResponseEntity<List<Product>> response = productController.getAllProducts();
// Assert
assertEquals(products.size(), response.getBody().size());
for (int i = 0; i < products.size(); i++) {
assertEquals(products.get(i).getTitle(), response.getBody().get(i).getTitle());
}
}
7. Testing for Exceptions in Controller Methods¶
Sometimes, a controller method may throw an exception if the requested data is not available. For instance, a getProductById()
method may throw a ProductNotExistsException
if the requested product doesn’t exist in the database.
To simulate this, we mock the behavior of the ProductRepository
to return an empty value when searching for a non-existent product ID.
Testing a non-existent product scenario:
@MockBean
private ProductRepository productRepository;
@Test
void testNonExistingProductThrowsException() throws ProductNot
ExistsException {
// Arrange
when(productRepository.findById(10L)).thenReturn(Optional.empty());
// Act & Assert
assertThrows(ProductNotExistsException.class,
() -> productController.getSingleProduct(10L));
}
assertThrows
is used to verify that the expected exception (ProductNotExistsException
) is thrown when attempting to fetch a product with an ID that doesn’t exist.- Mocking the
ProductRepository
ensures that the service layer doesn’t interact with a real database.