Writing unit tests for the solution using Junit

Writing unit tests for the solution using Junit

Does our code really work? Now comes the cringe!

Introduction

In this chapter, I'll see if the requirements of Task 1 were correctly implemented and are functioning as expected. I'll begin by detailing how to add JUnit to our Java application in IntelliJ IDEA, and then I'll go through all of the validations we discussed in chapter 2, providing test cases for each validation rule. Let's get started!

The repository

The project source code is found here: igcse-pre-release.

Add JUnit 5 library

Our project doesn't have JUnit 5 library yet. To add it, right-click on a method, select "Go to" and then click "Tests". Next, click "Create New Test" and the following window will pop up:

From here, select JUnit 5 as the version for the testing library, next click fix and JUnit will be installed.

Unit tests

In this section, I will go over all of the validations described in Chapter 2 and implement tests for them.

Validation 1: Booking date

There are only two circumstances in which the selected date will be marked as valid: when the booking date is properly formatted and when the date falls within the given booking period (in our case, 14 days). The booking date will be invalid in three cases: when the date format is invalid, when the date is older than the current date, or when the date is beyond the booking period.

Our tests must answer YES to the following questions.

  • Given a correctly formatted date, will the returned date be a valid LocalDateTime object and no exceptions are thrown?

  • Given an incorrectly formatted date, will the function throw an InvalidDateFormatException exception?

  • Given a correctly formatted old date, will the function throw an InvalidDateException exception?

  • Given a correctly formatted date that is beyond the booking period, will the function throw an InvalidDateException exception?

The following is my attempt at implementing unit tests for the validation class.

class BookingDateValidatorTest {
    DateTimeFormatter dateTimeFormatter;

    @BeforeEach
    void init(){
        final String DATE_FORMAT = "dd-MM-yyyy";
        dateTimeFormatter = DateTimeFormatter.ofPattern(DATE_FORMAT);
    }

    @Nested
    @DisplayName("Given a booking date")
    class FormatTests {
        @Test
        @DisplayName("returns valid booking date if it's a correctly formatted.")
        void returnAValidLocalDateTimeGivenACorrectDate() {
            LocalDate today = LocalDate.now();

            String date = today.format(dateTimeFormatter);
            String expected = today + "T08:00";

            LocalDateTime bookingDate = assertDoesNotThrow(() -> BookingDateValidator.validate(date));
            assertEquals(expected, bookingDate.toString());
        }

        @Test
        @DisplayName("throws an InvalidDateFormatException exception if it's incorrectly formatted")
        void throwsExceptionIfDateFormatIsInCorrectDate() {
            String date = LocalDate.now().toString();

            InvalidDateFormatException thrown = assertThrows(InvalidDateFormatException.class,
                    () -> BookingDateValidator.validate(date));

            assertEquals("Unable to create date time from: ["+ date + "], please enter date with format [dd-MM-yyyy].", thrown.getMessage());
        }
    }

    @Nested
    @DisplayName("Given a well formatted date")
    class DateValidityTests{
        @Test
        @DisplayName("return date that is valid if it is a valid booking date")
        void returnValidDate(){
            LocalDate threeDaysFromToday = LocalDate.now().plusDays(3);
            String date = threeDaysFromToday.format(dateTimeFormatter);

            LocalDateTime bookingDate = assertDoesNotThrow(() -> BookingDateValidator.validate(date));
            assertEquals(threeDaysFromToday, bookingDate.toLocalDate());
        }

        @Test
        @DisplayName("throw an InvalidDateException exception if it's an old date")
        void throwsExceptionGivenAnOldDate() {
            LocalDate threeDaysAgo = LocalDate.now().minusDays(3);
            String date = threeDaysAgo.format(dateTimeFormatter);

            InvalidDateException thrown = assertThrows(InvalidDateException.class,
                    () -> BookingDateValidator.validate(date));

            assertEquals("Booking date: " + threeDaysAgo + " can not be before the current date: " + LocalDate.now(), thrown.getMessage());
        }

        @Test
        @DisplayName("throw InvalidDateException exception if the date is beyond the two week booking period")
        void throwsExceptionGivenADateBeyondTheTwoWeekBookingPeriod() {
            LocalDate thirtyDaysLater = LocalDate.now().plusDays(30);
            String date = thirtyDaysLater.format(dateTimeFormatter);

            InvalidDateException thrown = assertThrows(InvalidDateException.class,
                    () -> BookingDateValidator.validate(date));

            assertEquals("Bookings can only be made within a "
                    + CarPark.BOOKING_PERIOD + " day period from the current date: "
                    + LocalDate.now(), thrown.getMessage());
        }
    }
}

Validation 2 & 3: Booking

For this validation, I came up with four possible scenarios that I will unit test. I will also include the payment for each booking in the tests. To do so, I had to mock the PaymentMethod interface so that I manually manage both successful and unsuccessful payments. Remember, the PaymentMethod interface mkPayment method is non-deterministic, which is why it must be mocked.

public class TestPaymentMethod implements PaymentMethod {
    private final double successRate;
    private final String name;

    private TestPaymentMethod(double successRate, String name) {
        this.successRate = successRate;
        this.name = name;
    }

    TestPaymentMethod(double successRate){
        this(successRate, "TestPaymentMethod");
    }

    @Override
    public Optional<Payment> mkPayment(UUID bookingID, Money bookingPrice) {
        return (successRate > 0.3) ?
                Optional.of(new Payment(bookingID, bookingPrice)) :
                Optional.empty();
    }

    @Override
    public String toString() {
        return "TestPaymentMethod{" +
                "name='" + name + '\'' +
                '}';
    }
}

I'll demonstrate how to use the TestPaymentMethod class below.

double probabilityOfSuccess = 0.9;
//...
newVisitor.setPaymentMethod(new TestPaymentMethod(probabilityOfSuccess));

We are done with the test dependencies, now let's look at the questions that must be answered by the unit tests.

  • Given that there are parking spaces available on the selected date will the booking be saved successfully?

  • Given that all parking spaces are fully booked on the selected date but there are available parking spaces on other dates, will the booking be terminated and a NoParkingSpaceException exception thrown?

  • Given that there are no parking spaces available during the whole booking period, will the booking be terminated and a NoParkingSpaceException exception thrown?

  • Given that there are parking spaces available on the selected date but the payment for the booking fails, will a PaymentFailedException be thrown?

NB: the fillAllAvailableBookings method fills up all the bookings for the whole 14 days booking period. This function's output will be used to test scenario 3 in the preceding list.

class CarParkTest {
    private Visitor newVisitor;
    private CarPark carPark;

    @BeforeEach
    void init() {
        double probabilityOfSuccess = 0.9;
        carPark = new CarPark("Test parking lot");
        Car car = new Car("234-X3");
        newVisitor = new Visitor("name", "id", "address");
        newVisitor.setCar(car);
        newVisitor.setPaymentMethod(new TestPaymentMethod(probabilityOfSuccess));
    }

    @Test
    @DisplayName("Given that there are parking spaces available on the selected date add first booking")
    void addBooking() {
        Booking booking = new Booking(newVisitor);
        assertDoesNotThrow(() -> carPark.addBooking(booking, LocalDateTime.now()));
        assertEquals(booking.getBookingDate(), LocalDate.now());
    }

    @Test
    @DisplayName("Given that all parking spaces are already booked for the entire booking period throws a NoParkingSpaceException exception.")
    void throwsExceptionWhenAllParkingSpacesHaveBeenBooked() {
        var bookedDate = LocalDateTime.of(LocalDate.now().plusDays(2), LocalTime.now());
        LocalDate upComingDate = LocalDate.now().plusDays(15);

        fillAllAvailableBookings();
        Booking booking = new Booking(newVisitor);

        RuntimeException thrown = assertThrows(NoParkingSpaceException.class, () -> carPark.addBooking(booking, bookedDate));
        assertEquals("No free parking space for the next 14 days. Should we schedule the booking on: " +
                upComingDate + "?", thrown.getMessage());
    }

    // Fills up all the parking spaces on each day of the entire booking period.
    private void fillAllAvailableBookings() {
        for (int i = 0; i < CarPark.BOOKING_PERIOD; i++) {
            var bookedDate = LocalDateTime.of(LocalDate.now().plusDays(i), LocalTime.now());
            for (int j = 0; j < CarPark.PARKING_SPACES; j++) {
                Booking booking = new Booking(newVisitor);
                carPark.addBooking(booking, bookedDate);
            }
        }
    }

    @Test
    @DisplayName("Given that there are no parking spaces on the selected date throw a NoParkingSpaceException exception.")
    void throwsExceptionGivenThatNoParkingSpacesAreAvailableOnASelectedDate() {
        var bookedDate = LocalDateTime.of(LocalDate.now().plusDays(2), LocalTime.now());

        for (int i = 0; i < CarPark.PARKING_SPACES; i++) {
            Booking booking = new Booking(newVisitor);
            carPark.addBooking(booking, bookedDate);
        }

        Booking booking = new Booking(newVisitor);
        NoParkingSpaceException thrown = assertThrows(NoParkingSpaceException.class, () -> carPark.addBooking(booking, bookedDate));

        assertEquals("No free parking space on this particular day: " + bookedDate, thrown.getMessage());
    }

    @Test
    @DisplayName("Given that there are parking spaces on the selected date but payment fails throw a PaymentFailedException exception.")
    void hasParkingSpacesOnASelectedDateButPaymentFails() {
        var bookedDate = LocalDateTime.of(LocalDate.now().plusDays(2), LocalTime.now());

        newVisitor.setPaymentMethod(new TestPaymentMethod(0.1));
        Booking booking = new Booking(newVisitor);
        PaymentFailedException thrown = assertThrows(PaymentFailedException.class, () -> carPark.addBooking(booking, bookedDate));

        assertEquals("Payment process failed. Please try again later.", thrown.getMessage());
    }
}

Validation 4: user details input

I assumed the Objects class tests for empty strings in the same way it tests for null objects with requireNonNull. Well, it doesn't! So, I decided that if the inputs are empty, the constructor should not be invoked, consequently I pushed this validation into the Main class's createVisitor function, as shown below. Lazy me!

private static Visitor createVisitor() throws IllegalArgumentException, InvalidInputException {
    Scanner scanner = new Scanner(System.in);

    System.out.println("Enter your name: ");
    String name = scanner.nextLine();

    System.out.println("Enter your id number: ");
    String id = scanner.nextLine();

    System.out.println("Enter your address: ");
    String address = scanner.nextLine();

    System.out.println("Enter your licence number: ");
    String license = scanner.nextLine();

    if (name.isEmpty() || id.isEmpty() || address.isEmpty() || license.isEmpty())
        throw new InvalidInputException("Visitor name, id, license or address must not be empty.");

    //...
}

Validation 5: Checkout

Now let's write tests for the checkout method in the visitor class. I'll start by testing the PaymentMethodFactory class to check whether we can construct a PaymentMethod or throw an exception given a valid or incorrect payment type string. Finally, I will test whether the checkout method makes a payment or fails if the "external" call to the payment gateway is successful or unsuccessful, respectively.

class VisitorTest {
    private Visitor visitor;
    private Booking booking;

    @BeforeEach
    void init(){
        Money price = new Money(Currency.getInstance("USD"), BigDecimal.valueOf(2.50));
        visitor = new Visitor("test name", "testID", "testAddress");
        visitor.setCar(new Car("234-SDFR"));
        booking = new Booking(visitor);
        booking.setParkingSpace(new ParkingSpace(1, price));
    }

    @Test
    @DisplayName("Given a correct payment method, successfully set the visitor's payment method ")
    void chooseCorrectPaymentMethodForVisitor(){
        PaymentMethod paymentMethod = PaymentMethodFactory.getInstance(PaymentType.valueOf("CREDITCARD"));
        visitor.setPaymentMethod(paymentMethod);

        assertEquals("CreditCard{name='creditcard'}", paymentMethod.toString());
    }

    @Test
    @DisplayName("Given an incorrect payment method, throw an IllegalArgumentException exception ")
    void chooseInCorrectPaymentMethodForVisitor(){
        Throwable thrown = assertThrows(IllegalArgumentException.class,
                () -> PaymentMethodFactory.getInstance(PaymentType.valueOf("wrong payment method")));

        assertEquals("No enum constant payment.method.PaymentType.wrong payment method", thrown.getMessage());
    }

    @Test
    @DisplayName("Given that the checkout method executed successfully, pay for a booking successfully")
    void allowSuccessfulCheckoutIfPaymentIsSuccessful() {
        double successRate = 0.9;
        PaymentMethod paymentMethod = new TestPaymentMethod(successRate);
        visitor.setPaymentMethod(paymentMethod);

        assertDoesNotThrow(() -> visitor.checkout(booking));
        assertEquals("Visitor{name='test name', id='testID', address='testAddress', car=Car{licenceNumber='234-SDFR'}, paymentMethod=TestPaymentMethod{name='TestPaymentMethod'}}"
                , visitor.toString());
    }

    @Test
    @DisplayName("Given that the checkout method fails, throw a PaymentFailedException exception")
    void throwsExceptionIfPaymentFails() {
        double successRate = 0.1;
        PaymentMethod paymentMethod = new TestPaymentMethod(successRate);
        visitor.setPaymentMethod(paymentMethod);

        Throwable thrown = assertThrows(PaymentFailedException.class, () -> visitor.checkout(booking));
        assertEquals("Payment process failed. Please try again later.", thrown.getMessage());
    }
}

Now, let's run the tests!

Conclusion

In this chapter, we tested almost all the features in task 1 and all the tests passed! At this stage the application demonstrates its ability to:

  • create a booking based on a valid date and the availability of a parking space on the selected date or during the entire booking period,

  • capture user data and store it in a suitable data structure,

  • validate all user inputs e.g. all validations in chapter two must pass including our dummy payment "thingy",

  • and display outputs, including error messages that are clear and understandable.

Notice that one requirement was not implemented. It directs that all data must be erased at the end of the booking period. I completely forgot about it. My bad! I will implement this functionality in the next chapter.

As always, feel free to send any feedback on my work so that both I and other readers can improve.