Testing is a critical skill for software developers, ensuring code quality and reliability. This guide covers essential concepts in unit testing, the JUnit framework, assertion types, and mock testing with Mockito, providing a strong foundation for interview preparation.
Introduction to Testing
Testing is an integral part of software development, aimed at verifying that code behaves as expected. It helps identify bugs and ensures that code changes do not introduce regressions.
Advantages of Testing:
Improved Code Quality: Regular testing can catch bugs early in the development process.
Facilitates Refactoring: Ensures that code changes do not break existing functionality.
Documentation: Tests can serve as documentation for how the code is supposed to work.
Unit Testing
Unit testing focuses on testing individual units or components of a software. JUnit is a popular framework for writing and running tests.
Benefits of Unit Testing:
Isolation: Tests are written for individual components in isolation.
Speed: Unit tests are typically fast to write and execute.
Cons:
Limited Scope: Does not catch integration issues between components.
JUnit Framework
JUnit is a widely used framework for unit testing in Java and Kotlin. It provides annotations to identify test methods and assertions to verify expected outcomes.
Basic Annotations:
@Test: Marks a method as a test method.@BeforeEach: Code to run before each test.@AfterEach: Code to run after each test.@BeforeAlland@AfterAll: Code to run once before/after all tests.
Assertions in JUnit:
Assertions are used to validate the expected outcome of a test.
assertEquals(expected, actual): Checks if two values are equal.assertNotNull(actual): Checks if a value is not null.assertTrue(condition): Checks if a condition is true.assertAll: Group multiple assertions.assertThrows: Verify that a specific exception is thrown.
Example with a Calculator:
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@DisplayName("Calculator Test Suite")
class CalculatorTest {
private val calculator = Calculator()
@Test
@DisplayName("Addition should return the correct result")
fun testAddition() {
assertEquals(5, calculator.add(2, 3))
}
@Test
@DisplayName("Division by zero should throw an exception")
fun testDivisionByZero() {
assertThrows<ArithmeticException> {
calculator.divide(5, 0)
}
}
@Test
@DisplayName("Multiple operations should be correct")
fun testMultipleOperations() {
assertAll(
{ assertEquals(5, calculator.add(2, 3)) },
{ assertEquals(1, calculator.subtract(3, 2)) },
{ assertEquals(6, calculator.multiply(2, 3)) }
)
}
}import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Calculator Test Suite")
class CalculatorTest {
private final Calculator calculator = new Calculator();
@Test
@DisplayName("Addition should return the correct result")
void testAddition() {
assertEquals(5, calculator.add(2, 3));
}
@Test
@DisplayName("Division by zero should throw an exception")
void testDivisionByZero() {
assertThrows(ArithmeticException.class, () -> {
calculator.divide(5, 0);
});
}
@Test
@DisplayName("Multiple operations should be correct")
void testMultipleOperations() {
assertAll(
() -> assertEquals(5, calculator.add(2, 3)),
() -> assertEquals(1, calculator.subtract(3, 2)),
() -> assertEquals(6, calculator.multiply(2, 3))
);
}
}Mockito for Mocking
Mockito is a powerful framework for creating mock objects, which are essential for isolating components during testing. By using mocks, you can simulate the behavior of complex objects and focus on testing the logic of the class under test.
Stubs vs. Mocks:
Stubs: Stubs are used to provide predefined responses to method calls made during tests. They are mainly used to define the behavior of a dependency without any concern for how those methods are called. Stubs help to control the environment in which the class is tested by supplying consistent and expected outcomes. However, they do not verify interactions with the dependency.
Example of Stub:
// Stub for consistent return value whenever(calculator.add(2, 3)).thenReturn(5)// Stub for consistent return value when(calculator.add(2, 3)).thenReturn(5)Mocks: Mocks, on the other hand, are full-featured replacements of objects that can both define behavior and verify interactions. They are designed to confirm that certain methods are called with specified parameters and are instrumental in interaction testing. While stubs focus on controlling data returned by the calls, mocks capture the interaction history with the object.
Example of Mock:
// Verify interaction with methods verify(calculator).multiply(100.0, 0.05)
Dependency Injection
Dependency injection is a design pattern that allows you to replace real dependencies with mock objects during testing. This approach is crucial for ensuring that your tests are isolated and not dependent on external systems or services.
Annotations
@Mock: This annotation is used to create a mock object.@InjectMocks: This annotation is used to inject mock objects into the class being tested. It helps in setting up the test environment by automatically injecting the mocks into the fields of the class under test.
Using Mockito
Below is an example of how to use Mockito in a test class:
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.DisplayName
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
@ExtendWith(MockitoExtension::class)
@DisplayName("InterestCalculator Test")
class InterestCalculatorTest {
@Mock
lateinit var calculator: Calculator
@InjectMocks
lateinit var interestCalculator: InterestCalculator
@Test
@DisplayName("Calculate Interest should use calculator to compute interest")
fun calculateInterestShouldUseCalculatorToComputeInterest() {
whenever(calculator.multiply(100.0, 0.05)).thenReturn(5.0)
val interest = interestCalculator.calculateInterest(100.0, 0.05)
assertEquals(5.0, interest)
verify(calculator).multiply(100.0, 0.05)
}
@Test
@DisplayName("Log Transaction should not throw any exception")
fun logTransactionShouldNotThrowException() {
doNothing().whenever(calculator).logTransaction(anyString())
interestCalculator.logTransaction("Test transaction")
verify(calculator).logTransaction("Test transaction")
}
@Test
@DisplayName("Divide by zero should throw ArithmeticException")
fun divideByZeroShouldThrowArithmeticException() {
doThrow(ArithmeticException("Division by zero")).whenever(calculator).divide(anyDouble(), eq(0.0))
assertThrows<ArithmeticException> {
interestCalculator.divide(10.0, 0.0)
}
verify(calculator).divide(10.0, 0.0)
}
}import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("InterestCalculator Test")
class InterestCalculatorTest {
@Mock
private Calculator calculator;
@InjectMocks
private InterestCalculator interestCalculator;
@Test
@DisplayName("Calculate Interest should use calculator to compute interest")
void calculateInterestShouldUseCalculatorToComputeInterest() {
when(calculator.multiply(100.0, 0.05)).thenReturn(5.0);
double interest = interestCalculator.calculateInterest(100.0, 0.05);
assertEquals(5.0, interest);
verify(calculator).multiply(100.0, 0.05);
}
@Test
@DisplayName("Log Transaction should not throw any exception")
void logTransactionShouldNotThrowException() {
doNothing().when(calculator).logTransaction(anyString());
interestCalculator.logTransaction("Test transaction");
verify(calculator).logTransaction("Test transaction");
}
@Test
@DisplayName("Divide by zero should throw ArithmeticException")
void divideByZeroShouldThrowArithmeticException() {
doThrow(new ArithmeticException("Division by zero")).when(calculator).divide(anyDouble(), eq(0.0));
assertThrows(ArithmeticException.class, () -> {
interestCalculator.divide(10.0, 0.0);
});
verify(calculator).divide(10.0, 0.0);
}
}Key Mockito Methods
Programming Behaviors:
whenever(Kotlin) |when(Java): Used to define the return value of a mock method call.whenever(calculator.add(2, 3)).thenReturn(5)when(calculator.add(2, 3)).thenReturn(5);doNothing: Used when you want a void method to do nothing upon invocation.doNothing().whenever(calculator).reset()doNothing().when(calculator).reset();doThrow: Used to simulate an exception being thrown by a method.doThrow(RuntimeException::class).whenever(calculator).divide(anyDouble(), eq(0.0))doThrow(RuntimeException.class).when(calculator).divide(anyDouble(), eq(0.0));verify: This method checks if a particular method of a mock object was called with specific arguments. It is useful for ensuring that the expected interactions with the mock object occur. Usingverifyis important because it ensures that your code interacts with its dependencies as expected, which is a critical aspect of testing the behavior of your application.verify(calculator).multiply(100.0, 0.05)
Spring Test
In addition to unit testing individual components, it's crucial to test the integration of these components with the Spring framework's functionalities. Spring provides several testing tools to help verify the interactions with real databases, RESTful endpoints, and more.
@DataJpaTest
@DataJpaTest is a specialized test annotation provided by Spring Boot designed to test JPA repositories. It configures an in-memory database, Hibernate, and Spring Data for testing data access logic.
Purpose: To focus on JPA components without loading the entire application context.
Configuration: Automatically configures Hibernate, Spring Data JPA, and an in-memory database.
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Optional: to use a real database
class UserRepositoryTest {
@Autowired
lateinit var userRepository: UserRepository
@Test
fun `should find user by username`() {
val user = userRepository.save(User(username = "johndoe", email = "[email protected]"))
val foundUser = userRepository.findByUsername("johndoe")
assertEquals(user.id, foundUser?.id)
}
}import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.*;
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE) // Optional: to use a real database
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldFindUserByUsername() {
User user = userRepository.save(new User("johndoe", "[email protected]"));
User foundUser = userRepository.findByUsername("johndoe");
assertNotNull(foundUser);
assertEquals(user.getId(), foundUser.getId());
}
}WebTestClient
WebTestClient is used for testing WebFlux applications but can also be used with RESTful services via WebClient. It is a non-blocking, reactive client for testing web servers.
Purpose: To test HTTP requests and validate the responses in WebFlux applications.
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.reactive.server.WebTestClient
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
import org.springframework.web.reactive.function.BodyInserters
@SpringJUnitConfig(classes = [WebFluxApplication::class])
class UserRestTest {
@Autowired
lateinit var webTestClient: WebTestClient
@Test
fun `test get all users`() {
webTestClient.get().uri("/users")
.exchange()
.expectStatus().isOk
.expectBodyList(User::class.java)
.hasSize(5) // Example validation
}
}import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = WebFluxApplication.class)
class UserRestTest {
@Autowired
private WebTestClient webTestClient;
@Test
void testGetAllUsers() {
webTestClient.get().uri("/users")
.exchange()
.expectStatus().isOk()
.expectBodyList(User.class)
.hasSize(5); // Example validation
}
}MockMvc
MockMvc is a powerful tool for testing Spring MVC applications. It allows you to perform requests against a mocked servlet environment.
Purpose: To test controllers' interactions with the Spring MVC stack without starting the server.
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
@WebMvcTest(UserController::class)
class UserControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Test
fun `should return all users`() {
mockMvc.get("/users")
.andExpect {
status { isOk() }
content { contentType("application/json") }
}
}
}import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnAllUsers() throws Exception {
mockMvc.perform(get("/users"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"));
}
}RestTemplate
RestTemplate is a synchronous client to perform HTTP requests for Spring applications, typically used for testing purposes in areas not involving WebFlux or when typical unit testing doesn't suffice.
Purpose: To perform integration tests involving HTTP requests on standard Spring MVC applications.
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.web.server.LocalServerPort
import org.springframework.http.HttpStatus
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RestTemplateTest {
@LocalServerPort
private var port: Int = 0
@Autowired
lateinit var restTemplate: TestRestTemplate
@Test
fun `should return all users over HTTP`() {
val url = "http://localhost:$port/users"
val response = restTemplate.getForEntity(url, Array<User>::class.java)
assertEquals(HttpStatus.OK, response.statusCode)
assertEquals(5, response.body?.size) // Example assertion
}
}import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class RestTemplateTest {
@LocalServerPort
private int port;
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldReturnAllUsersOverHttp() {
String url = "http://localhost:" + port + "/users";
ResponseEntity<User[]> response = restTemplate.getForEntity(url, User[].class);
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertEquals(5, response.getBody().length); // Example assertion
}
}Incorporating these tools allows developers to effectively test different layers of their applications, ensuring robust integration with Spring components.
Test-Driven Development (TDD)
TDD is a software development approach where tests are written before the code. It follows the cycle:
Red: Write a failing test.
Green: Write the minimum code to pass the test.
Refactor: Improve the code while ensuring tests still pass.
The example will demonstrate a simple function to calculate the sum of two numbers.
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
// Step 1: Red - Write a failing test
class CalculatorTest {
@Test
@DisplayName("Should return the sum of two numbers")
fun testAddition() {
val calculator = Calculator()
val result = calculator.add(2, 3)
assertEquals(5, result)
}
}
// Step 2: Green - Write the minimum code to pass the test
class Calculator {
fun add(a: Int, b: Int): Int {
return a + b
}
}
// Step 3: Refactor - Improve the code while ensuring tests still pass
// In this simple example, there might not be much to refactor, but
// the process encourages reviewing and improving the code structure.// Step 1: Red - Write a failing test
class CalculatorTest {
@Test
@DisplayName("Should return the sum of two numbers")
void testAddition() {
Calculator calculator = new Calculator();
int result = calculator.add(2, 3);
assertEquals(5, result);
}
}
// Step 2: Green - Write the minimum code to pass the test
class Calculator {
public int add(int a, int b) {
return a + b;
}
}
// Step 3: Refactor - Improve the code while ensuring tests still pass
// In this simple example, there might not be much to refactor, but
// the process encourages reviewing and improving the code structure.Explanation:
Red: We start by writing a test case in
CalculatorTestthat checks if theaddfunction returns the correct sum of two numbers. Initially, this test will fail because theCalculatorclass andaddfunction do not exist.Green: We then implement the
Calculatorclass and theaddfunction to return the sum of two numbers, ensuring the test passes.Refactor: Finally, we review the code to see if any improvements can be made while keeping all tests passing. In this simple case, the code is already quite straightforward.
Importance of TDD:
Encourages simple designs and inspires confidence to refactor.
Ensures comprehensive test coverage.
Final Thoughts
By mastering these testing techniques and tools, developers can ensure high-quality software and be well-prepared for technical interviews. Regular practice can enhance your skills and confidence. Good luck with your interviews!