この記事でわかること
LocalDate.plusMonths(1) を繰り返すと、2026年1月31日から2月28日を経て3月28日になります。設備点検の基準日を31日として保持し、2月だけ月末へ寄せて、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の仕様で月末調整を確認できます。
予定日を作るたび、対象月と基準日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版
変数・条件分岐から、クラスの基本まで学ぶ一冊。
豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。
- Javaを基礎から体系的に学びたい
- Java 25 LTS対応の入門書を選びたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
2026年2月の点検予定は28日ですが、基準日は31日のままです。翌月の予定は対象の年月と基準日から作れば、3月31日に戻ります。修正を確認するときは、短い月の結果と、その翌月の結果を組にして期待値へ入れてください。



