Test-Driven, API-first REST API development

Andrey Lebedev
19 min readJan 18, 2024

--

In this article, I want to embrace two topics related to API development: first, to demonstrate how the development of the API in a Spring Boot application can be done through “under-the-skin” Spring Boot integration tests, and second, include the topic of API-first approach when the API specification is given in an OpenAPI or Swagger document, and the corresponding class are generated by the maven plugin from this specification.

On top of that I want to bring about the topic of BDD and show a use case of a user story that describes the requirements in the Specification-By-Example form.

From this article, you will learn:

1) How to write a user story following BDD/Specification-By-Example approach, so it can be easily understood by all stakeholders and easily converted into automated tests.

2) How to use TDD approach when developing REST API in Spring Boot.

3) How to use OpenAPI specification in order to follow API-first principle.

Technologies used: Spring Boot, Spring Boot Test, OpenAPI, JUnit, Maven.

An overview of the technique

Schematically, the process of REST API development described in this article looks like this:

The development loop looks like this:

  1. The Software developer studies the user story (and asks questions if needed, but we don’t focus on that in this article).
  2. The developer then creates a specification implementation in the form of a Spring Boot Test, and gradually adds test methods for each scenario described in the user story.
  3. For every created test method, an OpenAPI specification is updated by adding new API methods and new components, that are needed for the new test.
  4. The code generation is run by the developer to create the new entities needed for the new test to compile.
  5. A minimum amount of production code is added to make the test pass.

Sample Use case

Let’s imagine we have the following user story from the classic “Book Shop” example, which tells us about the new functionality in the system: showing a list of books, books details and updating some book information.

Description

As a stock administrator,

I want to see a list of books, add new books and delete old books, browse and modify books details,

So that I can keep the catalogue of books up to date.

Acceptance criteria

Scenario 1: Browsing an empty books catalogue

Given: the user Joe with the stock administrator role connects to the application and there are no books created in the catalogue

When: the user browses the index page

Then: a table with the following columns with no rows and the button “Add a book” should be displayed:

Scenario 2: Showing a form for adding a new book item

Given: the user Joe browses the index page

When: the user clicks the button “Add a book”

Then: the user should be navigated to the page with a form containing the following editable fields:

Scenario 3: Adding a new book item

Given: the user Joe browses the adding a new book page and fills in the fields of the form as follows:

When: the user clicks the button “Save changes”

Then: the user should be navigated to the index page with the table of books shown as follows:

Scenario 4: Deleting a book

Given: the user Joe browses the index page with the list of books and the following list is shown:

When: the user clicks the “trash bin” icon in the row with the book id equal to 4

Then: the book should be deleted, and the row should disappear from the table:

Scenario 5: Browsing book details

Given: the user Joe browses the index page with the list of books

When: the user clicks on the row with Book ID equals to 2

Then: the user should be navigated to the book details page and the following table should be shown

And: only one field on the form should be editable — “Quantity in stock”.

Scenario 6: Updating book details

Given: the user Joe browses the book details of the book ID equal to 2 and changes the value of the field “Quantity in stock” from 10 to 20.

When: the user clicks the button “Save changes”

Then: the changes should be saved; the user should be navigated back to the index page and the updated catalogue of books should be displayed:

As we can see, this user story describes a behaviour of a web application. In this article we will focus on the backend REST API Development only, therefore some of the scenarios will not be implemented, and we won’t pay attention to the design mock-ups.

Creating a Spring Boot project and starting with a Spring Boot test

To start with, let’s create a new Spring Boot project using Spring Initializr

As non-standard options we will choose:

  • Maven project
  • Adding Lombok dependency

After opening the project, let’s create a new package in the test/java directory called com.github.andremoniy.apitdd.specification. We will use this package to store our specification tests. Inside this package we create a new class called BooksSpecification and annotate it with the following annotation:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BooksSpecification {
}

What this annotation does is starting our Spring Boot application and assigns a random port to it, that can be used to access it via a rest template object. For that we create an object variable:

@Autowired
TestRestTemplate restTemplate;

Scenario 1 implementation

Let’s start our implementation. For that we create our first test method inside the BookSpecificationclass:

@Test
@DisplayName("BSP-1 :: Scenario 1: Browsing an empty books catalogue")
void should_retrieve_an_empty_books_catalogue() {
// Given

// When
ResponseEntity<BookDto[]> bookResponseEntity = restTemplate.getForEntity("/api/books", BookDto[].class);
// Then

}

Here we annotated our test method with DisplayName annotation and provided the scenario name inside it. In reality will also have a specific user story id, say something like “BSP-1” (BookShoP), automatically generated by Jira. It is a good idea to add this backlog item’s id in the display name as well — this way it will be easy to find the story implementation and on the other hand — to find the original story by looking at the test’s display name.

In the “Given” section of the scenario 1 a user with the specific role is mentioned, but for now we won’t focus on the security implementation; this will be a matter of another article. In the present material we will focus only on the API creation. Therefore we leave the “Given” section of our test empty.

In the “When” block of our test we implement a GET method to our service using /api/booksURL. This is the way of interpreting the scenario’s requirements, assuming that the corresponding front end implementation will trigger this API method upon loading the index page.

If you are using Intellij IDEA like me, you will get something like that:

Obviously the class BookDto doesn’t exist and one might have a temptation to create this class straightaway by using the IDE’s context menu:

However, we will not do it that way. Instead we want all our API classes be generated automatically from a single source — an OpenAPI document. In the context of a web application this approach has one major benefit: we can automatically generate two sets of corresponding classes/methods/functions both in the backend (server implementation) and frontend (client implementation) projects. This dramatically simplifies the process of the collaboration between BE and FE components, speeds up the development and practically eliminates any chance of a human mistake on the front end side.

So, to proceed with this idea, we first should create a new OpenAPI specification document. For that let’s create a folder named api inside the main resources folder and inside it create a new OpenAPI Specification:

We will use OpenAPI 3 and yaml format:

Inside the auto-generated template we will add a new section called components and define the class BookDtothere (we have to give it at least one property, otherwise the class won’t be generated):

components:
schemas:
BookDto:
type: object
properties:
id:
type: integer

And we will add a specification for the GET method:

paths:
/api/books:
get:
tags:
- Books API
responses:
'200':
description: A list of Books in the catalogue
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BookDto'

So, how do we turn this specification into Java code? For that we will use openapi-generator maven plugin. Add the following configuration into the build/plugins section of the pom.xml:

<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.2.0</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>${project.basedir}/src/main/resources/api/api.yaml</inputSpec>
<generatorName>spring</generatorName>
<configOptions>
<sourceFolder>src/gen/java/main</sourceFolder>
m<modelPackage>com.github.andremoniy.apitdd.api.model</modelPackage> <apiPackage>com.github.andremoniy.apitdd.api.controller</apiPackage>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
<skipDefaultInterface>true</skipDefaultInterface>
<useTags>true</useTags> <openApiNullable>false</openApiNullable>
<documentationProvider>none</documentationProvider>
</configOptions>
<generateApiDocumentation>false</generateApiDocumentation>
<generateSupportingFiles>false</generateSupportingFiles>
</configuration>
</execution>
</executions>
</plugin>

Let’s understand this configuration first.

1) inputSpec — this is the easy part — it’s a relative path to our OpenAPI specification document;

2) generatorName — it tells the plugin to use Spring generator, which is essentially about using proper annotations;

3) interfaceOnly — we enable this flag, because we just need the controller interfaces be generated;

4) useSpringBoot3 — this essentially enables usage of Jakarta package instead of javax;

5) skipDefaultInterface — Simplicity is our king: we don’t need default implementation of the controller interfaces;

6) useTags — with enabling this option we get meaningful names for our interfaces, based on the assigned tag names;

7) openApiNullable — we disable this option to avoid adding additional libraries like openapi json annotations;

8) documentProvider — we set it as none to avoid generation of useless documentation annotations and the need to pull swagger dependencies. We don’t need this documentation inside generated java classes, because our OpenAPI specification is the source of documentation;

9) for the same reason as above we disable generateApiDocumentation flag;

10) finally, we don’t need any supporting tool files either, so we disable generateSupportingFiles flag.

Now, as we added this plugin, we can try to generate our classes. For that we execute the following command:

mvn clean compile

The classes are generated inside /target/generated-sources/openapi/src/gen/java/main folder:

If you use Intellij IDEA, I recommend to mark the main folder as Generated Source Root:

There is, however, a compilation problem: many an import declarations are unknown and we therefore we need to add the needed dependencies.

Please note that his is also part of the TDD philosophy: we don’t add any dependencies up-front; we add them only we need them to make our code compilable or to pass the tests.

In our case we are missing the following classes in the class path:

To cover this gap we should add the following dependencies:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>jakarta.platform</groupId>
<artifactId>jakarta.jakartaee-api</artifactId>
<version>10.0.0</version>
<scope>provided</scope>
</dependency>

Running mvn clean compile should give us now a positive result and a successful build. But please note that compile command doesn’t compile test classes. Our next step is to execute

mvn clean test

command.

This attempt will bring us new compilation problems, this time related to the BookSpecification class:

What is missing, is obviously a proper import for the generated BookDto class and Spring’s ResponseEntity class:

import com.github.andremoniy.apitdd.api.model.BookDto;
import org.springframework.http.ResponseEntity;

Having run the mvn clean test command we get now, surprisingly (!) , a green build:

This is another important principle of TDD technique: our test should first fail. If it doesn’t fail, it means the test is wrong or we have a misconfiguration. The latter is the case — we haven’t added the surefire plugin. Let’s fix this problem:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
<configuration>
<includes>
<include>**/*Test.java</include>
<include>**/*Tests.java</include>
<include>**/*Specification.java</include>
</includes>
</configuration>
</plugin>

If we run mvn clean test again — our test will fail — hurrah!

org.springframework.web.client.RestClientException: Error while extracting response for type [class [Lcom.github.andremoniy.apitdd.api.model.BookDto;] and content type [application/json]

This is in fact due to the actual response of the server be:

{"timestamp":"2024-01-12T17:38:29.568+00:00","status":404,"error":"Not Found","path":"/api/books"}

Obviously: because we didn’t implement the actual controller. Let’s do that! We create a new class BookApiController inside a new package called “controller”:

The class should implement the auto-generated interface called BooksApiApi and be annotated with Controller annotation:

@Controller
public class BookApiController implements BooksApiApi {
@Override
public ResponseEntity<List<BookDto>> apiBooksGet() {
return null;
}
}

Now our test is green and we can move forward to the next small step. Let’s add an assertion:

// Then
assertTrue(bookResponseEntity.getStatusCode().is2xxSuccessful());
assertNotNull(bookResponseEntity.getBody());
assertEquals(0, bookResponseEntity.getBody().length);

And this test will fail on the assertNotNull assertion, because the response body is actually now. Let’s fix it:

@Override
public ResponseEntity<List<BookDto>> apiBooksGet() {
return ResponseEntity.ok(List.of());
}

With that our test becomes green and voila — we have implemented our first scenario.

Scenario 2 Implementation

The scenario 2 doesn’t contain any API operations, therefore there is nothing to implement on the backend side here.

Scenario 3 Implementation

The scenario 3 embraces the creation of a new book item and its retrieval from the database. We first describe the Given condition:

@Test
@DisplayName("BSP-1 :: Scenario 3: Adding a new book item")
void should_add_new_book_and_retrieve_it() {
// Given
CreateBookDto book = new CreateBookDto()
.title("Designing Data-Intensive Applications")
.author("Martin Klepperman")
.isbn("978=1440373320")
.publisher("O'Reilly Media")
.publicationDate(LocalDate.of(2017, 3, 16))
.quantityInStock(5);

This code clearly won’t compile because the class CreateBookDto doesn’t exist, therefore we add it into our api.yaml specification in the “components” section:

CreateBookDto:
type: object
properties:
title:
type: string
author:
type: string
isbn:
type: string
publisher:
type: string
publicationDate:
type: string
format: date
quantityInStock:
type: integer

Once we compile the code and add the necessary import, we can proceed with the When and Then sections of the Scenario 3’s test method:

// When
ResponseEntity<BookDto> addBookResponseEntity = restTemplate.postForEntity("/api/books", book, BookDto.class);
// Then
assertTrue(addBookResponseEntity.getStatusCode().is2xxSuccessful());
ResponseEntity<BookDto[]> bookResponseEntity = restTemplate.getForEntity("/api/books", BookDto[].class);
assertTrue(bookResponseEntity.getStatusCode().is2xxSuccessful());
assertNotNull(bookResponseEntity.getBody());
assertEquals(1, bookResponseEntity.getBody().length);

However, we should not forget one important thing: the scenarios we have in the user story, imply a certain order of the operations. Thus Scenario 1 describes an empty catalogue, and after execution of scenarios 2 and 3, the catalogue will gain one item in it.

One may argue that these scenarios as well as the corresponding test methods should be independent from each other. In this case we would need to mock the data layer representation for each scenario. I don’t see any added value of doing so, though:

  • the natural order of the scenarios as well as their gradual implementation reflects the natural way of how the application is going to be used;
  • mocking the data layer in this integration test would mean exposing of the internal details of the implementation. We also will lose the integrity of the test, as it won’t test the database layer anymore;
  • also, the overhead cost of running the entire specification instead of being able to run each test method separately is negligible low. Therefore I prefer to introduce a methods order to make this specification deterministic. For that we add the following annotation for the entire test class:
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)

And annotate each of two test methods with the Order annotation. I recommend giving it values of the magnitude of hundreds. This way we leave “room” for future modifications, if we decide to add some intermediate steps between the tests:

    @Test
@DisplayName("BSP-1 :: Scenario 1: Browsing an empty books catalogue")
@Order(100)
...

@Test
@DisplayName("BSP-1 :: Scenario 3: Adding a new book item")
@Order(300)
...

Anyway, if we run our new test it will fail as expected: we haven’t specified, nor implemented the corresponding POST method. Opening our OpenAPI specification, and adding the new paths section below the previously defined get method:

post:
tags:
- Books API
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateBookDto'
responses:
'201':
description: The new book item has been added
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/BookDto'

The next step is to add the method into the BookApiController class:

@Override
public ResponseEntity<List<BookDto>> apiBooksPost(CreateBookDto createBookDto) {
return ResponseEntity.created(URI.create("")).build();
}

With that we progress on the implementation and the Scenario 3’s test is now failing on the

assertEquals(1, bookResponseEntity.getBody().length);

assertion.

This because so far we haven’t implemented any data layer. An implementation of a data layer, yet quite simple and straightforward, goes beyond the scope of this article. For this reason we will implement a naive hash map in the service layer to simulate the database behaviour. First we add an autowired bookService field to our controller:

private final BookService bookService;

To avoid manual creation of the constructor we’ll use the RequiredArgsConstructor annotation from Lombok:

@RequiredArgsConstructor
public class BookApiController implements BooksApiApi {

Next, we add an instance of the ModelMapper class to deal with DTO <-> Entity conversions:

private final ModelMapper modelMapper;

The corresponding dependency should be added in the pom.xml:

<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.2.0</version>
</dependency>

The controller’s methods get the following implementation:

@Override
public ResponseEntity<List<BookDto>> apiBooksGet() {
return ResponseEntity.ok(bookService.findAll()
.stream()
.map(book -> modelMapper.map(book, BookDto.class))
.collect(Collectors.toList()));
}

@Override
public ResponseEntity<List<BookDto>> apiBooksPost(CreateBookDto createBookDto) {
bookService.save(modelMapper.map(createBookDto, Book.class));
return ResponseEntity.created(URI.create("")).build();
}

And the corresponding bookService class that we create in the service package will have this simple implementation (please, remember: we should resist the temptation to do more work than it is needed to make our test to pass!):

@Service
public class BookService {
private final List<Book> books = new ArrayList<>();
public Collection<Book> findAll() {
return books;
}
public void save(Book book) {
books.add(book);
}
}

Where the Book class should be created inside model package (we are omitting fields for now):

public class Book {
}

The last important step is to add a configuration class where we declare ModelMapper bean:

@Configuration
public class AppConfiguration {
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}

With that we can run our test and ensure that it is green now. The last piece we need to add to this test (I promise: definitely the last piece for this scenario) is to add assertions of the contents of the returned object from the list of book. For that we slightly rewrite the last two assertions and also use ModelMapper to facilitate the comparison of the expected and actual objects:

BookDto[] books = bookResponseEntity.getBody();
assertNotNull(books);
assertEquals(1, books.length);
assertEquals(new BookDto()
.id(1)
.title("Designing Data-Intensive Applications")
.author("Martin Klepperman")
.quantityInStock(5), books[0]);

This new addition crashes our Scenario-3 test again; clearly, because the class Book doesn’t have any fields declared.

@Getter
@Setter
public class Book {

private String id;
private String title;
private String author;
private Integer quantityInStock;

}

This, however, doesn’t fix the problem. Upon giving a closer look, we’ll find out that BookDtoclass hasn’t got any fields apart from id. In fact, BookDtos and CreateBookDto both should inherit a common super type due to their fields intersection. We can fix it by redefining the BookDto and CreateBookDto components as follows:

    BaseBookDto:
type: object
properties:
title:
type: string
author:
type: string
quantityInStock:
type: integer

BookDto:
type: object
allOf:
- $ref: '#/components/schemas/BaseBookDto'
- properties:
id:
type: integer

CreateBookDto:
type: object
allOf:
- $ref: '#/components/schemas/BaseBookDto'
- properties:
isbn:
type: string
publisher:
type: string
publicationDate:
type: string
format: date

After compilation our tests should become green and with that we conclude the implementation of Scenario 3!

Scenario 4 Implementation

With each and every step we are getting closer to the state when implementation of a new scenario becomes more and more routine operation because of the foundations we put in place in the previous stages. Scenario 4 serves as a nice example of this increase of the development speed. The test method describing Scenario 4 will look like this:

@Test
@DisplayName("BSP-1 :: Scenario 4: Deleting a book")
@Order(400)
void should_delete_a_book() {
// Given
restTemplate.postForEntity("/api/books", new CreateBookDto()
.title("Specification by Example")
.author("Gojko Adzic")
.isbn("978-1617290084")
.publisher("Manning Publications")
.publicationDate(LocalDate.of(2011, 6, 9))
.quantityInStock(10), BookDto.class);
restTemplate.postForEntity("/api/books", new CreateBookDto()
.title("Sentience: The Invention of Consciousness")
.author("Nicholas Humphrey")
.isbn("1111111")
.publisher("Manning Publications")
.publicationDate(LocalDate.of(2011, 6, 9))
.quantityInStock(3), BookDto.class);
restTemplate.postForEntity("/api/books", new CreateBookDto()
.title("Middlemarch")
.author("George Eliot")
.isbn("222222")
.publisher("Eliot Media")
.publicationDate(LocalDate.of(2011, 6, 9))
.quantityInStock(2), BookDto.class);
restTemplate.postForEntity("/api/books", new CreateBookDto()
.title("The Naked Ape")
.author("Desmond Morris")
.isbn("4443444")
.publisher("Ape Media")
.publicationDate(LocalDate.of(2011, 6, 9))
.quantityInStock(1), BookDto.class);
// When
restTemplate.delete("/api/books/4");
// Then
BookDto[] books = getBooks();
assertEquals(4, books.length);
}

A little bit verbose test method is due to the need of adding four additional book items. The method getBooks is refactored repeating block of code that should be declared as follows:

private BookDto[] getBooks() {
ResponseEntity<BookDto[]> bookResponseEntity = restTemplate.getForEntity("/api/books", BookDto[].class);
assertTrue(bookResponseEntity.getStatusCode().is2xxSuccessful());
return bookResponseEntity.getBody();
}

The implementation that makes this test green is pretty straightforward. First, we add the new method in the api.yaml :


/api/books/{id}:
delete:
tags:
- Books API
parameters:
- in: path
name: id
schema:
type: integer
required: true
description: ID of the book to delete
responses:
'200':
description: The book was deleted

After compilation (mvn clean compile) we add an implementation in the controller:

@Override
public ResponseEntity<Void> apiBooksIdDelete(Integer id) {
bookService.deleteById(id);
return ResponseEntity.ok().build();
}

And finally the corresponding method in the BookService class:

public void deleteById(Integer id) {
books.removeIf(book -> id.equals(book.getId()));
}

Ah yes! We don’t have the id field declared yet. Remember, that our BookService is rather a mock implementation because we do not cover the topic of the data layer in this article. So the simplest way to glue things together and make it working would be enhancing the save method like this:

private final AtomicInteger idSequence = new AtomicInteger();
...
public void save(Book book) {
book.setId(idSequence.incrementAndGet());
books.add(book);
}

Terrible, terrible implementation of the data layer. But we should we forgive ourselves — this is only for the demonstration purposes. A bit of this ugly magic and our test is green again:

Scenario 5 Implementation

The test method for Scenario 5 is quite straightforward as well:

@Test
@DisplayName("BSP-1 :: Scenario 5: Browsing book details")
@Order(500)
void should_show_book_item_details() {
// Given
// When
ResponseEntity<BookDetailsDto> bookEntity = restTemplate.getForEntity("/api/books/2", BookDetailsDto.class);
// Then
assertTrue(bookEntity.getStatusCode().is2xxSuccessful());
assertEquals(new BookDetailsDto()
.id(2)
.title("Specification by Example")
.author("Gojko Adzic")
.isbn("978-1617290084")
.publisher("Manning Publications")
.publicationDate(LocalDate.of(2011, 6, 9))
.quantityInStock(10), bookEntity.getBody());
}

To make this test to pass, we add the following path description below the delete method in the api.yaml:

    get:
tags:
- Books API
parameters:
- in: path
name: id
schema:
type: integer
required: true
description: ID of the book to display
responses:
'200':
description: The requested book details
content:
application/json:
schema:
$ref: '#/components/schemas/BookDetailsDto'

And a new component that basically inherits both BookDto and CreateBookDto:

    BookDetailsDto:
type: object
allOf:
- $ref: '#/components/schemas/BookDto'
- $ref: '#/components/schemas/CreateBookDto'

After compilation we add an implementation of the new controller and service methods respectively:

@Override
public ResponseEntity<BookDetailsDto> apiBooksIdGet(Integer id) {
return ResponseEntity.ok(
modelMapper.map(bookService.findById(id), BookDetailsDto.class)
);
}
public Book findById(Integer id) {
return books.stream().filter(book -> id.equals(book.getId())).findAny().orElse(null);
}

And after adding the necessary import of the new bean BookDetailsDto in the test, all our tests should become green again:

Scenario 6 implementation

Final step — implementation of the book details update.

First let’s put in place the Given section of our test:

@Test
@DisplayName("BSP-1 :: Scenario 6: Updating book details")
@Order(600)
void should_update_book_details() {
// Given
UpdateBookDetailsDto updateBookDetailsDto = new UpdateBookDetailsDto()
.quantityInStock(20);
}

To make this code compiliable, we have to reconfigure our components a little bit:

    UpdateBookDetailsDto:
type: object
properties:
quantityInStock:
type: integer

BaseBookDto:
type: object
allOf:
- $ref: '#/components/schemas/UpdateBookDetailsDto'
- properties:
title:
type: string
author:
type: string

UpdateBookDetailsDto becomes now the super type for all *Book*Dto components, because it contains one common property for all of them. Once our code becomes compilable we can continue creation of the test:

// When
restTemplate.patchForObject("/api/books/2", updateBookDetailsDto, Void.class);
// Then
ResponseEntity<BookDetailsDto> bookEntity = restTemplate.getForEntity("/api/books/2", BookDetailsDto.class);
assertTrue(bookEntity.getStatusCode().is2xxSuccessful());
assertEquals(new BookDetailsDto()
.id(2)
.title("Specification by Example")
.author("Gojko Adzic")
.isbn("978-1617290084")
.publisher("Manning Publications")
.publicationDate(LocalDate.of(2011, 6, 9))
.quantityInStock(20), bookEntity.getBody());

Now this test compiles, but fails. We open our OpenAPI Specification again and add the following patch method below the get one:

    patch:
tags:
- Books API
parameters:
- in: path
name: id
schema:
type: integer
required: true
description: ID of the book to update
requestBody:
description: The updatable part of the book details
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateBookDetailsDto'
responses:
'200':
description: The updated book details
content:
application/json:
schema:
$ref: '#/components/schemas/BookDetailsDto'

After recompiling the project we add the following implementation in the controller:

@Override
public ResponseEntity<BookDetailsDto> apiBooksIdPatch(Integer id, UpdateBookDetailsDto updateBookDetailsDto) {
Book book = bookService.findById(id);
book.setQuantityInStock(updateBookDetailsDto.getQuantityInStock());
return apiBooksIdGet(id);
}

Surprisingly, but our test is still not passing. It fails with the following error:

Caused by: java.net.ProtocolException: Invalid HTTP method: PATCH

It’s hard to tell whether it is a new Spring Boot bug or not, but quick googling brings us to the solution: adding httpclient5 dependency.

<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3</version>
</dependency>

Our test is green now and with that we have finished the implementation of our user story:

The code created above can be found in this repository: Andremoniy/apitdd (github.com)

Test code coverage

The last topic I want to discuss in this article is to look at the resulting test code coverage. I have already touched this topic in detail in one of my previous articles “How It Should Have Been Tested”. But there are some small nuances related to OpenAPI first approach that I found necessary to cover here. First, let’s add the jacoco plugin into our pom.xml configuration:

<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<id>default-prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>default-report</id>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>com/github/andremoniy/apitdd/api/**/*.class</exclude>
</excludes>
</configuration>
</plugin>

The special configuration added here in the plugin’s section explicitly excludes the com.github.andremoniy.apitdd.api package from the coverage analysis. This is the package where auto-generated code resides and we are not interested in seeing its code coverage. Why? Primarily due to two reasons:

  1. We have enough confidence in this generated code: it’s quite simple — just POJOs and interfaces.
  2. If we include these classes, we’ll get a lot of statistic noise, because many of generated getters and setters are never executed in runtime.

After running mvn clean verify command, we’ll find a report generated inside /target/site folder. The report will look like this:

As you can see, we’ve got almost 100% test code coverage, except one class, which is ApitddApplication:

If you want to close this gap and achieve 100% coverage, look at my previous article where I explained how to do that.

Summary

In this article I gave an example of BDD/TDD of REST API using API-first approach. As you can see, following this technique you get naturally a full test coverage, which is a good sign that we put in place only needed implementation. Starting from integration tests following the acceptance criteria described in the user story, leads us to a neat, concise, well-designed solution, minimising the risk of regression problems in the future and facilitating user acceptance phase bringing it to a bare minimum (because all acceptance criteria are already automated).

While there are some great courses covering the topic of TDD API development in Spring Boot (consider, for instance, this Spring Academy course: Building a REST API with Spring Boot — Spring Academy), and articles covering API-first approach in Spring Boot (this article, for example: API First Development with Spring Boot and OpenAPI 3.0 | Baeldung), they together lacking a holistic approach of TDD API-first development. In this article I tried to fill this gap in the learning materials and I hope some developers will find it useful.

--

--

Andrey Lebedev
Andrey Lebedev

Written by Andrey Lebedev

PhD in CS, a Software engineer with more than 20 years of experience. Check my LinkedIn profile for more information: https://www.linkedin.com/in/andremoniy/

No responses yet