Wikipedia

Resultados de la búsqueda

viernes, 3 de diciembre de 2021

Handling dates in Spring: The right way

In this post I want to show you what — in my opinion —  is a nice way to handle dates within your Spring Boot application. Suitable both for business needs as well as for unit testing.

Usually we find situations in which dates are spawned simply with LocalDateTime.now() or — to make things worse  using the old API new Date(). This makes testing difficult since there's no easy way to control these scenarios for unit or integration testing.

A better approach would be to relay on a utility class in order to give the user the representation of what now means in certain context. Then again, we need either to do some obscure work to handle static methods in unit test — if you're wondering, there are several frameworks, being powermock the most known  or provide the functionality in that class for that purpose. Which in turn could lead to a usage of a test method — God forbids — within production code!

I would give you a clue: There's a mechanism that you're been using for quite a long time that's really handy for this situation... I think you might have guessed by now. YES! it's dependency injection!

Here's a simple class definition to show this approach:


In order for to be fully operational within a Spring context, you'll need to create the following configuration as well:



As you may have guessed, this Spring component will allow us to inject in every other component that we need to get what is the current date. It will also allow us to mock its methods easily with tools like Mockito.

In order to illustrate this, let's imagine that we need to code a feature that needs to validate if a credit card is expired or not. It should also tell us if the card is soon to expire if we're within the actual expiration month. To make this thing more interesting I will use TDD to build the code piece by piece.

Now, for simplicity this will be the class definition for the card:

public class Card {
private String cardholderName;
private String cardNumber;
private LocalDate expirationDate;
}
Let's create our CardExpirationService that will be the responsible for the logic regarding the expiration. After that, let's create the test class as well with the help of an IDE.

We can already start building the first scenario. I'm going to start with a non expired card. We create a non expired card, that is, a card that has an expiration date greater than a month from what we consider "now". Remember, we're doing this in a TDD way, so here's as far as I can get for my test code:

Let the IDE create the method for me so I can go back to the test again. And write the necessary assertion. Lest assume we want to get a "VALID" string for this scenario. The final test looks like this:
@Test
void shouldGetValidCard() {
LocalDate threeMonthsFromNow = LocalDate.now().plusMonths(3);
Card validCard = new Card(threeMonthsFromNow);

String result = cardExpirationService.validateExpiration(validCard);

Assertions.assertEquals("VALID", result);
}
No, if we run it, we'll get an error, of course, so it's time to move to the production code once again. Basically we'll check if the expiration date is in the future, so we write something like this:
public String validateExpiration(Card card) {

LocalDate today = LocalDate.now();
if (today.isBefore(card.getExpirationDate())) {
return "VALID";
}

return null;
}
Now, we run the test and we should have a neat green check! Let's write the next scenario. I'll choose the already expired scenario this time:
@Test
void shouldGetExpiredCard() {
LocalDate yesterday = LocalDate.now().minusDays(1);
Card expiredCard = new Card(yesterday);

String result = cardExpirationService.validateExpiration(expiredCard);

Assertions.assertEquals("EXPIRED", result);
}
Run this test and we should get an error again. It's time to go back to the production code and see what's wrong. The next obvious choice for us this time is to add an else clause and return from there. It should look similar to this:
public String validateExpiration(Card card) {

LocalDate today = LocalDate.now();
if (today.isBefore(card.getExpirationDate())) {
return "VALID";
} else {
return "EXPIRED";
}
}
And with no surprise, the tests are green again! Sweet! Now, let's tackle down the last scenario. A card that is soon to be expired:
@Test
void shouldGetSoonToBeExpiredCard() {
LocalDate oneDayBeforeEndOfMonth = LocalDate.now().plusMonths(1).minusDays(1);
Card soonToExpireCard = new Card(oneDayBeforeEndOfMonth);

String result = cardExpirationService.validateExpiration(soonToExpireCard);

Assertions.assertEquals("EXPIRES_SOON", result);
}
If we run the tests at this point we should get an error stating that:
Expected :EXPIRES_SOON
Actual :VALID
Time to go back to the production code and see what's wrong. So, for the time being, this particular case still represents a valid card. So we have to so something inside that branch. It may look like this:
public String validateExpiration(Card card) {

LocalDate today = LocalDate.now();
if (today.isBefore(card.getExpirationDate())) {
long daysToExpire = ChronoUnit.DAYS.between(today, card.getExpirationDate());
if (daysToExpire <= today.getMonth().maxLength()) {
return "EXPIRES_SOON";
}
return "VALID";
} else {
return "EXPIRED";
}
}
At this point all the tests shall pass. But so far we just covered an imaginary scenario that rarely ever happens in real life. What happens in reality is that we have to deploy this code within some other service possibly across multiple timezones. The integration test you write based on mocked data will start failing — when, all of a sudden, all cards are expired —. If we think about it, the class CardExpirationService somehow knows what day is it today, and that's not a responsibility of the class. Think of yourself, you just don't know what time is, do you? You go to your smartwatch — or a grandfather's clock— to exactly know what the time is. So let's do the same for this class. Let's give the chance to look at our controlled clock. The whole class should look like this:
public class CardExpirationService {

private final BusinessDateUtils businessDateUtils;

public CardExpirationService(BusinessDateUtils businessDateUtils) {
this.businessDateUtils = businessDateUtils;
}

public String validateExpiration(Card card) {

LocalDate today = businessDateUtils.getCurrentDate();
LocalDate expirationDate = card.getExpirationDate();

if (today.isBefore(expirationDate)) {
long daysToExpire = ChronoUnit.DAYS.between(today, expirationDate);
if (daysToExpire <= today.getMonth().maxLength()) {
return "EXPIRES_SOON";
}
return "VALID";
} else {
return "EXPIRED";
}
}
}
And we can perfectly control what now is from our unit test using a mocking framework, or, in this case — and for the sake of simplicity — I just wrote a custom implementation for the BusinessDateUtils interface. The test class should look like this:
class CardExpirationServiceTest {

private static final LocalDate now = LocalDate.of(2020, 4, 21);

private CardExpirationService cardExpirationService;

@BeforeEach
void setUp() {
BusinessDateUtils businessDateUtils = new TestBusinessDateUtils(now);

cardExpirationService = new CardExpirationService(businessDateUtils);
}

@Test
void shouldGetValidCard() {
LocalDate threeMonthsFromNow = now.plusMonths(3);
Card validCard = new Card(threeMonthsFromNow);

String result = cardExpirationService.validateExpiration(validCard);

Assertions.assertEquals("VALID", result);
}

@Test
void shouldGetExpiredCard() {
LocalDate yesterday = now.minusDays(1);
Card expiredCard = new Card(yesterday);

String result = cardExpirationService.validateExpiration(expiredCard);

Assertions.assertEquals("EXPIRED", result);
}

@Test
void shouldGetSoonToBeExpiredCard() {
LocalDate withinTheMonth = now.plusDays(3);
Card soonToExpireCard = new Card(withinTheMonth);

String result = cardExpirationService.validateExpiration(soonToExpireCard);

Assertions.assertEquals("EXPIRES_SOON", result);
}

}

As you can imagine, and with a little bit of Spring magic you can set a custom clock for your integration and/or unit tests with just a few lines of code 😄

And yes, of course, now that we have the unit tests in place we can add a few more scenarios for edge cases and do a little bit of refactor on the production code to make it cleaner 😉

Hope you find this useful and see you next time!