Wpis dla osób mającym mniejsze doświadczenie w javie, ale i bardziej doświadczeni mogą się z tym zetknąć.

Tak, wiem mamy Javę 8 i w ogóle…

Problem jest jednak taki, że czasami musimy pracować z narzędziami napisanymi przed J8 i one uparcie chcą Calendar.

Problem

Przyjrzyjmy się pewnej metodzie

Listing 1. Metoda parsująca nietypowy format daty

public Calendar parseBusinessDate(String date) {
    if (date.length() != 7) return null;
    String year = date.substring(0, 4);
    String month = date.substring(4);
    int y = Integer.parseInt(year);
    int m = Integer.parseInt(month);
    Calendar c = Calendar.getInstance();
    c.set(Calendar.YEAR, y);
    c.set(Calendar.MONTH, m - 1);
    c.set(Calendar.DATE, c.getActualMaximum(Calendar.DATE));
    return c;
}

Data ma siedem znaków. Cztery pierwsze to rok, a potem trzy znaki miesiąca. Datę ustawiamy na ostatni dzień danego miesiąca. Dlaczego tak nie pytajcie. Teraz napiszmy test. Będzie on specyficzny, bo chcemy sprawdzić rok przestępny:

Listing 2. test dla roku przestępnego

@Test
public void shouldParseLeapYear(){
    // when
    Calendar result = sut.parseBusinessDate("2012002");
    // then
    Assert.assertEquals(result.get(Calendar.YEAR), 2012);
    Assert.assertEquals(result.get(Calendar.MONTH), Calendar.FEBRUARY);
    Assert.assertEquals(result.get(Calendar.DATE), 29);
}

Wygląda ok, prawda? To uruchom ten test 30 lub 31 dnia miesiąca…

Ciekawe prawda?

Przyczyna i rozwiązanie

Problemem jest sposób w jaki inicjowana jest instancja Calendar. Wywołując getInstance otrzymujemy obiekt wskazujący na bieżącą datę. Oznacza to, że dzień może być ustawiony na 30 albo 31. Jednocześnie w czasie wywołania metody parseBusinessDate zanim ustawimy dzień ustawiamy rok i miesiąc. W efekcie jeżeli ustawimy miesiąc, który jest krótszy niż bieżący i zrobimy to w ostatnim dniu miesiąca to nastąpi „przepełnienie” daty i przeskoczymy do kolejnego miesiąca.
Co ciekawe błąd tego typu może umknąć naszej uwadze i testom jeżeli tylko sposób naszej pracy powoduje, że w te nieszczęsne ostatnie dni miesiąca nie puszczamy z jakiś powodów testów np. mamy dwa dni spotkań, jest weekend i nie ma commitów, albo code freeze przed releasem. Jeżeli jeszcze po stronie klienta procedury, które mogłyby wykryć błąd są puszczane pierwszego dnia roboczego, a nie ostatniego to taki babol może długo pozostawać w ukryciu.

Rozwiązanie problemu jest proste. Wystarczy „zresetować” zmienną po utworzeniu na przykład ustawiając dzień na 1 albo czas na zerową milisekundę.

Listing 3. Poprawiona metoda parsująca nietypowy format daty

public Calendar parseBusinessDate(String date) {
    if (date.length() != 7) return null;
    String year = date.substring(0, 4);
    String month = date.substring(4);
    int y = Integer.parseInt(year);
    int m = Integer.parseInt(month);
    Calendar c = Calendar.getInstance();
    c.setTimeInMillis(0L);
    c.set(Calendar.YEAR, y);
    c.set(Calendar.MONTH, m - 1);
    c.set(Calendar.DATE, c.getActualMaximum(Calendar.DATE));
    return c;
}

Mała rzecz, a potrafi wkurwić.