Campsite Booking API : Revisited 2
It has been almost a year and a half since I published the article “Campsite Booking API: Revisited”. During this time, I kept the project up-to-date and implemented numerous improvements. So, in this article, which is the second part of the series “Campsite Booking API (Java)”, I describe in detail what was achieved and how.
This project iteration, already the third one, mainly consists of code enhancements. So now, let’s take a closer look at it in the order in which they were implemented. The source code is available here.
Tests with JUnit 5 in BDD style
When this project started, unit and integration tests were implemented using JUnit 4 with AssertJ, Mockito, and the Act-Arrange-Assert (AAA) pattern. Let’s demonstrate it using the Calculator class as an example:
public class Calculator {
public int add(int op1, int op2) {
return op1 + op2;
}
public int subtract(int op1, int op2) {
return op1 - op2;
}
public int multiply(int op1, int op2) {
return op1 * op2;
}
public int divide(int op1, int op2) {
return op1 / op2;
}
}
And unit tests with JUnit 4 and the AAA approach for the above class might look like this:
public class CalculatorTest {
private Calculator calculator = new Calculator();
private Integer op1;
private Integer op2;
@Before
public void setUp() {
op1 = null;
op2 = null;
}
@Test
public void multiply_twoPositiveOperands_correctPositiveResult() {
// Arrange
op1 = 6;
op2 = 3;
// Act
int result = calculator.multiply(op1, op2);
// Assert
assertThat(result).isEqualTo(18);
}
@Test(expected = ArithmeticException.class)
public void divide_secondOperandZero_arithmeticException() {
// Arrange
op1 = 6;
op2 = 0;
// Act
calculator.divide(op1, op2);
// Assert
// ArithmeticException thrown
}
}
These are the above unit tests re-written with JUnit 5 in BDD style:
class CalculatorTest {
Calculator calculator = new Calculator();
Integer op1;
Integer op2;
Integer result;
@BeforeEach
void setUp() {
op1 = null;
op2 = null;
result = null;
}
@Nested
class Multiply {
@BeforeEach
void setUp() {
// executes before each test within this nested class
}
@Test
void twoPositiveOperands_correctPositiveResult() {
given_twoOperands(6, 3);
when_multiply();
then_assertResult(18);
}
private void when_multiply() {
result = calculator.multiply(op1, op2);
}
}
@Nested
class Divide {
@Test
void secondOperandZero_arithmeticException() {
given_twoOperands(6, 0);
when_divide_then_assertExceptionThrown(ArithmeticException.class);
}
@Test
void twoPositiveOperands_correctPositiveResult() {
given_twoOperands(6, 3);
when_divide();
then_assertResult(2);
}
private void when_divide() {
result = calculator.divide(op1, op2);
}
private void when_divide_then_assertExceptionThrown(Class<? extends Exception> exception) {
assertThrows(exception, () -> calculator.divide(op1, op2));
}
}
private void given_twoOperands(int op1, int op2) {
this.op1 = op1;
this.op2 = op2;
}
private void then_assertResult(int expected) {
assertThat(result).isEqualTo(expected);
}
}
So, comparing these two implementations, you can see that the JUnit 5/BDD implementation differs from the JUnit 4/AAA one in the following:
- All tests related to a particular method were encapsulated in a nested class annotated with the JUnit 5
@Nested
annotation. Consequently, theAct
part, that is to say, the name of the method under test, was removed from the names of the test methods. - In test methods, following the BDD approach,
the
Arrange
,Act
, andAssert
sections were encapsulated in methods prefixed withgiven_
,when_
, andthen_
, respectively, where these new methods can be declared either at the parent test class level or in nested test classes. - The
@Before
annotation was replaced with the@BeforeEach
. A method annotated with@BeforeEach
and declared in a nested test class will be executed before each test in that class but after the@BeforeEach
methods from the parent test class.
I think, writing tests in the BDD style improves the code’s overall readability and makes the tests’ purpose and flow clearer. In addition, it provides an excellent opportunity for code reuse.
Previously, with JUnit 4, I used the methodName_stateUnderTest_expectedBehavior convention for naming test methods,
for instance, divide_secondOperandZero_arithmeticException
as in the test implementation example above. With JUnit 5,
the methodName part was removed due to grouping all related tests inside a nested class.
Next, because the camelCase
naming approach is harder to read for complex method names, I switched
to the underscore_case
, additionally applying this to nested test class names. Following the BDD style, I explicitly
prefixed the state_under_test and expected_behaviour parts with given_
and then_
, respectively.
To improve the display of test reports, JUnit 5 introduced new annotations such as @DisplayName
and
@DisplayNameGeneration
. So, for example, it may suffice to annotate a test class with the @DisplayNameGeneration( ReplaceUnderscores.class)
to display the given_second_operand_zero_then_arithmetic_exception
method as given second operand zero then arithmetic exception
.
To further improve readability, I wanted to add a comma after the given part to display the method name like
this: given second operand zero, then arithmetic exception
. To do this, I used a double underscore to separate the
given and then parts and implemented the
following CustomReplaceUnderscoresDisplayNameGenerator
class:
public class CustomReplaceUnderscoresDisplayNameGenerator
extends DisplayNameGenerator.ReplaceUnderscores {
@Override
public String generateDisplayNameForMethod(Class<?> testClass, Method testMethod) {
String methodName = testMethod.getName()
.replace("__", ", ").replace("_", " ");
if (testMethod.getAnnotation(DisplayNamePrefix.class) != null) {
methodName = String.format("%s, %s",
testMethod.getAnnotation(DisplayNamePrefix.class).value(), methodName);
}
return methodName;
}
}
While working on
the integration tests
for the findForDateRange
method in
the BookingRepository.java
class, I wanted to better visualize the requested date ranges versus the start and end dates of an existing booking. For
example, a method named given_booking_dates_before_range_startDate__then_no_booking_found
must be shown with the prefix
SE|-|----|-|--
in the test results report, where the letters S
and D
stands for the start and end dates of the
existing booking, and two |-|
indicate the start and end of the requested date range:
Since special characters such as hyphen(-
) and pipe(|
) are not allowed in method names in Java, I came up with the
idea of implementing
the DisplayNamePrefix.java
annotation, which works in conjunction with the CustomReplaceUnderscoresDisplayNameGenerator.java
class. Thus, in order
to display a prefix in the test results, the test method must be annotated with @DisplayNamePrefix
, given that the
test class is annotated with the @DisplayNameGeneration(CustomReplaceUnderscoresDisplayNameGenerator.class)
:
@Test
@DisplayNamePrefix("SE|-|----|-|--")
void given_booking_dates_before_range_start_date__then_no_booking_found() {
given_existingBooking(1, 2);
when_findBookingsForDateRange(3, 4);
then_assertNoBookingFoundForDateRange();
}
For more details, please check this commit.
var Syntax for Local Variables
With the release of Java 10, it became possible to declare local variables using the new var
keyword. When using var
, you no longer need to declare the type of the variable explicitly, as this implies that its type will be inferred from
the context. So, for instance, we have the following pre-Java 10 variable declaration:
SomeClassWithVeryVeryLongName myVar = new SomeClassWithVeryVeryLongName();
With Java 10, it can be declared as follows:
var myVar = new SomeClassWithVeryVeryLongName();
So I did not miss this opportunity to simplify the code and make it a little more readable using this new var syntax. For more details, please check this commit.
Java 17
The previous iteration of this project was based on Java 11 LTS. But since Oracle released Java 17, the next Long-Term Support version, in September 2021, I decided to upgrade the project to the latest Java LTS.
This upgrade was relatively simple and involved updating the java.version
property
in pom.xml file from 11
to 17
.
Unfortunately, this caused some of the project’s dependencies to become incompatible, and as a result, I updated all of
the project’s dependencies, including Spring Boot, up to the latest versions.
Because of Java 17, I also had to upgrade the base image in
the Dockerfile from openjdk:11-jre-slim
to azul/zulu-openjdk-debian:17-jre
, and distribution
and java-version
properties in GitHub
Actions’ workflows.
Campsite Table, API v2
The original implementation was based on the assumption that there was only one campsite available for booking. Therefore, the domain model contained only one class, Booking. This time I decided to enhance the solution with multiple campsites available for booking to choose from. Consequently, a new Campsite domain class has been added to the model, which now looks like in the UML class diagram below:
The above class diagram translates to the following physical data model for the MySQL database:
The inception of the new domain class required the implementation of the corresponding service and repository classes, namely CampsiteServiceImpl and CampsiteRepositoryImpl.
Hence, it also affected the REST API by introducing breaking changes. First, a new campsiteId
field was added to
the BookingDto
API model class. Previously, only two parameters, the start_date
and end_date
had to be passed to
the getVacantDates
endpoint in the API contract. So secondly, with multiple campsites to choose from, a new third
parameter, campsite_id
, was added to this
endpoint signature.
Due to these breaking changes, I had to upgrade the API version from v1 to v2.
findForDateRange Method without Pessimistic Read Locking
In the initial implementation,
the createBooking
method in the BookingServiceImpl class used
the findVacantDays
method from the same class to get available booking dates and validate them before creating a new booking. Then,
the findVacantDays
method, in turn, invoked
the findForDateRange
method in the BookingRepository class to get vacant dates. That is, in fact,
the getVacantDates
and addBooking
endpoints shared the same service method to execute incoming requests.
And given that the findForDateRange
method was implemented using the @Lock(LockModeType.PESSIMISTIC_READ)
annotation, concurrent requests to the getVacantDates
and addBooking
endpoints could fail due to
the CannotAcquireLockException
that occurs when a transaction cannot obtain a pessimistic lock.
The solution to this problem is to have two different methods for finding bookings for the date range in the
BookingRepository class, one without any locking mechanism used by the getVacantDates
endpoint and the other
with pessimistic locking for the addBooking
endpoint. In addition, the LockModeType
value for the new method with
pessimistic locking was upgraded to the PESSIMISTIC_WRITE
to acquire an exclusive lock because when using
the PESSIMISTIC_READ
on a transaction initiated by the createBooking
method in the BookingServiceImpl
class, JPA will implicitly convert the pessimistic read lock to an exclusive lock, PESSIMISTIC_WRITE
or PESSIMISTIC_FORCE_INCREMENT
, when a new Booking
entity is flushed to the database.
public interface BookingRepository extends CrudRepository<Booking, Long> {
String FIND_FOR_DATE_RANGE = "select b from Booking b "
+ "where ((b.startDate < ?1 and ?2 < b.endDate) "
+ "or (?1 < b.endDate and b.endDate <= ?2) "
+ "or (?1 <= b.startDate and b.startDate <=?2)) "
+ "and b.active = true "
+ "and b.campsite.id = ?3";
Optional<Booking> findByUuid(UUID uuid);
@Query(FIND_FOR_DATE_RANGE)
List<Booking> findForDateRange(LocalDate startDate, LocalDate endDate, Long campsiteId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="100")})
@Query(FIND_FOR_DATE_RANGE)
List<Booking> findForDateRangeWithPessimisticWriteLocking(LocalDate startDate, LocalDate endDate, Long campsiteId);
}
New Implementation of findForDateRangeWithPessimisticWriteLocking Method
Back then, while working on the initial implementation of this project, I
chose H2 as an in-memory DB for developing integration tests or running the
API without an external database. The H2 served well for these purposes except for the case of concurrent creation of
bookings with the same start and end dates. Unlike MySQL, concurrent requests to the addBooking
endpoint with the same
start and end date and camping ID were unsuccessful when using H2.
The expected result should be only one booking created, and other concurrent requests should return a 400 Bad Request
response when sending simultaneous requests to create a booking as in the example below, for instance:
{
"id":2,
"version":0,
"campsiteId":1,
"uuid":"ea2e2f8f-749d-4497-b0ec-0da4bf437800",
"email":"john.smith.3@email.com",
"fullName":"John Smith 3",
"startDate":"2022-09-16",
"endDate":"2022-09-17",
"active":true,
"_links":{
"self":{
"href":"http://localhost/v2/booking/ea2e2f8f-749d-4497-b0ec-0da4bf437800"
}
}
}
{
"status":"BAD_REQUEST",
"timestamp":"2022-08-30T02:52:19.10936",
"message":"No vacant dates available from 2022-09-16 to 2022-09-17"
}
Evidently, the pessimistic locking in the findForDateRangeWithPessimisticWriteLocking
method works well when using
MySQL, but somehow it doesn’t work at all with the H2 database. So, while researching this issue, I came across
an informative article by Andrey
Zahariev-Stoev: “Handling Pessimistic Locking with JPA on Oracle, MySQL, PostgreSQL, Apache Derby and H2”
. In this article, he explains in great detail the problem of concurrent database transactions and how they relate to
exclusive pessimistic locking. In addition, he offers several suggestions for implementing pessimistic locking solution when
using the Java Persistence API (JPA) with different RDBMS
vendors.
It turned out that the H2 database does not provide full support for handling the LockTimeoutException
and setting
lock timeout for a single transaction; therefore, I replaced H2 with Apache Derby as the
in-memory DB. Consequently, I re-implemented the findForDateRangeWithPessimisticWriteLocking
method, which was moved
from the BookingRepository class to the CustomizedBookingRepository
, and added a new BookingServiceImplConcurrentTestIT
class that contains integration tests for optimistic and pessimistic locking. All these code modifications were inspired
by “Testing Pessimistic Locking Handling with Spring Boot and JPA”
, another great article by Andrey Zahariev-Stoev, and are based on the source code from the corresponding
GitHub repository.
As you can see, the new implementation of the findForDateRangeWithPessimisticWriteLocking
method differs from the old
one in that it no longer uses annotations to set the query string, the type of lock mode, and the lock timeout. Instead,
the query object is created explicitly with the provided query string, parameters, and the lock mode using the JPA’s
Query interface. The lock timeout is set
through the appropriate custom repository context, either
MysqlCustomizedRepositoryContextImpl
or DerbyCustomizedRepositoryContextImpl class.
@Override
public List<Booking> findForDateRangeWithPessimisticWriteLocking(
LocalDate startDate, LocalDate endDate, Long campsiteId) {
log.info("Lock timeout before executing query[{}]",
customizedRepositoryContext.getLockTimeout());
Query query = customizedRepositoryContext.getEntityManager()
.createQuery(FIND_FOR_DATE_RANGE)
.setParameter(1, startDate)
.setParameter(2, endDate)
.setParameter(3, campsiteId)
.setLockMode(PESSIMISTIC_WRITE);
customizedRepositoryContext.setLockTimeout(
queryProperties.getFindForDateRangeWithPessimisticWriteLockingLockTimeoutInMs());
List<Booking> bookings = query.getResultList();
log.info("Lock timeout after executing query[{}]",
customizedRepositoryContext.getLockTimeout());
return bookings;
}
For more details, please check this commit.
TOC Generator GitHub Actions
Recently, while working on my other pet project, the Bilberry Hugo theme, I discovered a pretty useful GitHub Actions workflow for generating README’s table of contents (TOC). So, instead of manually creating and maintaining a TOC, the TOC Generator workflow will generate a TOC and a corresponding commit for it if your README file has TOC-related changes.
So, to implement this feature, I had to do two things. First, I added the readme-toc.yml
workflow to
the .github/workflows
directory. Please note that it’s triggered only for pull requests.
name: Generate README TOC
on:
pull_request:
branches: "*"
jobs:
generateTOC:
name: Generate TOC
runs-on: ubuntu-latest
steps:
- uses: technote-space/toc-generator@v4
Secondly, I replaced the manually created table of contents in README with the following markdown:
<!-- START doctoc -->
<!-- END doctoc -->
Continue reading the series “Campsite Booking API (Java)”: