Spring Boot Native Test Automation of Simple Registration Application

Spring Boot is one of the most used frameworks that is used to develop backend applications. To be sure that everything is working as expected we need to test our application.

Native test automation directly tests the code of the application and it requires knowledge of the framework that the application was developed on.

This blog explains the test automation of Spring Boot application components which was developed for this article. The application and all test automation code can be downloaded from GitHub.

The simple spring boot application registers users that come as RESTful HTTP requests into the database. If everything with the request is OK, then the app returns a successful response. Otherwise, the app will return error messages to the user inside a response body. Test automation will ensure that the applications handle valid and invalid data correctly and everything works fine.

Please note that this application does not cover security and all validation implementations. The aim of this article is just to give an elementary-level Spring Boot test automation tutorial.

Installation

Since the aim of this article is not the installation of Spring Boot, I only provide a list of required tools.

  • Java 21: Language of the application.
  • PostgreSQL 16: Data is saved in this database.
  • Postman (optional): The application to make HTTP requests.
  • DBeaver (optional): Open source application to manage database visually.
  • IntelliJ IDEA Community Edition (optional): Free IDE that is mostly preferred by Java developers.

In this project, we use Maven, Spring Boot 3.3.0, Jar as packaging, and Java 21.

Packages

To install the required packages you should look to the pom.xml file and then sync it.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.3.0</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>az.isfan</groupId>
	<artifactId>automation</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>chat</name>
	<description>Spring Boot Test Automation </description>
	<properties>
		<java.version>21</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
			<version>3.3.0</version>
		</dependency>
		<dependency>
			<groupId>org.postgresql</groupId>
			<artifactId>postgresql</artifactId>
			<scope>runtime</scope>
			<version>42.7.3</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-jpa</artifactId>
			<version>3.3.0</version>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<optional>true</optional>
			<version>1.18.32</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-validation</artifactId>
			<version>3.3.0</version>
		</dependency>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
			<version>3.3.0</version>
    </dependency>
    <dependency>
			<groupId>com.h2database</groupId>
			<artifactId>h2</artifactId>
			<scope>test</scope>
			<version>2.2.224</version>
    </dependency>
	</dependencies>
	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
				<configuration>
					<excludes>
						<exclude>
							<groupId>org.projectlombok</groupId>
							<artifactId>lombok</artifactId>
						</exclude>
					</excludes>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

Here are the short explanations of why we need all of these packages.

  • spring-boot-starter-web: This is needed to make HTTP requests like get and post.
  • spring-boot-starter-data-jpa: JPA is a known pack to handle database relations.
  • lombok: A useful package to prevent writing constructors, getters, and setters in code for nice and readable code.
  • spring-boot-starter-validation: This package is used to validate request data. i.e. is a valid email.
  • spring-boot-starter-test: This is needed to implement basic JUnit tests.
  • h2: This is used for mocking the database. It simulates a database by using RAM and prevents implementing tests on real databases

To sync Maven right click on any place in the pom.xml file, go to “Maven”, and press the “Reload project” button. This process will automate the download of required packages to the project.

Sync Maven

Configuration

To configure the database the application.properties file was renamed to application.yml file.

Location of application.yml

The application.yml will give information to the code on how to connect to PostgreSQL.

spring:
  datasource:
    url: jdbc:postgresql://localhost:5433/automation
    username: postgres
    password: admin
    driver-class-name: org.postgresql.Driver
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true
    properties:
      hibernate:
        format_sql: true
    database: postgresql

If you are using the DBeaver application then right-click on the database and click the “Create New Database” button the type database name as automation.

DBeaver create database

To get the URL and port of the database by using DBeaver click on “postgres” and press the “Edit Connection” button.

Url and port of database

ddl-aut variable with create value will create new tables each time. In other words, when you run the application each time the old values will be deleted. If you want to keep old values in the database then give update or none values to the ddl-aut variable.

password and username values are configured during installing the PostgreSQL.

SpringBootApplication

The root class of the application is annotated by @SpringBootApplication. This class starts the application context and handles calling other classes automatically.

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }
}

I renamed the file name to App.java file. No method is called inside this file in the scope of this tutorial post.

Root class of the application

Run Application

In order to be sure that everything is working you can start to run the application itself. To run the application open the root (App.java) class and press the green triangle button on IntelliJ IDE.

Run Spring Boot Application

If there will not be any exception message in the terminal then it is working.

Also, you can test the application in action by using Postman software.

The URL will be localhost:8080/api/v1/user/register. Then select POST, Body, raw, and JSON format as parameters in Postman.

Postman parameters

Write the body of the request as shown below JSON code and then click to “Send” button.

{
    "nick-name": "isfzade",
    "password": "123456",
    "email": "info@isfan.az"
}

The response status will be 201 Created and the body will be as follows.

{
    "errors": null,
    "response": {
        "id": 1,
        "nick-name": "isfzade"
    },
    "is-successful": true
}

Also, you should see the data on the database.

Data in PostgreSQL database

In order to see the error response write the request body as shown below.

{
    "nick-name": "isfzade",
    "password": "",
    "email": ""
}

The error response status be 400 Bad Request and the body will be as shown below.

{
    "errors": [
        {
            "code": 1006,
            "type": "PASSWORD_EMPTY",
            "message": "Password cannot be empty"
        },
        {
            "code": 1002,
            "type": "EMAIL_EMPTY",
            "message": "Email cannot be empty"
        }
    ],
    "response": null,
    "is-successful": false
}

Run Tests

The automated tests are located inside the test folder and have the exact folder/file structure of the application (under the main folder).

Folder structure of tests

In order to run all tests inside a folder right click and then press “Run Tests”.

Run tests that are inside a folder

Also, it is possible to run a single test. Select any test in the test class and press the green triangle button on the IntelliJ IDE.

Run single test

The results of the tests will be shown on the “Run” window of the IntelliJ IDE.

Test Results

Mapper Testing

It is a good idea always to start writing tests for the class with fewer or no injections. To do so a mapper service is the ideal starting point. The mapper is a class to convert some object to another one.

@Service
public class UserMapper {
    private Logger logger = LoggerFactory.getLogger(UserMapper.class);

    public User toUser(UserRegisterDto userRegisterDto) {
        logger.info("toUser: userRegisterDto = {}", userRegisterDto);

        var user = User.builder()
                .nickName(userRegisterDto.nickName())
                .email(userRegisterDto.email())
                .password(userRegisterDto.password())
                .build();
        return user;
    }

    public UserResponseDto toUserResponseDto(User user) {
        logger.info("toUserResponseDto: ");

        var userResponseDto = new UserResponseDto(
                user.getId(),
                user.getNickName()
        );
        return userResponseDto;
    }

    public ResponseDto<UserResponseDto> toResponseDto(UserResponseDto userResponseDto) {
        logger.info("toResponseDto: userResponseDto = {}", userResponseDto);

        var responseDto = new ResponseDto(
                true,
                null,
                userResponseDto
        );
        return responseDto;
    }
}

The User class keeps the password of the user and when another user requests information about the user we send only limited information.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue
    private Integer id;

    @Column(
            name = "nick_name",
            unique = true,
            nullable = false
    )
    private String nickName;

    @Column(
            unique = true,
            nullable = false
    )
    private String email;

    @Column(
            nullable = false
    )
    private String password;
}

The mapper converts the User object to the UserResponseDto object, which contains only nickname information.

public record UserResponseDto(
        Integer id,
        @JsonProperty("nick-name")
        String nickName
) {}

For the mappers, JUnit5 is the only package that is used as a testing tool. @BeforeEach and @Test annotations are coming from JUnit. Since UserMapper does not needs any injection we initialize it inside the setUp() method. The setUp() method will be called when each test starts due to @BeforeEach annotation.

@BeforeEach
void setUp(){
    userMapper = new UserMapper();
}

The aim here is to be sure that the provided test conditions will be satisfied when something changes in the main code.

import az.isfan.automation.user.dto.UserRegisterDto;
import az.isfan.automation.user.dto.UserResponseDto;
import az.isfan.automation.user.models.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class UserMapperTest {
    private UserMapper userMapper;
    private String nickname = "testNickName";
    private String password = "testPassword";
    private String email = "test@isfan.az";

    @BeforeEach
    void setUp(){
        userMapper = new UserMapper();
    }

    @Test
    public void toUser_convertCorrectly() {
        var userDto = new UserRegisterDto(
                nickname,
                password,
                password
        );
        var user = userMapper.toUser(userDto);
        assertNotNull(user, "User cannot be null");
        assertNotNull(user.getNickName(), "Nickname is null");
        assertNotNull(user.getEmail(), "Email is null");
        assertNotNull(user.getPassword(), "Password is null");
        assertNull(user.getId(), "Id should be null");
        assertEquals(userDto.nickName(), user.getNickName(), "Nickname is incorrect");
        assertEquals(userDto.password(), user.getPassword(), "Password is incorrect");
        assertEquals(userDto.email(), user.getEmail(), "Email is incorrect");
    }

    @Test
    public void toUserResponseDto_convertCorrectly() {
        var user = User.builder()
                .nickName(nickname)
                .password(password)
                .email(email)
                .build();
        var userResponseDto = userMapper.toUserResponseDto(user);
        assertNotNull(userResponseDto.nickName(), "Nickname is null");
        assertEquals(user.getNickName(), userResponseDto.nickName(), "Nickname is incorrect");
        assertNull(userResponseDto.id(), "Id should be null");
    }

    @Test
    public void toResponseDto_convertCorrectly() {
        var userResponseDto = new UserResponseDto(
                123,
                nickname
        );
        var responseDto = userMapper.toResponseDto(userResponseDto);
        assertNotNull(responseDto.isSuccessful(), "isSuccessful is null");
        assertNotNull(responseDto.response(), "response is null");
        assertNull(responseDto.errors(), "errors should be null");
        assertEquals(true, responseDto.isSuccessful(), "isSuccessful");
        assertEquals(userResponseDto.nickName(), responseDto.response().nickName(), "nickName is incorrect");
    }
}

The UserMapper has methods to convert one class or record to another for different purposes. Depending on the purpose usage scenario can be; data to save a database, data that comes from the user, and data to send to the user. The purpose of each object in the registration application is:

  • User: The class is directly saved to the database. Contains nickname, email, and password information.
  • UserRegisterDto: The record that the user sends as a request when registered. Contains nickname, email, and password information
  • UserResponseDto: The record that the server sends inside response. Only contains the id and nickname.
  • ResponseDto: The record that the server sends as a response. Contains errors, is-successful, and UserResponseDto.

assertNotNull, assertNull, and assertEquals are the assertion methods that come with the JUnit package. If the condition is not satisfied then these methods will throw AssertionFailedError and the test will fail.

assertNotNull(user, "User cannot be null");
assertNull(user.getId(), "Id should be null");
assertEquals(userDto.nickName(), user.getNickName(), "Nickname is incorrect");

Assertion methods are easy to use. The name of assertion methods is self-explanatory. assertNull() method checks if the variable is null or not. If it will not null then it will throw an exception. The message that is written inside the method will be displayed when the test fails.

assertNull(user.getId(), "Id should be null");

assertEquals() method asserts if the value is equal to the expected value. The expected value should be written first, then the variable that we want to check, and an optional fail message can be provided.

assertEquals(userDto.nickName(), user.getNickName(), "Nickname is incorrect");

Validator Testing

The validator is the important component of Spring Boot which validates the data that is coming from the user. The Validator class has capability to check if the value is empty or email is a valid email.

The validation annotations are used inside data that comes from the user.

public record UserRegisterDto(
        @JsonProperty("nick-name")
        @NotEmpty(message = UserErrorMessages.NICK_NAME_EMPTY)
        String nickName,

        @NotEmpty(message = UserErrorMessages.PASSWORD_EMPTY)
        String password,

        @NotEmpty(message = UserErrorMessages.EMAIL_EMPTY)
        @Email(message = UserErrorMessages.EMAIL_VALID)
        String email
) {}

When user tries to register we want to validate that none of the fields are empty by using @NotEmpty annotations.

@NotEmpty(message = UserErrorMessages.NICK_NAME_EMPTY)
String nickName,

In addition, we want to be sure the user email will be valid email like info@isfan.az instead of info@isfan by using @Email annotation.

@Email(message = UserErrorMessages.EMAIL_VALID)
String email

Spring Boot made validation testing very easy by calling only two methods. To call the validator object inside the test class, Validation.buildDefaultValidatorFactory(), factory.getValidator() is called before each class.

private Validator validator;

@BeforeEach
void setUp() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    this.validator = factory.getValidator();
}

Valid and invalid data scenarios are tested to observe and assert expected reactions. The validate() method of the Validator class will return a set of violations if validation fails. If validation passes then the method will return an empty set.

Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);

All validator tests are given as follows.

class RestValidatorTest {

    private Validator validator;

    private String nickname = "testNickName";
    private String password = "testPassword";
    private String email = "test@isfan.az";

    @BeforeEach
    void setUp() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        this.validator = factory.getValidator();
    }

    @Test
    public void correctVariables_noViolations() {
        var userRegisterDto = new UserRegisterDto(
                nickname,
                password,
                email
        );

        Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);
        assertTrue(violations.isEmpty(), "No violation was expected");
    }

    @Test
    public void allIncorrectVariables_ThreeViolations() {
        var userRegisterDto = new UserRegisterDto(
                null,
                null,
                null
        );

        Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);
        assertEquals(3, violations.size(), "3 violations were expected");
    }

    @Test
    public void nullEmail_correctMessage() {
        var userRegisterDto = new UserRegisterDto(
                nickname,
                password,
                null
        );

        Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);
        assertEquals(1, violations.size(), "1 violations were expected");
        var violation = violations.iterator().next();
        assertEquals(UserErrorMessages.EMAIL_EMPTY, violation.getMessage(), "Message was incorrect");
    }

    @Test
    public void nullNickName_correctMessage() {
        var userRegisterDto = new UserRegisterDto(
                null,
                password,
                email
        );

        Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);
        assertEquals(1, violations.size(), "1 violations were expected");
        var violation = violations.iterator().next();
        assertEquals(UserErrorMessages.NICK_NAME_EMPTY, violation.getMessage(), "Message was incorrect");
    }

    @Test
    public void nullPassword_correctMessage() {
        var userRegisterDto = new UserRegisterDto(
                nickname,
                null,
                email
        );

        Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);
        assertEquals(1, violations.size(), "1 violations were expected");
        var violation = violations.iterator().next();
        assertEquals(UserErrorMessages.PASSWORD_EMPTY, violation.getMessage(), "Message was incorrect");
    }

    @Test
    public void invalidEmail_correctMessage() {
        var userRegisterDto = new UserRegisterDto(
                nickname,
                password,
                "invalidEmail"
        );

        Set<ConstraintViolation<UserRegisterDto>> violations = validator.validate(userRegisterDto);
        assertEquals(1, violations.size(), "1 violation was expected");
        var violation = violations.iterator().next();
        assertEquals(UserErrorMessages.EMAIL_VALID, violation.getMessage(), "Message was incorrect");
    }
}

Repository Testing

Repository is an interface that extends Spring Boots JpaRepository which contains predefined methods to save, find, and delete data from the database.

Also, it is possible to write custom methods that use custom SQL queries. getByNickName() and getByEmail() methods are artificially created in the scope of this project to find users with different search parameters.

public interface UserRepo extends JpaRepository<User, Integer> {
    @Query(value="SELECT u FROM User u WHERE u.nickName=:nick_name")
    User getByNickName(@Param("nick_name") String nickName);

    @Query(value="SELECT u FROM User u WHERE u.email=:email")
    User getByEmail(@Param("email") String email);
}

In reality, it is not logical to make tests on a real database. Since a real database will contain real users’ information testing will add mock users into it and may harm the real users’ information. Due to this reason, Spring Boot provides @DataJpaTest annotation to create a mock database on memory instead of storage. Any operation in the tests will not effect the real database.

@DataJpaTest
class UserRepoTest {}

We inject UserRepo class by using @Autowired annotation.

@Autowired
private UserRepo userRepo;

Whole repository testing will be as given below code.

import az.isfan.automation.user.models.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.DataIntegrityViolationException;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest
class UserRepoTest {
    private String nickname = "testNickName";
    private String password = "testPassword";
    private String email = "test@isfan.az";

    @Autowired
    private UserRepo userRepo;

    @Test
    public void correctVariables_saves() {
        User user = User.builder()
                        .nickName(nickname)
                        .password(password)
                        .email(email)
                        .build();
        var savedUser = userRepo.saveAndFlush(user);
        assertNotNull(savedUser, "Saved user should not be null");
        assertEquals(user.getNickName(), savedUser.getNickName(), "Nickname is incorrect");
        assertEquals(user.getEmail(), savedUser.getEmail(), "Email is incorrect");
        assertEquals(user.getPassword(), savedUser.getPassword(), "Password is incorrect");
    }

    @Test
    public void getUserByNickname_returnsCorrectUser() {
        User user = User.builder()
                .nickName(nickname)
                .password(password)
                .email(email)
                .build();
        userRepo.saveAndFlush(user);
        var userFromDb = userRepo.getByNickName(nickname);
        assertNotNull(userFromDb, "Saved user should not be null");
        assertEquals(user.getNickName(), userFromDb.getNickName(), "Nickname is incorrect");
    }

    @Test
    public void getUserByEmail_returnsCorrectUser() {
        User user = User.builder()
                .nickName(nickname)
                .password(password)
                .email(email)
                .build();
        userRepo.saveAndFlush(user);
        var userFromDb = userRepo.getByEmail(email);
        assertNotNull(userFromDb, "Saved user should not be null");
        assertEquals(user.getEmail(), userFromDb.getEmail(), "Email is incorrect");
    }

    @Test
    public void getNotExistingUserByNickname_returnsNull() {
        var userFromDb = userRepo.getByNickName("incorrectNickname");
        assertNull(userFromDb, "No user was expected");
    }

    @Test
    public void getNotExistingUserEmail_returnsNull() {
        var userFromDb = userRepo.getByEmail("incorrectEmail");
        assertNull(userFromDb, "No user was expected");
    }

    @Test
    public void saveWithDuplicateNickName_throwsException() {
        User user1 = User.builder()
                .nickName(nickname)
                .password(password)
                .email(email)
                .build();
        User user2 = User.builder()
                .nickName(nickname)
                .password(password)
                .email("email")
                .build();
        assertDuplicateField(user1, user2);
    }

    @Test
    public void saveWithDuplicateEmail_throwsException() {
        User user1 = User.builder()
                .nickName(nickname)
                .password(password)
                .email(email)
                .build();
        User user2 = User.builder()
                .nickName("nickname")
                .password(password)
                .email(email)
                .build();
        assertDuplicateField(user1, user2);
    }

    @Test
    public void saveWithNullFields_throwsException() {
        // Nickname
        User user = User.builder()
                .nickName(null)
                .password(password)
                .email(email)
                .build();
        assertNullField(user);

        // Password
        user = User.builder()
                .nickName(nickname)
                .password(null)
                .email(email)
                .build();
        assertNullField(user);

        // Email
        user = User.builder()
                .nickName(nickname)
                .password(password)
                .email(null)
                .build();
        assertNullField(user);
    }

    private void assertDuplicateField(User user1, User user2) {
        userRepo.saveAndFlush(user1);
        assertThrows(
                DataIntegrityViolationException.class,
                () -> { userRepo.saveAndFlush(user2); }
        );
    }

    private void assertNullField(User user) {
        assertThrows(
                DataIntegrityViolationException.class,
                () -> { userRepo.saveAndFlush(user); }
        );
    }
}

By using assertThrows() it is possible to assert if the method throws an exception.

assertThrows(
        DataIntegrityViolationException.class,
        () -> { userRepo.saveAndFlush(user); }
);

The private methods that do not have @Test annotation will not be considered as tests. They are used to decrease the code repetition.

@Test
public void saveWithNullFields_throwsException() {
    // Nickname
    User user = User.builder()
            .nickName(null)
            .password(password)
            .email(email)
            .build();
    assertNullField(user);
}

private void assertNullField(User user) {}

saveAndFlush() is used to get an exception when database validation (unique or nullable fields) is not satisfied. Therefore, in tests, we are not using the save() method to check the database validation.

userRepo.saveAndFlush(user);

Service Testing

Services are the classes that provide business functionality. Differently than the mapper service, there might be a service between the repository and the REST controller.

UserService is the class that has the register() method and this method is responsible for data mapping, validation, and database actions.

@Service
public class UserService {
    private Logger logger = LoggerFactory.getLogger(UserService.class);

    @Autowired
    public UserService(UserRestValidator userRestValidator, UserRepo userRepo, UserMapper userMapper) {
        this.userRestValidator = userRestValidator;
        this.userRepo = userRepo;
        this.userMapper = userMapper;
    }

    private final UserRestValidator userRestValidator;
    private final UserRepo userRepo;
    private final UserMapper userMapper;


    public ResponseDto<UserResponseDto> register(
            UserRegisterDto userRegisterDto
    ) throws UserExceptions {
        logger.info("register: ");

        userRestValidator.validate(userRegisterDto);
        var user = userMapper.toUser(userRegisterDto);
        var userFromDb = userRepo.save(user);
        var userResponseDto = userMapper.toUserResponseDto(userFromDb);
        return userMapper.toResponseDto(userResponseDto);
    }
}

UserService requires dependencies inside the constructor and they are injected by using @Autowired annotation.

In order to initialize UserService we need to initialize dependent classes too. However, we aim to test the service independently from other objects. We already tested the injected classes in their test classes.

To call the UserService class inside the test class we put we use the openMocks() method before each test.

@BeforeEach
public void setUp() {
    MockitoAnnotations.openMocks(this);
}

In order to inject dependencies into the test class, we use the @Mock annotation to initialize dependencies and the @InjectMocks annotation to inject mock dependencies into UserService object.

@InjectMocks
private UserService userService;

@Mock
private UserRestValidator userRestValidator;
@Mock
private UserRepo userRepo;
@Mock
private UserMapper userMapper;

When the method of a mock class is called it will return a null value since it is an imitation. Therefore, the when() method of the Mockito class is used to specify what the mock method should return when it is called.

var userRegisterDto = new UserRegisterDto(
                nickname,
                password,
                email
        );
var user = User.builder().build();
when(userMapper.toUser(userRegisterDto)).thenReturn(user);

We manually create data objects and return them through the mock method. The aim here is not to check if the mapper converts data correctly and we already did it inside mapper tests. The aim is to check if the business logic of the service is correct. Therefore, we try to verify if the dependency method is called inside service method.

By using Mockito‘s times() method we verify that the toUser() method is called only once.

verify(userMapper, times(1)).toUser(userRegisterDto);

To assert if the method has never been called we use the never() method.

verify(userMapper, never()).toUser(userRegisterDto);

All in together the UserServiceTest class looks as shown below.

import az.isfan.automation.common.dto.ResponseDto;
import az.isfan.automation.user.dto.UserRegisterDto;
import az.isfan.automation.user.dto.UserResponseDto;
import az.isfan.automation.user.enums.UserErrors;
import az.isfan.automation.user.exceptions.UserException;
import az.isfan.automation.user.exceptions.UserExceptions;
import az.isfan.automation.user.models.User;
import az.isfan.automation.user.repos.UserRepo;
import az.isfan.automation.user.validators.UserRestValidator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class UserServiceTest {
    private String nickname = "testNickName";
    private String password = "testPassword";
    private String email = "test@isfan.az";

    @InjectMocks
    private UserService userService;

    @Mock
    private UserRestValidator userRestValidator;
    @Mock
    private UserRepo userRepo;
    @Mock
    private UserMapper userMapper;

    @BeforeEach
    public void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    public void registerWithValidFields_correctMethodsCalled() throws UserExceptions {
        var userRegisterDto = new UserRegisterDto(
                nickname,
                password,
                email
        );
        var user = User.builder().build();
        var userResponseDto = new UserResponseDto(0, nickname);
        ResponseDto<UserResponseDto> response = new ResponseDto(
                true,
                null,
                userResponseDto
        );

        when(userMapper.toUser(userRegisterDto)).thenReturn(user);
        when(userRepo.save(user)).thenReturn(user);
        when(userMapper.toUserResponseDto(user)).thenReturn(userResponseDto);
        when(userMapper.toResponseDto(userResponseDto)).thenReturn(response);

        var returnedResponse = assertDoesNotThrow(
                () -> userService.register(userRegisterDto), "Register should not throw exception"
        );

        verify(userRestValidator, times(1)).validate(userRegisterDto);
        verify(userMapper, times(1)).toUser(userRegisterDto);
        verify(userRepo, times(1)).save(user);
        verify(userMapper, times(1)).toUserResponseDto(user);
        verify(userMapper, times(1)).toResponseDto(userResponseDto);
        assertNotNull(returnedResponse, "Response should not be null");
        assertEquals(response.response(), returnedResponse.response(), "Returned response is not correct");
    }

    @Test
    public void exceptionThrown_rightMethodsCalled() throws UserExceptions {
        var userRegisterDto = new UserRegisterDto(
                null,
                password,
                email
        );
        var exception = new UserException(
                UserErrors.UNKNOWN.code,
                UserErrors.UNKNOWN,
                "Error Message"
        );
        var exceptions = new UserExceptions(
                new ArrayList(List.of(exception))
        );
        var user = User.builder().build();
        var userResponseDto = new UserResponseDto(0, nickname);

        doThrow(exceptions).when(userRestValidator).validate(userRegisterDto);

        assertThrows(
                UserExceptions.class,
                () -> userService.register(userRegisterDto), "Register should throw exception"
        );

        verify(userRestValidator, times(1)).validate(userRegisterDto);
        verify(userMapper, never()).toUser(userRegisterDto);
        verify(userRepo, never()).save(user);
        verify(userMapper, never()).toUserResponseDto(user);
        verify(userMapper, never()).toResponseDto(userResponseDto);
    }
}

Since, UserService‘s register() method may throw an exception, exceptionThrown_rightMethodsCalled() also will throw too.

@Test
public void exceptionThrown_rightMethodsCalled() throws UserExceptions {}

However, the class will not throw anything in reality since we handled exception throwing with the assertThrows() method. Therefore, it is safe to declare that the test class may throw and it will not cause any crash during testing.

assertThrows(
    UserExceptions.class,
    () -> userService.register(userRegisterDto), "Register should throw exception"
);

The function that throws an exception is the UserRestValidator‘s validate() method. That function returns void and throws exceptions when the data is not valid. Since it is the void function we should use doThrow() instead of when().thenThrow().

doThrow(exceptions).when(userRestValidator).validate(userRegisterDto);

Rest Controller Testing

The REST controller is the layer that communicates with a user through HTTP.

In order to mock HTTP requests Spring Boot’s @WebMvcTest annotation is used. Inside the annotation, we declare our controller class.

@WebMvcTest(controllers = UserController.class)
class UserHttpRequestTest {}

In order to use, the mock post method we inject the MockMvc object into the test class.

@Autowired
private MockMvc mockMvc;

To convert requests and responses to different types we inject ObjectMapper.

@Autowired
private ObjectMapper objectMapper;

In order to inject a mock of our own services we use @MockBean annotation instead of @Mock.

@MockBean
private UserService userService;

@MockBean
private UserExceptionService userExceptionService;

To make a post request we first write the whole URI (portion after localhost:8080). Then we convert our request object by using the writeValueAsString() method.

ResultActions response = mockMvc.perform(
        post("/api/v1/user/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(
                        objectMapper.writeValueAsString(userRegisterDto)
                )
);

We get a response from MockMvc as a ResultActions object and it has its assertion methods. By using the andExpect() method we assert conditions.

The MockMvcResultMatchers class provides field calling and matching methods. status() returns the HTTP status (i.e. 201 Created) and by using the isCreated() method we are matching the result.

response
    .andExpect(MockMvcResultMatchers.status().isCreated())
    .andExpect(MockMvcResultMatchers.jsonPath("$.is-successful").value(true))

Also, using the jsonPath() method we get the JSON field. There should be $. at the beginning of the string, and then we write the field name. The corresponding JSON will look like as shown below.

{
    "errors": null,
    "response": {
        "id": 1,
        "nick-name": "isfzade"
    },
    "is-successful": true
}

If we want to assert a list (i.e. errors field) then we should write the index in front of the field name.

{
    "errors": [
        {
            "code": 1004,
            "type": "PASSWORD_EMPTY",
            "message": "Password cannot be empty"
        }
    ],
    "response": null,
    "is-successful": false
}
.andExpect(MockMvcResultMatchers.jsonPath("$.errors[0].code").value(exception.getCode()))

The whole controller tests will be as shown below code.

import az.isfan.automation.common.dto.ErrorDto;
import az.isfan.automation.common.dto.ResponseDto;
import az.isfan.automation.user.dto.UserRegisterDto;
import az.isfan.automation.user.dto.UserResponseDto;
import az.isfan.automation.user.enums.UserErrors;
import az.isfan.automation.user.exceptions.UserException;
import az.isfan.automation.user.exceptions.UserExceptions;
import az.isfan.automation.user.services.UserExceptionService;
import az.isfan.automation.user.services.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.hamcrest.Matchers;
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.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import java.util.ArrayList;
import java.util.List;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

@WebMvcTest(controllers = UserController.class)
class UserHttpRequestTest {
    private String nickname = "testNickName";
    private String password = "testPassword";
    private String email = "test@isfan.az";

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private UserService userService;
    @MockBean
    private UserExceptionService userExceptionService;

    @Test
    public void postRequest_returnsCorrectValue() throws Exception {
        var userRegisterDto = new UserRegisterDto(
                nickname,
                password,
                email
        );
        var userResponseDto = new UserResponseDto(
                123,
                userRegisterDto.nickName()
        );
        var responseDto = new ResponseDto<UserResponseDto>(
                true,
                null,
                userResponseDto
        );
        when(userService.register(userRegisterDto)).thenReturn(responseDto);

        ResultActions response = mockMvc.perform(
                post("/api/v1/user/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(
                                objectMapper.writeValueAsString(userRegisterDto)
                        )
        );

        response
                .andExpect(MockMvcResultMatchers.status().isCreated())
                .andExpect(MockMvcResultMatchers.jsonPath("$.is-successful").value(true))
                .andExpect(MockMvcResultMatchers.jsonPath("$.response").value(Matchers.notNullValue()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.response.nick-name").value(nickname))
                .andExpect(MockMvcResultMatchers.jsonPath("$.errors").value(Matchers.nullValue()));
    }

    @Test
    public void inValidPostRequest_returnsCorrectValue() throws Exception {
        var userRegisterDto = new UserRegisterDto(
                null,
                password,
                email
        );
        UserException exception = new UserException(
                UserErrors.UNKNOWN.code,
                UserErrors.UNKNOWN,
                "Error Message"
        );
        UserExceptions exceptions = new UserExceptions(
                new ArrayList(List.of(exception))
        );
        var errorDto = new ErrorDto(
                exception.getCode(),
                exception.getType().toString(),
                exception.getMessage()
        );
        var errorDtoList = List.of (errorDto);
        var responseDto = new ResponseDto<List<ErrorDto>>(
                false,
                errorDtoList,
                null
        );

        when(userService.register(userRegisterDto)).thenThrow(exceptions);
        when(userExceptionService.getErrorResponse(exceptions)).thenReturn(responseDto);

        ResultActions response = mockMvc.perform(
                post("/api/v1/user/register")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(
                                objectMapper.writeValueAsString(userRegisterDto)
                        )
        );

        response
                .andExpect(MockMvcResultMatchers.status().isBadRequest());

        response
                .andExpect(MockMvcResultMatchers.status().isBadRequest())
                .andExpect(MockMvcResultMatchers.jsonPath("$.response").value(Matchers.nullValue()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.is-successful").value(false))
                .andExpect(MockMvcResultMatchers.jsonPath("$.errors").value(Matchers.notNullValue()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.errors[0]").value(Matchers.notNullValue()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.errors[0].code").value(exception.getCode()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.errors[0].type").value(exception.getType().toString()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.errors[0].message").value(exception.getMessage()));
    }
}