2月28日から3月31日に戻る経路と、3月28日にずれる経路を並べたカレンダーのイラスト

実装・エラー解決

JavaのplusMonthsで毎月の予定日がずれる|2月の後も基準日を保つ方法

この記事でわかること

LocalDate.plusMonths(1) を繰り返すと、2026年1月31日から2月28日を経て3月28日になります。設備点検の基準日を31日として保持し、2月だけ月末へ寄せて、3月は31日に戻す方法を試します。

新人SE
新人SE
毎月31日を基準に点検予定を作ったら、2月は28日で合っているのに、3月も28日になりました。
ポンコツSE
ポンコツSE
2月用に確定した28日を、そのまま3月を計算する元にしています。点検の基準日31日は、月ごとの予定日とは別に持たせましょう。

設備点検の計画を月初のバッチでまとめて作る場面です。日付が3月31日から28日へずれると、その月の人員手配や施設への事前連絡まで変わります。1月から2月までの確認は通るため、3月を含めたテストで初めてずれに気づけます。

実行サンプル

設備点検予定の修正前後、JUnit 9件、pom.xml、READMEを収録。JDK 17とMaven 3.9系で、ZIPを展開して mvn clean test を実行します。続けて java -cp target/classes example.inspection.InspectionScheduleDemo で2026年1〜3月の前後を比べられます。

1月31日から1か月ずつ進めると、3月28日になる

練習用の仕様を「毎月、基準日が存在すればその日。存在しなければその月の末日」とします。基準日31日なら、2026年1月は31日、2月は28日、3月は31日が期待値です。休日による前倒し・繰り越しや、点検する時刻はここでは扱いません。

修正前の InspectionScheduleDemo.rollingDates() は、作った予定日を previous に持ち、1か月進めた戻り値を代入して次の計算に使います。main() は同じ1〜3月について、修正前後の結果を表示します。

package example.inspection;

import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;

public final class InspectionScheduleDemo {
    private InspectionScheduleDemo() {
    }

    static List<LocalDate> rollingDates(LocalDate firstDate, int monthCount) {
        List<LocalDate> dates = new ArrayList<>();
        LocalDate previous = firstDate;

        for (int i = 0; i < monthCount; i++) {
            dates.add(previous);
            previous = previous.plusMonths(1);
        }
        return dates;
    }

    public static void main(String[] args) {
        LocalDate january31 = LocalDate.of(2026, 1, 31);
        YearMonth january = YearMonth.of(2026, 1);
        YearMonth march = YearMonth.of(2026, 3);
        var day31 = new MonthlyInspectionSchedule(31);

        System.out.println("before=" + rollingDates(january31, 3));
        System.out.println("after=" + day31.datesFrom(january, 3));
        System.out.println("day30=" + new MonthlyInspectionSchedule(30).dateIn(march));
        System.out.println("monthEnd=" + march.atEndOfMonth());
    }
}
対象月前月の予定日を使う基準日31日を使う
2026年1月1月31日1月31日
2026年2月2月28日2月28日
2026年3月3月28日3月31日

plusMonths() は、その呼び出しの元になった日付から計算する

LocalDate.of(2026, 1, 31).plusMonths(1) では、2月31日が存在しないので最後に存在する日、2月28日になります。ここまでは期待どおりです。次に LocalDate.of(2026, 2, 28).plusMonths(1) を実行すると、3月28日は存在するため調整は起きません。1月の基準が31日だったという情報は、2月28日の LocalDate には入っていません。

1月31日を保ったまま january31.plusMonths(2) を一度だけ実行すれば3月31日です。january31.plusMonths(1).plusMonths(1) では、その途中で2月28日を新しい起点にするため3月28日になります。plusMonths() の戻り値を正しく受け取っていても、次に渡す値を変えると結果がずれます。Java 17のplusMonthsの仕様で月末調整を確認できます。

関連記事LocalDate.plusDaysで日付が変わらない理由|戻り値を代入する必要がある 呼び出す前の日付のままなら、LocalDateの戻り値の扱いを確認できます。 記事を読む

予定日を作るたび、対象月と基準日31日から計算する

修正版の MonthlyInspectionSchedule は、設定された基準日を dayOfMonth に保持します。YearMonth は年と月を表す型です。datesFrom() では firstMonth.plusMonths(i) で対象月だけを進め、dateIn() に渡します。前の月の予定日は、次の予定を作る入力に使いません。

package example.inspection;

import java.time.LocalDate;
import java.time.YearMonth;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public record MonthlyInspectionSchedule(int dayOfMonth) {
    public MonthlyInspectionSchedule {
        if (dayOfMonth < 1 || dayOfMonth > 31) {
            throw new IllegalArgumentException("dayOfMonth must be 1 to 31");
        }
    }

    public LocalDate dateIn(YearMonth month) {
        int day = Math.min(dayOfMonth, month.lengthOfMonth());
        return month.atDay(day);
    }

    public List<LocalDate> datesFrom(YearMonth firstMonth, int monthCount) {
        Objects.requireNonNull(firstMonth, "firstMonth");
        if (monthCount < 0) {
            throw new IllegalArgumentException("monthCount must not be negative");
        }

        List<LocalDate> dates = new ArrayList<>();
        for (int i = 0; i < monthCount; i++) {
            dates.add(dateIn(firstMonth.plusMonths(i)));
        }
        return List.copyOf(dates);
    }
}

Math.min(dayOfMonth, month.lengthOfMonth()) は基準日と月の日数を比べ、小さい方を採用します。2026年2月は min(31, 28) なので28日。3月では min(31, 31) なので31日です。最後に month.atDay(day) が年・月・日を組み合わせ、予定日を表す LocalDate を作ります。Java 17のYearMonthの仕様も参照できます。

最初に契約上の基準日そのものを表す日付が確保できるなら、その日を起点として plusMonths(i) で計算する方法でも同じ結果を作れます。業務では2月から新規登録したり、2月分の予定日しか残っていなかったりします。2月28日を見ても、もとの設定が28日・29日・30日・31日のどれかは分かりません。翌月以降も予定を出し直すなら、点検ルールとして基準日を保存し、月ごとの確定予定日とは分けて読み込みます。

「毎月30日」と「毎月末」は、3月の予定日で見分ける

3月に31日がある場合、「毎月30日、なければ当月末」は3月30日、「毎月末」は3月31日です。2026年2月はどちらも28日になるので、2月だけ見ても契約を区別できません。毎月末という契約があるなら、対象の YearMonth から atEndOfMonth() を呼ぶと、その意図をコードに残せます。

手順書に「月末付近」としか書いていない場合は、「2月28日の後は、3月30日ですか、31日ですか」と日付で確認します。「休業日なら次の営業日」と言われたら、月の日数を使う処理だけでは答えが出ません。基準日を決める処理と、休日カレンダーで実際の点検日を調整する処理を分け、翌月の基準日には調整後の日付を使わないようにします。

2月だけで合格にせず、3月に31日へ戻ることをJUnitで確かめる

MonthlyInspectionScheduleTest の要点は、1月31日と2月28日の両方が通っても、3月の31日まで期待値に含めることです。うるう年は2028年2月を開始月とし、2月29日から3月31日になることも確認します。1月の予定日をあらかじめ作っていない場合にも、保存してある基準日が使われるテストです。

ZIPのJUnitは合計9件です。旧動作の再現に加え、通常年・うるう年の2月を越えるケース、基準日30日と毎月末の違い、年越し、0か月、負の月数、範囲外の基準日2件を確認します。修正版のループを前の予定日から進める方式へ戻すと、通常年と、うるう年の2月から始める2件が失敗します。

MonthlyInspectionScheduleTest.javaの全文を見る
package example.inspection;

import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class MonthlyInspectionScheduleTest {
    private final MonthlyInspectionSchedule day31 = new MonthlyInspectionSchedule(31);

    @Test
    void rollingCalculationLosesThe31stAfterFebruary() {
        assertEquals(List.of(
                        LocalDate.of(2026, 1, 31),
                        LocalDate.of(2026, 2, 28),
                        LocalDate.of(2026, 3, 28)),
                InspectionScheduleDemo.rollingDates(LocalDate.of(2026, 1, 31), 3));
    }

    @Test
    void returnsToThe31stAfterACommonFebruary() {
        assertEquals(List.of(
                        LocalDate.of(2026, 1, 31),
                        LocalDate.of(2026, 2, 28),
                        LocalDate.of(2026, 3, 31)),
                day31.datesFrom(YearMonth.of(2026, 1), 3));
    }

    @Test
    void returnsToThe31stWhenScheduleStartsInALeapFebruary() {
        assertEquals(List.of(LocalDate.of(2028, 2, 29), LocalDate.of(2028, 3, 31)),
                day31.datesFrom(YearMonth.of(2028, 2), 2));
    }

    @Test
    void fixedDay30AndMonthEndDifferInMarch() {
        var day30 = new MonthlyInspectionSchedule(30);
        YearMonth february = YearMonth.of(2026, 2);
        YearMonth march = YearMonth.of(2026, 3);

        assertEquals(february.atEndOfMonth(), day30.dateIn(february));
        assertEquals(LocalDate.of(2026, 3, 30), day30.dateIn(march));
        assertEquals(LocalDate.of(2026, 3, 31), march.atEndOfMonth());
    }

    @Test
    void crossesYearBoundaryWithoutLosingTheDay() {
        assertEquals(List.of(LocalDate.of(2026, 12, 31), LocalDate.of(2027, 1, 31)),
                day31.datesFrom(YearMonth.of(2026, 12), 2));
    }

    @Test
    void zeroMonthsReturnsNoDates() {
        assertEquals(List.of(), day31.datesFrom(YearMonth.of(2026, 1), 0));
    }

    @Test
    void rejectsNegativeMonthCount() {
        assertThrows(IllegalArgumentException.class,
                () -> day31.datesFrom(YearMonth.of(2026, 1), -1));
    }

    @ParameterizedTest
    @ValueSource(ints = {0, 32})
    void rejectsAnInvalidAnchorDay(int dayOfMonth) {
        assertThrows(IllegalArgumentException.class, () -> new MonthlyInspectionSchedule(dayOfMonth));
    }
}
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>java-localdate-plusmonths-monthly-drift</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

レビューでは、基準日をどこから読んだかを確認する

設備点検は基準日31日で、存在しない月だけ当月末です。前月の確定予定日から計算すると3月28日にずれるため、設定の31日と対象の年月から毎月計算するようにしました。2026年2月28日と3月31日、2028年2月29日と3月31日をテストで確認しています。

  • 予定の初回日付、月ごとの確定日、契約上の基準日をどの項目から取得するか区別した。
  • 短い月の予定日と、その翌月に基準日へ戻ることを確認した。
  • 日付が欠けた月の扱いと、常に月末に行うかを具体的な予定日で確認した。
  • 休日調整がある場合は、調整後の日付を次の月の起点にしていないことを確認した。

日付の型とメソッドの使い分けを復習したいときの1冊

『スッキリわかるJava入門 第5版』の「文字列と日付の扱い」は、日付を文字列から取り出し、日付の型で扱うところから復習したいときに使えます。読んだあとに、年と月だけの YearMonth と、確定した年月日の LocalDate が今回のどの変数に当たるかをコードで確かめると、型の違いを実装へつなげやすくなります。

書籍「スッキリわかるJava入門 第5版」の表紙
商品画像:Amazon.co.jp
PR Java初学者向け

スッキリわかるJava入門 第5版

中山 清喬 (著), 国本 大悟 (著), 株式会社フレアリンク (監修)

変数・条件分岐から、クラスの基本まで学ぶ一冊。

豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。

  • Javaを基礎から体系的に学びたい
  • Java 25 LTS対応の入門書を選びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事LocalDate.parseの原因と対処|日付形式・存在しない日・STRICTの違い 設定や連携ファイルから日付を読み込むとき、書式の不一致と存在しない日付を切り分けられます。 記事を読む 関連記事Javaの現在日時をテストで固定する方法|Clock.fixedで期限判定を確認する 実際のバッチで「今月」を求める処理を足すなら、実行日を固定して月をまたぐ条件を試せます。 記事を読む

まとめ

2026年2月の点検予定は28日ですが、基準日は31日のままです。翌月の予定は対象の年月と基準日から作れば、3月31日に戻ります。修正を確認するときは、短い月の結果と、その翌月の結果を組にして期待値へ入れてください。

-実装・エラー解決
-, ,