How to make life easier for a programmer when writing tests

beeline cloud Let's talk about Spring boot and integration testing. I'll tell you how to simplify your life when writing tests.

Let's dive into the details…

Let's say 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 on 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 annotations:

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

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

testcontainers raise docker containers, so we select the desired tag on the dockerhub website. Calling, starting, stopping the container occurs directly from our test class, to do this, just add:

@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 it is necessary to execute some SQL script. For example, fill the created tables with data. To do this, just 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 data for connecting to the database will dynamically change:

@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 point 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 in order for the application to be considered ready to see the light of day.

I suggest starting with positive tests and adding a record to our service. To do 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 we need to get the record after creation. To do this, after the creation request, we will add a receive request.

Receiving occurs via the URL “/api/v1/{uuid}”. Where uuid — this is the ID of the newly created entity that is returned in the POST request. To get it, you need to slightly change the first query:

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 executing 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 we 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 carried out one of the positive testing scenarios. It can be improved, for example, by checking data directly in the database.

Now we are confident that we will receive a fully working project that meets the requested criteria. This scheme can be scaled for regression testing and automatic launch, and can also be easily transferred to QA. Those. cover types of integration testing before going on stage.

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

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *