DEV Community

sankhadeep
sankhadeep

Posted on

TDD with spring-boot: A struggle of an experienced developer

I was working on a spring boot microservice project, having a few services like user service, referral service, Product service etc. Previously in my 10+ years of software development journey, I never used TDD. I developed apps for iOS and worked on HFT software development using C/C++, but all unit tests were done manually. So it was a stiff learning curve, a process of unlearning. We were following the Extreme programming (XP) methodology. We were doing pair programming to write test cases. As I am taking a long break(I may never go back to the corporate world), I thought it would be better if I write down my struggle with TDD.
In this article, I will share some code snippets from a sample project I used to learn the concept of TDD.
We have the option to write test cases either by slicing the layer horizontally or vertically. They both have pros and cons. I used both approaches for the different services I was working on. Here I will try to recall the vertical slicing where we can code one feature starting from the controller layer and the service layer to the repository layer.
In this project, I used Spring Security and for the authentication, JWT was used. In the actual project, we used the antipattern where we use the single MySQL db for all the services. we used the Checkstyle static code analyzer but kept the test files out of its reach, by declaring

checkstyleTest.enabled = false

.For code coverage we used JaCoCo. here the feature we will develop is simple user registration, as we are working on the Userservice app.
_ GitHub link at the end of this article.

  • Gradle file Jacoco config
..... checkstyle { checkstyleTest.enabled = false } .... .... dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.kafka:spring-kafka' runtimeOnly 'com.h2database:h2' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.flywaydb:flyway-core' runtimeOnly 'org.flywaydb:flyway-mysql' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.kafka:spring-kafka-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'org.springframework.security:spring-security-test' } tasks.named('build') { dependsOn(installPrePushHook) } tasks.named('test') { useJUnitPlatform() finalizedBy jacocoTestReport // finalizedBy jacocoTestCoverageVerification } jacoco { toolVersion = "0.8.9" } ... jacocoTestReport { dependsOn test } jacocoTestCoverageVerification { violationRules { rule { enabled = true element = 'BUNDLE' limit { counter = 'INSTRUCTION' value = 'COVEREDRATIO' minimum = 0.4 } } } } jacocoTestReport { dependsOn test afterEvaluate { classDirectories.setFrom(files(classDirectories.files.collect { fileTree(dir: it, exclude: [ "com/sankha/userService/UserServiceApplication.class", ]) })) } } // to run coverage verification during the build (and fail when appropriate) check.dependsOn jacocoTestCoverageVerification 
Enter fullscreen mode Exit fullscreen mode

project sructure
Let's start from the controller layer, here is the code snippet for the controller layer, the Entry point for the testing.

@WebMvcTest(UserController.class) @ActiveProfiles("test") class UserControllerTest { @Autowired ObjectMapper objectMapper; @Autowired private MockMvc mockMvc; @Autowired private WebApplicationContext context; @MockBean private UserService userServiceMock; @MockBean private JwtService jwtService; @BeforeEach public void setup() { mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); } @Test void userShouldBeAbleToRegisterWithValidDetails() throws Exception { UserRequest userRequest = new UserRequest("abc@example.com", "9158986369", "Email", "password", Role.ADMIN); String json = objectMapper.writeValueAsString(userRequest); UUID referredByUserId = UUID.randomUUID(); mockMvc.perform(post("/users/register") .param("referredby", String.valueOf(referredByUserId)) .content(json) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isCreated()); verify(userServiceMock).register(userRequest, referredByUserId); } } 
Enter fullscreen mode Exit fullscreen mode

we declared MockMvc obj and defined the object inside the setup method using WebApplicationContext. MockMvc according to the document is "Main entry point for server-side Spring MVC test support.".We had to use the service layer mock objects as we had yet to write the service layer code. Then I had to follow the AAA and RGR while writing the test.AAA means Arrange, Act, and Assert.
Arranging the object required for the test.
Act or Action: perform the actual task like triggering the actual URL or function.
Assert: verify the result.
At first, the test method is bound to fail as there will be no actual functionality defined in the actual controller class. this is the Red part for (RGR). we would implement the method in the controller.

.... @PostMapping("/register") @ResponseStatus(HttpStatus.CREATED) void registerUser(@RequestBody @Valid UserRequest userRequest, @RequestParam(value = "referredby", required = false) UUID userId) { userService.register(userRequest, userId); } .... 
Enter fullscreen mode Exit fullscreen mode

so the test case will pass this time, it is called Green of (RGR). now we can focus on refactoring the code, the last part of (RGR). As the controller layer testing for the said feature is done, we can move to the next layer which is the service layer. In general, we need at least two test cases for each feature.
Service layer

@ExtendWith(SpringExtension.class) @ActiveProfiles("test") @AutoConfigureMockMvc public class UserServiceTest { ObjectMapper objectMapper; @Mock private UserRepository userRepository; @Mock private PasswordEncoder passwordEncoder; @InjectMocks private UserService userService; @Mock private JwtService jwtService; @BeforeEach void setup() { objectMapper = new ObjectMapper(); } @Test void userShouldBeCreatedWithDetailsProvided() { UserRequest userRequest = new UserRequest("abc@example.com", "9158986369", "Email", "password", Role.ADMIN); ArgumentCaptor<User> argumentCaptor = ArgumentCaptor.forClass(User.class); User user = objectMapper.convertValue(userRequest, User.class); when(userRepository.save(any(User.class))).thenReturn(user); when(passwordEncoder.encode(user.getPassword())).thenReturn(anyString()); userService.register(userRequest, null); verify(userRepository).save(argumentCaptor.capture()); User actualUser = argumentCaptor.getValue(); Assertions.assertEquals(userRequest.email(), actualUser.getEmail()); Assertions.assertEquals(userRequest.phoneNumber(), actualUser.getPhoneNumber()); Assertions.assertEquals(userRequest.phoneNumber(), actualUser.getPhoneNumber()); Assertions.assertEquals(userRequest.role(), actualUser.getRole()); Assertions.assertNotNull(actualUser.getPassword()); } ... 
Enter fullscreen mode Exit fullscreen mode

Here the service layer will save modify or fetch the data. The repository is yet to be set up, we need a mock of the repository object. Corresponding actual service class with the register method would look like the following:

@Service @RequiredArgsConstructor public class UserService { private final PasswordEncoder passwordEncoder; private final UserRepository repository; ObjectMapper mapper = new ObjectMapper(); public void register(UserRequest userRequest, UUID referredByUserId) { User user = extractUserFromRequest(userRequest); if (userExist(user)) { throw new UserAlreadyExistException(AppConstants.USER_ALREADY_EXIST); } User saved = repository.save(user); if (saved != null) { sendMessage(referredByUserId, saved); } ... 
Enter fullscreen mode Exit fullscreen mode

if all the tests are passed for the service layer, we can jump to the repository layer test cases. we will validate the entities before saving them to the database. TestEntityManager and validator will help in this matter.

@ExtendWith(SpringExtension.class) @DataJpaTest @ActiveProfiles("test") @Transactional class UserRepositoryTest { @Autowired private TestEntityManager entityManager; private Validator validator; private UserBuilder userBuilder; @Autowired private UserRepository repository; @BeforeEach void setup() { userBuilder = new UserBuilder(); validator = Validation.buildDefaultValidatorFactory().getValidator(); } @Test void shouldSaveUserToRepository() { User user = userBuilder.withEmail("abc@example.com") .withNumber("1234567892") .withPassword("password") .withPreference("Email") .build(); User actual = repository.save(user); Assertions.assertEquals(user.getEmail(), actual.getEmail()); Assertions.assertEquals(user.getPhoneNumber(), actual.getPhoneNumber()); Assertions.assertEquals(user.getPreference(), actual.getPreference()); } ... 
Enter fullscreen mode Exit fullscreen mode

user entity would look like: I used flyway for data migration

@AllArgsConstructor @NoArgsConstructor @Getter @Entity @Table(name = "users") @Builder public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "id", nullable = false) @JdbcTypeCode(SqlTypes.CHAR) private UUID id; @Email @Column(unique = true) private String email; @Column(unique = true) @NotBlank @Pattern(regexp = "(^$|[0-9]{10})") private String phoneNumber; @Column(nullable = false) @NotBlank private String preference; ... ... 
Enter fullscreen mode Exit fullscreen mode

this is a high-level overview of TDD that we used to do. I also did reactive java TDD, in which I had to think in a different way to setup a test case. I will keep editing this article from time to time.

Github link for this project github

Top comments (0)