忍者ブログ
VBAによる実用アプリケーションの構築、およびGAS(Google Apps Script)やOffice Scriptへのモダンな移行パスを検証・解説するテックブログ。現場で即戦力となるコードモジュールや再利用可能な実用部品を継続的に提供します。

【VBA】「4で割れる」だけじゃない!正確な「うるう年判定関数」の作り方(単体テストコード付き)

日付やスケジュールの自動計算、月別集計などのマクロを作成する際、必ずと言っていいほど登場するのが「指定した西暦年がうるう年(閏年)かどうか」の判定です。

「4で割り切れたらうるう年でしょ?」と安易に year Mod 4 = 0 だけで済ませていると、「100年に1度の例外ルール」に引っかかり、将来の計算でバグを引き起こす原因になります。

今回は、グレゴリオ暦の正確なルールを整理し、「契約による設計(DbC)」のコメントフォーマットに則った安全な汎用関数とテストコードを作成します。

1. うるう年(閏年)決定の「3つのルール」

現在使われているグレゴリオ暦において、うるう年は以下の優先順位(ルール)に従って決定されます。

  • 基本ルール: 西暦年が 4 で割り切れる 年はうるう年(例: 2024年)
  • 例外ルール①: ただし、100 で割り切れる 年はうるう年ではない(例: 1900年、2100年)
  • 例外ルール②: ただし、400 で割り切れる 年は例外的にうるう年(例: 2000年、2400年)

この条件をVBAの論理演算子(And / Or)で表現すると、次の一行に集約されます。

IsLeapYear = (Year Mod 4 = 0 And Year Mod 100 <> 0) Or (Year Mod 400 = 0)

2. 実装:安全なうるう年判定関数 (IsLeapYear)

事前条件(1以上の西暦年)と事後条件を明記した関数です。不正な数値(0以下など)が渡された場合もクラッシュさせず、エラーログを出力して安全に False を返します。

' ==============================================================================
' [機能名] うるう年(閏年)判定
'
' [処理内容]
'   ・指定された西暦年(targetYear)がうるう年かどうかをグレゴリオ暦の定義に従って判定します。
'
' [引数]
'   @param  {Long} targetYear : 必須 : 判定対象の西暦年 (1 以上の整数)
'
' [事前条件 (Requires)]
'   ・targetYear >= 1 であること(グレゴリオ暦の正の西暦年)。
'
' [事後条件 (Ensures)]
'   ・うるう年の場合     : True を返す。
'   ・うるう年でない場合 : False を返す。
'   ・事前条件違反の場合 : False を返し、[ERROR] ログを出力する。
'
' [戻り値]
'   @return {Boolean} うるう年判定結果
'
' [例外処理 (Throws)]
'   ・targetYear < 1 の場合はエラーログを出力して False を返却。
'
' [履歴]
'   2026/10/08  [作成者名]  新規作成
' ==============================================================================
Public Function IsLeapYear(ByVal targetYear As Long) As Boolean
    ' 1. 事前条件(Requires)のチェック
    If targetYear < 1 Then
        Debug.Print "[ERROR] IsLeapYear: 西暦年は 1 以上の整数を指定してください。 (指定値: " & targetYear & ")"
        IsLeapYear = False
        Exit Function
    End If
    
    ' 2. グレゴリオ暦に基づく判定ロジック
    ' ・4で割り切れ、かつ100で割り切れない年
    ' ・または、400で割り切れる年
    If (targetYear Mod 4 = 0 And targetYear Mod 100 <> 0) Or (targetYear Mod 400 = 0) Then
        IsLeapYear = True
    Else
        IsLeapYear = False
    End If
End Function

3. 境界値(エッジケース)を網羅する単体テストコード

うるう年判定で最もバグが起きやすい「100の倍数年(1900年など)」や「400の倍数年(2000年など)」を含むテストケースを一括検証します。
エントリーポイントのため、RAIIパターン(CAspect)で実行時間とログ出力を自動化しています。

' ==============================================================================
' [機能名] IsLeapYear 関数の網羅的テスト
' ==============================================================================
Public Sub Test_IsLeapYear()
    ' エントリーポイント用アスペクト(ログ・実行時間自動化)
    Dim aspect As New CAspect: aspect.Begin "Test_IsLeapYear"
    
    ' テストケース: Array(検証する年, 期待される判定結果, テストの意図)
    Dim testCases As Variant
    testCases = Array( _
        Array(2024, True,  "4で割り切れる年 (平年の直近うるう年)"), _
        Array(2023, False, "4で割り切れない年 (平年)"), _
        Array(1900, False, "100で割り切れるが400で割り切れない年 (例外平年)"), _
        Array(2100, False, "100で割り切れるが400で割り切れない年 (未来の例外平年)"), _
        Array(2000, True,  "400で割り切れる年 (例外うるう年)"), _
        Array(1600, True,  "400で割り切れる過去の年 (例外うるう年)"), _
        Array(0,    False, "事前条件違反: 0年"), _
        Array(-400, False, "事前条件違反: 負の数") _
    )
    
    Debug.Print "=================================================="
    Debug.Print " IsLeapYear 単体テスト開始"
    Debug.Print "=================================================="
    
    Dim passCount As Long: passCount = 0
    Dim failCount As Long: failCount = 0
    Dim i As Long
    
    For i = LBound(testCases) To UBound(testCases)
        Dim checkYear As Long: checkYear = CLng(testCases(i)(0))
        Dim expected As Boolean: expected = CBool(testCases(i)(1))
        Dim memo As String:      memo = testCases(i)(2)
        
        Dim actual As Boolean
        actual = IsLeapYear(checkYear)
        
        If actual = expected Then
            passCount = passCount + 1
            Debug.Print "[PASS] " & memo & " (" & checkYear & "年) => " & actual
        Else
            failCount = failCount + 1
            Debug.Print "[FAIL] " & memo & " (" & checkYear & "年)"
            Debug.Print "      期待: " & expected & ", 実際: " & actual
        End If
    Next i
    
    Debug.Print "--------------------------------------------------"
    Debug.Print " 結果: SUCCESS=" & passCount & " / FAIL=" & failCount
    Debug.Print "=================================================="
End Sub

4. 実行結果(イミディエイトウィンドウ)

10:00:00 [START] Test_IsLeapYear (User: developer)
==================================================
 IsLeapYear 単体テスト開始
==================================================
[PASS] 4で割り切れる年 (平年の直近うるう年) (2024年) => True
[PASS] 4で割り切れない年 (平年) (2023年) => False
[PASS] 100で割り切れるが400で割り切れない年 (例外平年) (1900年) => False
[PASS] 100で割り切れるが400で割り切れない年 (例外平年) (2100年) => False
[PASS] 400で割り切れる年 (例外うるう年) (2000年) => True
[PASS] 400で割り切れる過去の年 (例外うるう年) (1600年) => True
[ERROR] IsLeapYear: 西暦年は 1 以上の整数を指定してください。 (指定値: 0)
[PASS] 事前条件違反: 0年 (0年) => False
[ERROR] IsLeapYear: 西暦年は 1 以上の整数を指定してください。 (指定値: -400)
[PASS] 事前条件違反: 負の数 (-400年) => False
--------------------------------------------------
 結果: SUCCESS=8 / FAIL=0
==================================================
10:00:00 [END]   Test_IsLeapYear (0.001s)

5. 補足:VBA組み込み関数を使う裏ワザとの比較

VBAには、DateSerial 関数を使って「2月29日が存在するか(または3月0日の日付が29日か)」を判定するテクニックもあります。

' 日付関数を利用した判定例
Public Function IsLeapYearDateSerial(ByVal targetYear As Long) As Boolean
    IsLeapYearDateSerial = (Day(DateSerial(targetYear, 3, 0)) = 29)
End Function
  • 算術演算版 (Mod 使用): 外部の日付オブジェクト生成がなく超高速で、処理の意図(400年周期のロジック)が明確。
  • DateSerial 版: 記述が1行で済むが、VBAの内部日付シリアル値変換が入るため大量計算時のオーバーヘッドがわずかに大きい。

業務システムや共通ライブラリとして組み込む場合は、事前条件ガード(範囲チェック)を併設できる算術演算版 (IsLeapYear) の方が堅牢でおすすめです。

6. まとめ

  • 「100の倍数」と「400の倍数」の例外を忘れない:Mod 4 = 0 だけでは 1900年や 2100年を誤って「うるう年」と判定してしまいます。
  • 境界値テスト(2000年・1900年)を自動テストに含める:ロジックが正しいかを検証する際、2000年(うるう年)と1900年(平年)の両方をテストケースに入れておくことで、実装ミスを100%防ぐことができます。

PR