How to make a programmer’s life easier when writing tests

How to make a programmer’s life easier when writing tests

Image – Edge2Edge Media – Unsplash.com

Probably many of you have worked, or at least heard of, that there are developers who work on a project alone. Well, as one… There is a scrum, an analyst, a product, someone else up to the director, but there is only one programmer, there is not even a tester. In this case, the optimal type of testing, in my opinion, is integration testing using test containers.

Hello, Habre! My name is Mykola Piskunov – a leading developer in the Big Data division. And today on the beeline cloud blog we will talk about Spring boot and integration testing. I will tell you how to simplify life when writing tests.

Let’s dive into the details…

Suppose we have a controller with standard CRU operations:

@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/api/v1")
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class FooController {
 
   FooService fooService;
 
   @PostMapping
   public ResponseEntity<FooDto> create(@Valid @RequestBody FooDtoRequest request) {
       return ResponseEntity.ok(fooService.create(request));
   }
 
   @GetMapping
   public ResponseEntity<PagedFooDto> readAll(@RequestParam(value = "page", defaultValue = "0") Integer page,
                                          	@RequestParam(value = "pageSize", defaultValue = "15") Integer pageSize) {
 
       return ResponseEntity.ok(fooService.getFooDtoFromDB(page, pageSize));
   }
 
   @GetMapping(value = "/{uuid}")
   public ResponseEntity<FooDto> readOne(@PathVariable UUID uuid) {
       return ResponseEntity.ok(fooService.getOneFooDtoFromDB(uuid));
   }
 
   @PutMapping(value = "/{uuid}")
   public ResponseEntity<FooDto> update(@Valid @RequestBody FooDtoRequest request, @PathVariable UUID uuid) {
       FooDto response = fooService.update(request, uuid);
       return ResponseEntity.ok(response);
   }
 
   @DeleteMapping(value = "/{uuid}")
   public ResponseEntity<Map<String, String>> delete(@PathVariable UUID uuid) {
       fooService.delete(uuid);
       return ResponseEntity.ok(Map.of("status", "deleted"));
   }
}

And the objects we need look like this (for simplicity, let the fields in these objects be the same).

Request:

public record FooDtoRequest(
   UUID id,
 
   @NotBlank(message = "field must not be blank")
   String fooFieldOne,
 
   @NotBlank(message = "field must not be blank")
   String fooFieldTwo
) {
   @Builder
   public FooDtoRequest {}
}

Response:

public record FooDto (
   UUID id,
   String fooFieldOne,
   String fooFieldTwo
) {
   @Builder
   public FooDto {}
}

Behind the controller is a standard service class that performs CRUD operations with records in the database. We will cover the endpoints implemented in this controller with integration tests.

I mostly use maven in my projects. We connect the dependencies necessary for testing:

<dependency>
   <groupId>junit</groupId>
   <artifactId>junit</artifactId>
   <scope>test</scope>
</dependency>
<dependency>
   <groupId>org.testcontainers</groupId>
   <artifactId>junit-jupiter</artifactId>
   <version>${testcontainers.version}</version>
   <scope>test</scope>
</dependency>
<dependency>
   <groupId>org.testcontainers</groupId>
   <artifactId>spock</artifactId>
   <version>${testcontainers.version}</version>
   <scope>test</scope>
</dependency>
<dependency>
   <groupId>org.testcontainers</groupId>
   <artifactId>postgresql</artifactId>
   <version>${testcontainers.version}</version>
   <scope>test</scope>
</dependency>
<dependency>
   <groupId>io.rest-assured</groupId>
   <artifactId>rest-assured</artifactId>
   <version>${rest-assured.version}</version>
   <scope>test</scope>
</dependency>
<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-test</artifactId>
   <scope>test</scope>
   <exclusions>
       <exclusion>
       	<groupId>org.junit.vintage</groupId>
       	<artifactId>junit-vintage-engine</artifactId>
       </exclusion>
   </exclusions>
</dependency>

The test class must be marked with instructions:

@Slf4j
@DirtiesContext
@Testcontainers
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class FooTests {
…
}

In the example scheme, we will need a test database, I use Postgresql.

testcontainers docker containers are raised, so we select the desired tag on the dockerhub website.

@Container
public static final JdbcDatabaseContainer<?> postgreSQLContainer =
       new PostgisContainerProvider()
           	.newInstance("15-3.4")
           	.withDatabaseName("tests-db")
           	.withUsername("sa")
               .withPassword("sa");

After the container is initialized, sometimes you need to execute any SQL script. For example, fill the created tables with data. To do this, it is enough to place the file with SQL commands in the resources folder and add “.withInitScript(“test.sql”)”:

@Container
public static final JdbcDatabaseContainer<?> postgreSQLContainer =
       new PostgisContainerProvider()
           	.newInstance("15-3.4")
           	.withDatabaseName("tests-db")
           	.withUsername("sa")
           	.withPassword("sa")
           	.withInitScript("test.sql");

Testcontainers also allow us to dynamically control application characteristics. In our example, the database connection data will change dynamically:

@DynamicPropertySource
private static void datasourceConfig(DynamicPropertyRegistry registry) {
   registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl);
   registry.add("spring.datasource.username", postgreSQLContainer::getUsername);
   registry.add("spring.datasource.password", postgreSQLContainer::getPassword);
}

At this stage, we are ready to write tests, and the class itself should look something like this:

@Slf4j
@DirtiesContext
@Testcontainers
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class FooTests {
 
   @LocalServerPort
   Integer port;
 
   @Container
   public static final JdbcDatabaseContainer<?> postgreSQLContainer =
       	new PostgisContainerProvider()
               	.newInstance("15-3.4")
               	.withDatabaseName("tests-db")
               	.withUsername("sa")
               	.withPassword("sa");
 
   @DynamicPropertySource
   private static void datasourceConfig(DynamicPropertyRegistry registry) {
       registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl);
       registry.add("spring.datasource.username", postgreSQLContainer::getUsername);
       registry.add("spring.datasource.password", postgreSQLContainer::getPassword);
   }
 
   @BeforeEach
   void setUp() {
       RestAssured.baseURI = "http://localhost:" + port;
   }
}

For example, we have a list of test cases – they must work correctly so that it is considered that the program is ready to see the light.

I suggest starting with positive tests and adding a record to our service. For this, we use the given() method from the RestAssured library:

import static io.restassured.RestAssured.given;

And let’s add the first test:

@Test
void goodTestCases() {
   FooDtoRequest request = FooDtoRequest.builder()
       	.fooFieldOne("fooFieldOne")
       	.fooFieldTwo("fooFieldTwo")
       	.build();
 
   given()
       	.contentType(ContentType.JSON)
       	.body(b) // задаем тело запроса
       	.when()
       	.post("/api/v1") // выполняем запрос
       	.then()
       	.statusCode(200) // проверяем статус ответа
       	// проверяем корректность заполнения полей ответа
       	.body("fooFieldOne", equalTo(request.fooFieldOne()))
       	.body("fooFieldTwo", equalTo(requestb.fooFieldTwo()))
           .log();
}

Now you need to get the record after creation. To do this, after the request for creation, we will add a request for receiving.

The reception takes place at the url “/api/v1/{uuid}”. Where uuid is the ID of the newly created entity that is returned on the POST request. To get it, you need to change the first query a bit:

FooDto response = given()
       .contentType(ContentType.JSON)
       .body(request)
       .when()
       .post("/api/v1")
       .as(FooDto.class);

Now this is an object from which you can get the ID and nothing prevents you from performing a GET request:

given()
       .contentType(ContentType.JSON)
       .pathParam("uuid", response.id())
       .when()
       .get("/api/v1/{uuid}")
       .then()
   	.statusCode(200)
   	// проверяем корректность заполнения полей ответа
   	.body("fooFieldOne", equalTo(request.fooFieldOne()))
       .body("fooFieldTwo", equalTo(request.fooFieldTwo()));

Then update the object:

request = FooDtoRequest.builder()
       .fooFieldOne("NEWFieldOne")
       .fooFieldTwo("NEWFieldTwo")
       .build();
 
given()
       .contentType(ContentType.JSON)
       .body(request)
       .pathParam("uuid", response.id())
       .when()
       .put("/api/v1/{uuid}")
       .then()
       .statusCode(200)
   	// проверяем корректность заполнения полей ответа
   	.body("fooFieldOne", equalTo(request.fooFieldOne()))
       .body("fooFieldTwo", equalTo(request.fooFieldTwo()));

And delete:

given()
       .contentType(ContentType.JSON)
       .pathParam("uuid", response.id())
       .when()
       .delete("/api/v1/{uuid}")
       .then()
       .statusCode(200);

So, we conducted one of the positive testing scenarios. It can be improved, for example, by checking data directly in the database.

We are now confident that we will receive a fully working project that meets the requested criteria. This scheme can be scaled for regression testing and auto-run, and easily handed off to QA. That is, cover the types of integration testing before exit stages.

beeline cloud– Secure cloud provider. We develop cloud solutions so that you provide customers with the best services.

Related posts