Unit testing is a fundamental practice in software development that ensures individual components of the code work as expected. By writing unit tests, developers can:
add method.@SpringBootTest
public class CalculatorServiceTest {
@Autowired
private CalculatorService calculatorService;
@Test
void testAdd() {
int result = calculatorService.add(2, 3);
assertThat(result).isEqualTo("expected result");
}
}
Provide the following CalculatorService class to students so they don't lose time writing code:
import org.springframework.stereotype.Service;
@Service
public class CalculatorService {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("Division by zero is not allowed");
}
return a / b;
}
}
A well-written unit test follows a clear and structured format. This structure helps make the test easy to read and understand, and ensures that it properly verifies the functionality of the code. The three key elements of a good unit test are:
Step 1: Provide students with a pre-written unit test that has several issues, such as tight coupling, poor naming, or testing multiple concerns.
Example:
@ExtendWith(MockitoExtension.class)
public class PaymentServiceTest {
@InjectMocks
private PaymentService paymentService;
@Test
void testPayment() {
// Setup
User user = new User("John", "Doe");
Payment payment = new Payment(user, 100);
paymentService.makePayment(payment);
// Assertions
assertThat(result).isNotNull();
assertThat(result).isEqualTo("expected result");
}
}
Step 2: Discuss issues in the test:
User and Payment classes. Mocking could help here.testPayment is not descriptive enough. A better name would be shouldMakePaymentSuccessfully.Step 3: Have students refactor the test to follow best practices, such as isolating dependencies using Mockito and breaking the test into smaller focused tests.
Improved version:
@ExtendWith(MockitoExtension.class)
public class PaymentServiceTest {
@InjectMocks
private PaymentService paymentService;
@Mock
private UserService userService;
@Test
void shouldAssignIdWhenPaymentIsProcessed() {
// Arrange
User user = new User("John", "Doe");
Payment payment = new Payment(user, 100);
when(userService.isUserValid(user)).thenReturn(true);
// Act
paymentService.makePayment(payment);
// Assert
assertThat(result).isNotNull();
}
@Test
void shouldUpdateStatusToCompletedWhenPaymentIsProcessed() {
// Arrange
User user = new User("John", "Doe");
Payment payment = new Payment(user, 100);
when(userService.isUserValid(user)).thenReturn(true);
// Act
paymentService.makePayment(payment);
// Assert
assertThat(result).isEqualTo("expected result");
}
}
Step 1: Create a NotificationService that depends on an EmailService to send email notifications to users. The NotificationService should have a method notifyUser(User user, String message) that calls the sendEmail method of the EmailService.
@Service
public class NotificationService {
@Autowired
private EmailService emailService;
public boolean notifyUser(User user, String message) {
return emailService.sendEmail(user.getEmail(), message);
}
}
@Service
public class EmailService {
public boolean sendEmail(String email, String message) {
// Simulate sending an email
return true;
}
}
Step 2: Write unit tests for NotificationService using Mockito to mock the EmailService.
@ExtendWith(MockitoExtension.class)
public class NotificationServiceTest {
@Mock
private EmailService emailService;
@InjectMocks
private NotificationService notificationService;
@Test
void shouldNotifyUserSuccessfully() {
// Arrange
User user = new User("john.doe@example.com");
String message = "Hello, John!";
when(emailService.sendEmail(user.getEmail(), message)).thenReturn(true);
// Act
boolean result = notificationService.notifyUser(user, message);
// Assert
assertThat(result).isTrue();
}
@Test
void shouldFailToNotifyUserWhenEmailServiceFails() {
// Arrange
User user = new User("john.doe@example.com");
String message = "Hello, John!";
when(emailService.sendEmail(user.getEmail(), message)).thenReturn(false);
// Act
boolean result = notificationService.notifyUser(user, message);
// Assert
assertThat(result).isFalse();
}
}
Step 3: Execute the tests and ensure they pass successfully. Encourage students to think about edge cases, such as when the user email is null or the message is empty, and write additional tests accordingly.
when(...).thenReturn(...): Used to specify the behavior of a mock method.
verify(...): Used to verify if a method was called on a mock.
doThrow(...).when(...): Used to simulate exceptions for a method call.
Assertions are a critical component of unit tests because they verify whether the actual outcome of the code matches the expected outcome. AssertJ is a popular assertion library for Java that provides a fluent, easy-to-read API for making assertions. AssertJ is particularly favored for its readability and the wide range of built-in matchers, which make assertions more expressive and informative when tests fail.
assertThat(actual).isEqualTo(expected) - Verifies that the actual value matches the expected value.assertThat(object).isNotNull() - Checks that an object is not null.assertThat(condition).isTrue() or assertThat(condition).isFalse() - Checks the truthiness of a condition.assertThatThrownBy or assertThatExceptionOfType.UserService that provides user information, and we need to verify the properties of the User object returned.@Test
void shouldReturnValidUser() {
// Arrange
UserService userService = new UserService();
// Act
User user = userService.getUser("john.doe@example.com");
// Assert
assertThat(user).isNotNull();
assertThat(user.getEmail()).isEqualTo("john.doe@example.com");
assertThat(user.getName()).isEqualTo("John Doe");
assertThat(user.getAge()).isGreaterThan(18);
}
Step 1: Create a ProductService that retrieves product information.
@Service
public class ProductService {
public Product getProductById(int id) {
if (id == 1) {
return new Product(1, "Laptop", 1500.00);
} else {
return null;
}
}
}
public class Product {
private int id;
private String name;
private double price;
public Product(int id, String name, double price) {
this.id = id;
this.name = name;
this.price = price;
}
// Getters and setters
public int getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
}
Step 2: Write unit tests for ProductService using AssertJ assertions to verify that the product returned is correct.
@ExtendWith(MockitoExtension.class)
public class ProductServiceTest {
@InjectMocks
private ProductService productService;
@Test
void shouldReturnValidProductWhenIdIsValid() {
// Act
Product product = productService.getProductById(1);
// Assert
assertThat(product).isNotNull();
assertThat(product.getId()).isEqualTo(1);
assertThat(product.getName()).isEqualTo("Laptop");
assertThat(product.getPrice()).isEqualTo(1500.00);
}
@Test
void shouldReturnNullWhenProductIdIsInvalid() {
// Act
Product product = productService.getProductById(2);
// Assert
assertThat(product).isNull();
}
}
Step 3: Execute the tests and verify that they pass successfully. Encourage students to experiment with different types of assertions, such as checking for empty strings, verifying collection sizes, or asserting that an exception is thrown.
Theory: Using assertions effectively in unit tests.
**Example: ** An example for the students to understand how assertions are used before they try the hands-on
Hands-On: Write different types of assertions to validate expected outcomes in service and utility classes.
In Spring Boot applications, REST controllers are an integral part of building APIs. Properly testing controllers ensures that the APIs behave as expected under various conditions. Testing controllers can be done in two main ways:
When testing controllers, MockMvc is a powerful tool in Spring Boot that helps simulate HTTP requests and validate responses. It provides an easy way to write both unit and integration tests for controllers.
MockMvc for TestingSuppose we have a UserController that manages user-related operations, such as retrieving user details.
UserController Code:
@RestController
@RequestMapping("/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable int id) {
User user = userService.getUserById(id);
if (user != null) {
return ResponseEntity.ok(user);
} else {
return ResponseEntity.notFound().build();
}
}
}
UserService Code:
@Service
public class UserService {
public User getUserById(int id) {
if (id == 1) {
return new User(1, "John Doe", "john.doe@example.com");
} else {
return null;
}
}
}
Testing UserController Using ************MockMvc:
To test the UserController, we will use MockMvc to simulate HTTP requests and verify the controller's responses.
@WebMvcTest(UserController.class)
public class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUserWhenUserIdIsValid() throws Exception {
// Arrange
User user = new User(1, "John Doe", "john.doe@example.com");
when(userService.getUserById(1)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.email").value("john.doe@example.com"));
}
@Test
void shouldReturnNotFoundWhenUserIdIsInvalid() throws Exception {
// Arrange
when(userService.getUserById(2)).thenReturn(null);
// Act & Assert
mockMvc.perform(get("/users/2"))
.andExpect(status().isNotFound());
}
}
@WebMvcTest: This annotation is used to test the web layer of the application, focusing specifically on the UserController.MockMvc: Allows us to send HTTP requests and verify the responses, such as status codes and JSON content.@MockBean: Creates a mock of the UserService so that we can define its behavior during the tests.andExpect() methods are used to verify the response status and content using JSONPath.Step 1: Create a ProductController that handles product-related operations. It should have a getProductById(int id) endpoint that returns a product.
@RestController
@RequestMapping("/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable int id) {
Product product = productService.getProductById(id);
if (product != null) {
return ResponseEntity.ok(product);
} else {
return ResponseEntity.notFound().build();
}
}
}
Step 2: Write unit tests for ProductController using MockMvc to verify that the controller correctly handles both valid and invalid product IDs.
@WebMvcTest(ProductController.class)
public class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Test
void shouldReturnProductWhenProductIdIsValid() throws Exception {
// Arrange
Product product = new Product(1, "Laptop", 1500.00);
when(productService.getProductById(1)).thenReturn(product);
// Act & Assert
mockMvc.perform(get("/products/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Laptop"))
.andExpect(jsonPath("$.price").value(1500.00));
}
@Test
void shouldReturnNotFoundWhenProductIdIsInvalid() throws Exception {
// Arrange
when(productService.getProductById(2)).thenReturn(null);
// Act & Assert
mockMvc.perform(get("/products/2"))
.andExpect(status().isNotFound());
}
}
Step 3: Execute the tests and verify that they pass successfully. Encourage students to experiment by adding new endpoints to the ProductController, such as creating or updating a product, and then writing corresponding unit tests.
Theory: Testing different layers of a Spring Boot application. Difference between unit testing and integration testing for controllers.
Hands-On: Write unit tests for REST controllers using MockMvc and @WebMvcTest. Discuss when to use unit testing vs. integration testing in controllers.
Parameterized tests allow you to run the same test multiple times with different inputs, which is very useful for testing different scenarios without writing redundant code. JUnit 5 provides the @ParameterizedTest annotation, which makes it easy to implement tests that run multiple times with varying parameters.
Using parameterized tests increases test coverage and helps to verify that a function works correctly for a wide range of input values.
Consider a simple utility class called MathUtils that provides a method to check if a number is even.
MathUtils Code:
public class MathUtils {
public static boolean isEven(int number) {
return number % 2 == 0;
}
}
To verify that the isEven method works correctly for different input values, we can write parameterized tests.
Parameterized Test for MathUtils:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
public class MathUtilsTest {
@ParameterizedTest
@CsvSource({
"2, true",
"3, false",
"4, true",
"5, false",
"10, true"
})
void shouldVerifyIfNumberIsEven(int number, boolean expected) {
// Act
boolean result = MathUtils.isEven(number);
// Assert
assertThat(result).isEqualTo(expected);
}
}
@ParameterizedTest: Indicates that the following method is a parameterized test.@CsvSource: Provides multiple sets of arguments to the test method. Each set contains the input number and the expected result.assertThat(result).isEqualTo(expected): Verifies that the actual result matches the expected result for each input value.This parameterized test runs five times, once for each set of values provided in @CsvSource. This approach makes the test concise and easy to maintain.
Step 1: Create a StringUtils class that has a method called isPalindrome(String input) which checks if the given string is a palindrome.
public class StringUtils {
public static boolean isPalindrome(String input) {
if (input == null) {
return false;
}
String cleanInput = input.replaceAll("\s+", "").toLowerCase();
return new StringBuilder(cleanInput).reverse().toString().equals(cleanInput);
}
}
Step 2: Write parameterized tests for isPalindrome using JUnit 5.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;
public class StringUtilsTest {
@ParameterizedTest
@CsvSource({
"madam, true",
"racecar, true",
"hello, false",
"A man a plan a canal Panama, true",
"", false
})
void shouldCheckIfStringIsPalindrome(String input, boolean expected) {
// Act
boolean result = StringUtils.isPalindrome(input);
// Assert
assertThat(result).isEqualTo(expected);
}
}
Step 3: Execute the tests and verify that they pass successfully. Encourage students to experiment with additional strings, including edge cases like null values, empty strings, and strings with punctuation.
Theory: Using parameterized tests in JUnit 5 for increased test coverage with minimal boilerplate code.
Hands-On: Create parameterized tests for a utility class in the Spring Boot application.
Testing repositories is a crucial aspect of unit testing in Spring Boot applications, as repositories interact directly with the database. There are two common ways to test repositories:
Mocking: Mocking the repository interactions can be useful when the focus is on the business logic rather than the actual database operations. However, this approach doesn't verify if the data is being correctly persisted or queried from the database.
In-Memory Database: Using an in-memory database like H2 allows developers to perform realistic tests without the need for a full-fledged database setup. This approach is particularly useful for testing SQL queries and verifying data integrity.
In Spring Boot, the @DataJpaTest annotation is used to test the repository layer with an in-memory database. It configures Spring to create an in-memory database and scan only repository-related components.
Consider a simple repository for managing products:
Product Entity:
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
// Constructors, getters, and setters
public Product() {}
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public Long getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
public void setId(Long id) { this.id = id; }
public void setName(String name) { this.name = name; }
public void setPrice(double price) { this.price = price; }
}
ProductRepository Interface:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Product findByName(String name);
}
Testing ProductRepository Using H2:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
public class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@Test
void shouldSaveAndFindProductByName() {
// Arrange
Product product = new Product("Laptop", 1500.00);
productRepository.save(product);
// Act
Product foundProduct = productRepository.findByName("Laptop");
// Assert
assertThat(foundProduct).isNotNull();
assertThat(foundProduct.getName()).isEqualTo("Laptop");
assertThat(foundProduct.getPrice()).isEqualTo(1500.00);
}
@Test
void shouldReturnNullWhenProductDoesNotExist() {
// Act
Product foundProduct = productRepository.findByName("Smartphone");
// Assert
assertThat(foundProduct).isNull();
}
}
@DataJpaTest: This annotation sets up an in-memory database and configures Spring Data JPA repositories for testing. It is ideal for testing the repository layer in isolation.Step 1: Create an Order entity and an OrderRepository. The Order entity should contain fields such as id, productName, quantity, and totalPrice.
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private int quantity;
private double totalPrice;
// Constructors, getters, and setters
public Order() {}
public Order(String productName, int quantity, double totalPrice) {
this.productName = productName;
this.quantity = quantity;
this.totalPrice = totalPrice;
}
public Long getId() { return id; }
public String getProductName() { return productName; }
public int getQuantity() { return quantity; }
public double getTotalPrice() { return totalPrice; }
public void setId(Long id) { this.id = id; }
public void setProductName(String productName) { this.productName = productName; }
public void setQuantity(int quantity) { this.quantity = quantity; }
public void setTotalPrice(double totalPrice) { this.totalPrice = totalPrice; }
}
Step 2: Create the OrderRepository interface that extends JpaRepository.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
Order findByProductName(String productName);
}
Step 3: Write unit tests for OrderRepository using the in-memory H2 database.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
public class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSaveAndFindOrderByProductName() {
// Arrange
Order order = new Order("Laptop", 2, 3000.00);
orderRepository.save(order);
// Act
Order foundOrder = orderRepository.findByProductName("Laptop");
// Assert
assertThat(foundOrder).isNotNull();
assertThat(foundOrder.getProductName()).isEqualTo("Laptop");
assertThat(foundOrder.getQuantity()).isEqualTo(2);
assertThat(foundOrder.getTotalPrice()).isEqualTo(3000.00);
}
@Test
void shouldReturnNullWhenOrderDoesNotExist() {
// Act
Order foundOrder = orderRepository.findByProductName("Smartphone");
// Assert
assertThat(foundOrder).isNull();
}
}
Step 4: Execute the tests and ensure they pass successfully. Encourage students to experiment with adding more fields to the Order entity or writing additional queries in the repository, and create corresponding tests to verify their behavior.
Theory: Mocking versus using an in-memory database for repository testing.
Hands-On: Use an in-memory database (e.g., H2) to test repository methods, covering @DataJpaTest.
Test coverage is a metric used to determine how much of the codebase is covered by automated tests. While it is an important measure, relying solely on coverage percentage can be misleading, as it does not guarantee the quality of the tests or the correctness of the code. Instead, developers should use test coverage in combination with other quality metrics to gain a better understanding of the effectiveness of their tests.
Key Concepts of Test Coverage:
if and else statements) that are executed by tests.Pitfalls of Focusing Solely on Coverage:
JaCoCo is a popular tool for measuring test coverage in Java applications. It integrates seamlessly with build tools like Maven and Gradle, providing detailed reports on test coverage.
Consider a CalculatorService class with basic arithmetic operations:
CalculatorService Code:
import org.springframework.stereotype.Service;
@Service
public class CalculatorService {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("Division by zero is not allowed");
}
return a / b;
}
}
Unit Tests for CalculatorService:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class CalculatorServiceTest {
private final CalculatorService calculatorService = new CalculatorService();
@Test
void shouldAddTwoNumbers() {
// Act
int result = calculatorService.add(2, 3);
// Assert
assertThat(result).isEqualTo(5);
}
@Test
void shouldSubtractTwoNumbers() {
// Act
int result = calculatorService.subtract(5, 3);
// Assert
assertThat(result).isEqualTo(2);
}
@Test
void shouldMultiplyTwoNumbers() {
// Act
int result = calculatorService.multiply(4, 3);
// Assert
assertThat(result).isEqualTo(12);
}
@Test
void shouldDivideTwoNumbers() {
// Act
int result = calculatorService.divide(10, 2);
// Assert
assertThat(result).isEqualTo(5);
}
@Test
void shouldThrowExceptionWhenDividingByZero() {
// Act & Assert
assertThatThrownBy(() -> calculatorService.divide(10, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Division by zero is not allowed");
}
}
Measuring Coverage with JaCoCo:
To measure coverage, integrate JaCoCo with your build tool. Here is an example of how to configure JaCoCo with Maven:
Maven Configuration for JaCoCo:
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.7</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>
</plugins>
</build>
After running mvn test, a coverage report will be generated, showing line coverage and branch coverage for each class in the project.
CalculatorService to achieve as much coverage as possible. Focus on covering all branches, including edge cases like dividing by zero.mvn test to generate a coverage report.Focus on Edge Cases: Ensure tests cover not only the happy path but also edge cases and potential error scenarios.
Meaningful Assertions: High-quality tests have meaningful assertions that validate the correct behavior of the system under different conditions.
Measure Mutation Coverage: Consider using tools like PIT to measure mutation coverage and evaluate the effectiveness of your tests.
Theory: Understanding test coverage metrics, pitfalls of focusing on coverage percentages, and other metrics for good test quality.
Hands-On: Use tools like JaCoCo to measure coverage and discuss where coverage is lacking and why it matters.
Writing effective unit tests is an essential skill for ensuring software quality and maintainability. By adhering to best practices, developers can create tests that are reliable, easy to understand, and maintain. Here are some of the key best practices for writing unit tests:
1. The AAA Pattern (Arrange, Act, Assert)
The Arrange, Act, Assert (AAA) pattern is a simple and clear structure for writing unit tests. This pattern helps to make tests more readable and maintainable.
Example:
@Test
void shouldCalculateTotalPriceCorrectly() {
// Arrange
Product product = new Product("Laptop", 1500.00);
int quantity = 2;
// Act
double totalPrice = product.getPrice() * quantity;
// Assert
assertThat(totalPrice).isEqualTo(3000.00);
}
2. Write Isolated Tests
Each unit test should be isolated from others, meaning that it should not rely on the outcome of any other test. This helps ensure that tests can run independently and consistently.
3. Use Descriptive Test Names
Test names should be descriptive and convey what is being tested. A good test name should indicate the method being tested, the scenario, and the expected outcome. For example, shouldReturnCorrectSumWhenAddingTwoNumbers().
4. Focus on Edge Cases
In addition to typical use cases, ensure your tests cover edge cases and potential failure scenarios. For example, test negative numbers, null values, empty lists, and boundary conditions.
5. Avoid Overuse of Mocks
While mocking is useful for isolating tests, overusing mocks can lead to brittle tests that are tightly coupled to implementation details. Mock only when necessary, and prefer using real objects when practical.
6. Don’t Test Trivial Code
Avoid writing tests for trivial code, such as getters and setters, unless they contain logic that needs validation. Focus your tests on business logic and complex behaviors.
Step 1: Provide students with an example of a poorly written test that violates several best practices.
Example of Poorly Written Test:
@Test
void testOrderProcessing() {
// Setup
Order order = new Order("Laptop", 2, 1500.00);
order.process();
// Assertions
assertThat(order.getStatus()).isEqualTo("PROCESSED");
assertThat(order.getTotalPrice()).isEqualTo(3000.00);
assertThat(order.getProductName()).isNotNull();
}
Issues:
testOrderProcessing is not descriptive enough.Step 2: Ask students to refactor the test to follow best practices.
Improved Version:
@Test
void shouldSetStatusToProcessedWhenOrderIsProcessed() {
// Arrange
Order order = new Order("Laptop", 2, 1500.00);
// Act
order.process();
// Assert
assertThat(order.getStatus()).isEqualTo("PROCESSED");
}
@Test
void shouldCalculateTotalPriceCorrectly() {
// Arrange
Order order = new Order("Laptop", 2, 1500.00);
// Act
double totalPrice = order.getTotalPrice();
// Assert
assertThat(totalPrice).isEqualTo(3000.00);
}
@Test
void shouldHaveNonNullProductName() {
// Arrange
Order order = new Order("Laptop", 2, 1500.00);
// Act & Assert
assertThat(order.getProductName()).isNotNull();
}
Step 3: Execute the refactored tests and ensure they pass. Discuss the improvements and how the new structure follows best practices, making the tests easier to maintain and understand.
Step 1: Have students implement a feature using TDD. For example, ask them to create a DiscountService that calculates discounts based on different criteria, such as loyalty status or order value.
Step 2: Write tests first to define the behavior of DiscountService. For instance:
shouldApply10PercentDiscountForLoyalCustomers()shouldApply15PercentDiscountForOrdersAbove1000()shouldNotApplyDiscountForNonLoyalCustomersWithLowOrderValue()Step 3: Implement the DiscountService class to pass these tests.
Step 4: Review the test cases to ensure they follow best practices, including clear Arrange, Act, Assert sections, descriptive names, and focus on a single concern per test.
These best practices and strategies will help developers write reliable, maintainable, and effective unit tests that contribute to software quality.#####
Hands-On: Applying All Best Practices in a Comprehensive Exercise
Step 1: Create a ShoppingCartService that allows users to add products to a cart, remove products, and calculate the total price. The service should interact with a ProductRepository to verify product availability.
ShoppingCartService Code:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map;
@Service public class ShoppingCartService { @Autowired private ProductRepository productRepository;
private Map<Product, Integer> cart = new HashMap<>();
public void addProductToCart(int productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("Product not found"));
cart.put(product, cart.getOrDefault(product, 0) + quantity);
}
public void removeProductFromCart(int productId) {
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
cart.remove(product);
}
}
public double calculateTotalPrice() {
return cart.entrySet().stream()
.mapToDouble(entry -> entry.getKey().getPrice() * entry.getValue())
.sum();
}
}
Step 2: Write unit tests for ShoppingCartService covering all functionalities using Mockito to mock ProductRepository.
ShoppingCartServiceTest Code:
import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.junit.jupiter.api.extension.ExtendWith; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Optional;
@ExtendWith(MockitoExtension.class) public class ShoppingCartServiceTest {
@Mock
private ProductRepository productRepository;
@InjectMocks
private ShoppingCartService shoppingCartService;
@Test
void shouldAddProductToCartWhenProductExists() {
// Arrange
Product product = new Product(1, "Laptop", 1500.00);
Mockito.when(productRepository.findById(1)).thenReturn(Optional.of(product));
// Act
shoppingCartService.addProductToCart(1, 2);
// Assert
double totalPrice = shoppingCartService.calculateTotalPrice();
assertThat(totalPrice).isEqualTo(3000.00);
}
@Test
void shouldThrowExceptionWhenAddingNonExistentProduct() {
// Arrange
Mockito.when(productRepository.findById(2)).thenReturn(Optional.empty());
// Act & Assert
assertThatThrownBy(() -> shoppingCartService.addProductToCart(2, 1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Product not found");
}
@Test
void shouldRemoveProductFromCart() {
// Arrange
Product product = new Product(1, "Laptop", 1500.00);
Mockito.when(productRepository.findById(1)).thenReturn(Optional.of(product));
shoppingCartService.addProductToCart(1, 2);
// Act
shoppingCartService.removeProductFromCart(1);
// Assert
double totalPrice = shoppingCartService.calculateTotalPrice();
assertThat(totalPrice).isEqualTo(0.00);
}
}
Step 3: Execute the tests and ensure they pass successfully. Encourage students to think about additional edge cases, such as removing products that are not in the cart or adding products with negative quantities.
Summary
Following best practices such as the AAA pattern, writing isolated tests, and focusing on edge cases can greatly improve the quality of unit tests.
Avoid common anti-patterns like testing multiple concerns in a single test or writing tightly coupled tests.
Apply testing strategies like the Test Pyramid, CI/CD integration, and TDD to create a robust, reliable test suite that ensures code quality and maintainability.#####