Unit Test on Spring Boot, Mock, Integration Test with Test Container, and Argument Capture
Why should unit tests be written?
I am going to mention a real story about it. We were working on a massive project with my teammates. At that time, the product manager came up with a new task that was given by the global side. Each code is very crucial for big projects. That’s why we must have been careful about each code throughout development. When we started coding, we realized during the process that one logic others had developed was not working correctly through UNIT TEST.
Moreover, we designed our code and logic based on this code, which is not working correctly. What if we do not have a UNIT TEST, and What if we and other engineers never consider to have written a UNIT TEST? What would it be? The answer is quite clear. We would have finished the task. End of the day, we would understand our mistake in the production environment. Lastly, it would come back to us as a cost. You can hear dozens of stories like this, which may even be worse in the Software industry. Throughout this article, we will cover Unit Tests, Mock, Integration Test ,Argument Capture in Spring, and Why Unit Test Important. Enjoy reading.
In Real-Life Example - 1
In a real-life scenario, the Product Owner creates tasks with the analyst. Generally, the analyst analyzes what the product needs. End of the day, engineers share these tasks.
John: I finished my task. Unfortunately, I had no time to write the Unit Test. That’s why I tested everything manually. Everything is working without problem.
Phill: Okay Nick! Congrats! We can take a new task in the next sprint for Unit Tests.
They supposed they would take a new task for the unit tests in the next sprint. Undoubtedly, things sometimes work out differently than we should. Mostly, engineers forget to write the tests. End of the day, When they start to add new business logic and code, they cannot estimate the problem during the development. That’s why we really need the Unit and Integration test.
More understandably, the company wants to do the project. The company wants to deal with the Product manager. The company wants to deal with an Analyst specialist. The company wants to hire engineers. The company does not give developers enough development time. Engineers don’t write tests. The result is a failure and sad. As a result, Unit tests must. Otherwise, extending the new business logic and code might not be viable. Even if it is viable, it might impact other business logic, and this cannot be predicted because of the absence of the Unit and Integration Test.
In Real-Life Example — 2
In the second scenario, engineers have time to write the Unit and Integration tests. This might be helpful in the development process and new features development, enhancing the clarity of the code and guarantee. When new features and code want to be added, the behaviours of the code can be observed.
More understandably, the company wants to do the project. The company wants to deal with the Product manager. The company wants to deal with an Analyst specialist. The company wants to hire engineers. The company gives developers enough development time. Engineers write tests. And the company wants to add new features. Engineers add new features. Engineers write unit and integration tests. At the end of the day, everything is excellent and completed successfully. Engineers are happy, Product managers are happy, and Analysts are happy. And the boss is happy.
Test Strategy on Spring Boot
Usually, our team prefers to write unit tests using Kotlin. Kotlin provides more clarity and efficiency than Java in the UNIT TEST.
- Using mockito
- Kotlin provides more clarity and efficiency
- Kotlin provides clear method names
- Test coverage should be enhanced.
- Integration test must.
- Mockito Argument Capture should be used for void methods.
What is Mockito
Mockito is a popular open-source Java Framework that allows developers to write tests with a clean and simple API. Its main purpose is to create mock objects.
Mockito creates mock objects that simulate the behaviour of the real object, which means that it provides a clean and straightforward way to conduct unit tests.
My steps in Unit Test
- Possible scenarios you can think about possible scenarios. If you do not remember anything, ask Chat GPT or Google Bard. (Use Technology efficiently). For instance, success, failure because of X, failure because of Y, success because of Z and so on…
- Detect which services you must mock. For instance, when you want to write a unit test for a User service. You must mock the repository interface and mapper interface (if you use MapStruct) and other services that are called by User Service.
- If mock services do not return any value, you can use Argument Capture, which helps you determine which argument set your object is captured by Argument Capture.
- You can verify which method of service runs using verify(). Furthermore, you can compare values using Assertions.
I will leave the code here, but you can access the real service by clicking here.
When we look at the create method in UserService, we can clearly understand which checks email and phone exist before insertion, then the prepareUser() function of the UserPreapre class is called additionally save() and map functions are called. According to this information, we can create three scenarios here.
1-) Success to create operation
2-) Failure to create an operation because of duplicate email
3–) Failure to create operation because of duplicate phone
package beratyesbek.youtube.mock.service
import beratyesbek.youtube.mock.UserPrepare
import beratyesbek.youtube.mock.model.User
import beratyesbek.youtube.mock.model.dto.user.UserCreateDTO
import beratyesbek.youtube.mock.model.dto.user.UserReadDTO
import beratyesbek.youtube.mock.model.mapper.UserMapper
import beratyesbek.youtube.mock.repository.UserRepository
import beratyesbek.youtube.mock.service.email.EmailService
import beratyesbek.youtube.mock.service.email.user.UserEmailService
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito.*
import org.springframework.test.context.junit.jupiter.SpringExtension
@ExtendWith(SpringExtension::class)
class UserServiceTest {
@Mock
private lateinit var userMapper: UserMapper
@Mock
private lateinit var userRepository: UserRepository
@Mock
private lateinit var userPrepare: UserPrepare
@Mock
private lateinit var userEmailService: UserEmailService
@InjectMocks
private lateinit var userService: UserServiceImpl
@Test
fun `create user success test`() {
val userCreateDto = mock(UserCreateDTO::class.java)
val user = mock(User::class.java)
val readDTO = mock(UserReadDTO::class.java)
`when`(userRepository.existsByEmail(userCreateDto.email)).thenReturn(false)
`when`(userRepository.existsByPhone(userCreateDto.phone)).thenReturn(false)
`when`(userPrepare.prepareUserForCreation(userCreateDto)).thenReturn(user)
doNothing().`when`(userEmailService).sendVerificationEmail(any(), any(), any())
`when`(userRepository.save(user)).thenReturn(user)
`when`(userMapper.mapToReadDTO(user)).thenReturn(readDTO)
val result = userService.create(userCreateDto)
verify(userRepository, times(1)).existsByEmail(userCreateDto.email)
verify(userRepository, times(1)).existsByPhone(userCreateDto.email)
verify(userRepository, times(1)).save(user)
Assertions.assertEquals(user.email, result.email)
Assertions.assertEquals(user.phone, result.phone)
}
@Test
fun `create use failure because of duplicate email`() {
val userCreateDTO = mock(UserCreateDTO::class.java)
val user = mock(User::class.java)
val readDTO = mock(UserReadDTO::class.java)
// Mocking, It returns true when the method is called
// It is used for testing the failure case
// It will throw an exception when the method is called
`when`(userRepository.existsByEmail(userCreateDTO.email)).thenReturn(true)
`when`(userRepository.existsByPhone(userCreateDTO.phone)).thenReturn(false)
doNothing().`when`(userEmailService).sendVerificationEmail(any(), any(), any())
`when`(userPrepare.prepareUserForCreation(userCreateDTO)).thenReturn(user)
`when`(userRepository.save(user)).thenReturn(user)
`when`(userMapper.mapToReadDTO(user)).thenReturn(readDTO)
val exception = Assertions.assertThrows(RuntimeException::class.java) {
userService.create(userCreateDTO)
}
// It will check the method is called or not
verify(userRepository, never()).save(any())
Assertions.assertTrue(exception.message?.contains("User already exists with this email") ?: false)
}
@Test
fun `create user failure because of duplicate phone`() {
val userCreateDTO = mock(UserCreateDTO::class.java)
val user = mock(User::class.java)
val readDTO = mock(UserReadDTO::class.java)
// Mocking, It returns true when the method is called
// It is used for testing the failure case
// It will throw an exception when the method is called
`when`(userRepository.existsByPhone(userCreateDTO.phone)).thenReturn(true)
`when`(userRepository.existsByEmail(userCreateDTO.email)).thenReturn(false)
doNothing().`when`(userEmailService).sendVerificationEmail(any(), any(), any())
`when`(userPrepare.prepareUserForCreation(userCreateDTO)).thenReturn(user)
`when`(userRepository.save(user)).thenReturn(user)
`when`(userMapper.mapToReadDTO(user)).thenReturn(readDTO)
val exception = Assertions.assertThrows(RuntimeException::class.java) {
userService.create(userCreateDTO)
}
// It will check the method is called or not
verify(userRepository, never()).save(any())
Assertions.assertTrue(exception.message?.contains("User already exists with this phone") ?: false)
}
}
Explanation of Tests
@Mock annotation is responsible for creating a mock object that is simulated.
@InjectMock annotation is used to automatically inject mocks into the fields of the test class, particularly into the fields marked with. It creates an instance of UserServiceImpl
UserCreateDTO, UserReadDTO, and User are mocked using the mock() function, which means these objects are manipulated to simulate the behaviour of the real objects.
`when`()
As the name suggests, this method simulates the actual behaviour of an object when it is called and performs the operations that the object performs. The object here is simulated as the following object will be returned after running using thenReturn. As we see in the method, doNothing uses void methods
verify()
checks whether the method is called or not.times()
checks how many times the method is called.
Assertions.assertEquals() compare two arguments, expected and actual value. If they match, it returns true. Otherwise, it returns false.
Note: You must pass the same references or values to when()
the Unit Test and Service side. If somehow a value that changes in the service is given to the method, mocking cannot simulate it correctly. Therefore, your tests may not work.
Argument Capture
Argument capture in Mockito is a technique that allows you to capture the arguments passed to a method during a test, which is a really useful technique.
Code
package beratyesbek.youtube.mock.service.email
import beratyesbek.youtube.mock.service.email.model.*
import beratyesbek.youtube.mock.service.email.user.UserEmailServiceImpl
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.*
import org.mockito.Mockito.verify
import org.springframework.test.context.junit.jupiter.SpringExtension
import kotlin.test.assertEquals
@ExtendWith(SpringExtension::class)
class UserEmailServiceTest {
@Mock
private lateinit var emailService: EmailService
@InjectMocks
private lateinit var userEmailService: UserEmailServiceImpl
@Captor
private lateinit var emailRequestCaptor: ArgumentCaptor<EmailRequest>
@Test
fun `send verification email successfully`() {
val email = "berat@gmail.com"
val firstName = "Berat"
val lastName = "Yesbek"
userEmailService.sendVerificationEmail(email, firstName, lastName)
// It is used to capture the arguments for all successive calls.
verify(emailService, Mockito.times(1)).send(emailRequestCaptor.capture())
// Returns the value of the captured argument.
val capturedEmailRequest = emailRequestCaptor.value
val capturedContext = capturedEmailRequest.context
assertEquals(email, capturedEmailRequest.to)
assertEquals(EmailTemplates.EMAIL_VERIFY, capturedEmailRequest.template)
assertEquals(EmailSubjects.COMPLETE_YOUR_REGISTRATION, capturedEmailRequest.subject)
assertEquals(firstName, capturedContext.getVariable("firstName"))
assertEquals(lastName, capturedContext.getVariable("lastName"))
assertEquals(EmailMessage.VERIFY_ACCOUNT.message, capturedContext.getVariable("message"))
}
}
The @Captor
annotation in Mockito simplifies the creation and use of ArgumentCaptor
instances in test classes.
The @Captor
annotation is employed to capture arguments during method calls for later verification. The test method checks if the sendVerificationEmail
method of UserEmailServiceImpl
correctly interacts with the mock EmailService
, ensuring that the send
the method is called with the expected arguments and asserts specific values within the captured EmailRequest
. The end of this code compares captured and expects values to check that the values have been set correctly.
Integration Test with Test Container PSQL in Spring Boot
The test container is the isolated environment to implement the integration test. Integration tests are indeed designed to assemble and run different units or components of software together to ensure that they function correctly as a whole.
Most of the time, integration tests are implemented for Repositories. However, I am going to show it on UserController.
Before jumping into the integration test, we must configure our test container.
1-) Implement Dependencies That We need
// Test gradle file dependencies.
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.7.20'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:postgresql:1.19.1'
testImplementation 'org.testcontainers:testcontainers:1.15.3'
testImplementation 'org.testcontainers:junit-jupiter:1.15.3'
2-) Create a Test Container Class
package beratyesbek.youtube.mock.integrationtest.testcontainers
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.context.annotation.Bean
import org.testcontainers.containers.PostgreSQLContainer
@TestConfiguration
open class TestContainer {
@Bean
@ServiceConnection
open fun postgresSQLContainer(): PostgreSQLContainer<Nothing> {
val container = PostgreSQLContainer<Nothing>("postgres:16-alpine");
container.withDatabaseName("test")
container.withUsername("test")
container.withPassword("test")
return container
}
}
3-) Create a Test Container Profile
You must create a test container profile file under the src/main/resources/application-test-containers.properties
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.generate-ddl=true
spring.datasource.url=${JDBC_DATABASE_URL:jdbc:tc:postgresql:16-alpine:///test}
spring.datasource.username=${JDBC_DATABASE_USERNAME:test}
spring.datasource.password=${JDBC_DATABASE_PASSWORD:test}
spring.flyway.baseline-on-migrate=true
4-) Create a Controller, Service or Repository that You want to Implement Integration Test
package beratyesbek.youtube.mock.integrationtest
import beratyesbek.youtube.mock.controller.UserController
import beratyesbek.youtube.mock.model.dto.user.UserCreateDTO
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpStatus
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension
@ActiveProfiles("test-containers")
@ExtendWith(SpringExtension::class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class UserControllerIntegrationTest {
@Autowired
private lateinit var userController: UserController
@Test
fun `create user successful`() {
val userCreateDTO = UserCreateDTO.builder()
.email("berat@gmail.com")
.firstname("Berat")
.lastname("Yesbek")
.phone("1234563443")
.password("123456")
.build()
val result = userController.create(userCreateDTO)
Assertions.assertNotNull(result)
Assertions.assertNotNull(result.body)
Assertions.assertNotNull(result.body?.id)
Assertions.assertEquals(result.statusCode, HttpStatus.OK)
Assertions.assertEquals(result.body?.email, userCreateDTO.email)
Assertions.assertEquals(result.body?.firstname, userCreateDTO.firstname)
Assertions.assertEquals(result.body?.lastname, userCreateDTO.lastname)
Assertions.assertEquals(result.body?.phone, userCreateDTO.phone)
}
}
@ActiveProfiles annotation is used to activate specific Spring profiles during a test.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
This annotation is used to specify that the test should load the entire Spring application context.
webEnvironment = SpringBootTest.WebEnvironment.NONE
Indicates that the test will not involve a web server. The application context will be loaded without starting a server.- It’s commonly used for integration tests where you want to test the application’s components without the overhead of a running web server. (This part is quoted from here)
Conclusion
Unit and integration tests are significant in managing the software development process well. Without tests, the behaviours of code and business logic cannot be guaranteed when new logic wants to be implemented. Test Coverage should be enhanced as much as possible by developers.