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)