<この記事の著者>
かいり - Tech Team Journal
文系未経験から開発エンジニアとしてSIerに新卒入社する。その後Web系事業会社も経て開発経験を積んだのち、得意なシステムテスト技術をより活かせるQAエンジニアにジョブチェンジした。
現在は「テストが得意なエンジニア」として上流工程からのテスト活動を推進している。
2024年2月29日はうるう日でした。
この日は複数のシステムが障害を起こし、中には運転免許の更新ができなくなったという国のシステムに関わるものもあったことはニュースにもなったのでご存知の方も多いと思います。
日付というものは私たちのごく身近にあるものであり、システム開発をする上で触れる機会も多いですが、それだけに今回のような障害を引き起こすこともあります。
今回は元開発エンジニアであり、現在はテスト専門のQA(品質保証)エンジニアとして働いている私が、実際にバグを引き起こすコードを交えながらうるう日バグの発生する要因や、それを防ぐ方法について解説します。
【目次】
日付や時刻はバグが混入しやすい
そもそもの話になりますが、「特定の日付を過ぎた」「特定の時刻になった」という条件はうるう日に限らずバグが混入しやすいです。
これには以下のような要因があると考えられます。
それを条件に処理の分岐が発生しやすい
誕生日を例にとってみるとわかりやすいですが、特定の日付を条件として処理を分岐させているシステムは非常に多いです。例えばX(旧Twitter)であれば誕生日には自分のページに風船が飛びますし、誕生月にはクーポンが発行されるECシステムなどもあります。
また、直近の消費税の変更を考えてみても、2019年9月30日23時59分までは8%で計算していたものを10月1日0時0分からは10%で計算するように変更する必要があったわけで、実際レジ等のシステムはこの移行がうまくいかず障害となったものもありました。
条件分岐は多ければ多いほどバグが混入しやすいと言っても過言ではありませんので、それを生み出しやすい日付や時刻の処理は必然的にバグが見つかりやすい、となります。
条件の再現が難しい
日付によって発生する条件を再現するには、究極のところ「その時を迎える」他にありません。が、それが現実的に難しいことは明らかだと思います。
時刻であれば国ごとのタイムゾーンもありますし、うるう年は基本的に4年に1度しか来ません。なので、そもそも実際に確認することが難しいというハードルがあります。
進数が異なる
普段私たちは10進数で物事を計算することが多いと思いますし、システム上でも計算は同様に10進数で行われますが、日付や時刻の進数に焦点を当てると以下のようになります。
- 分:60進数
- 時間:10進数
- 日(日付):月によって異なる
- 月:12進数
- 年:10進数
これが何を示すかというと、独自に日数や時刻計算処理を行うと計算をミスしやすいということです。実際に「半年後(6ヶ月後)を取得する」という処理のバグを含むPythonのコードを以下に示します。
from datetime import datetime # 現在の日付を取得 current_date = datetime.now() # 半年後の日付を計算 month = current_date.month + 6 year = current_date.year day = current_date.day future_date = datetime(year, month, day) print(f"半年後の日付: {future_date}")
このコードは、「月に6を足して未来の月を取得する」という処理です。例えば2024年3月3日であれば2024年9月3日となるので一見うまく動くように見えますが、2024年7月1日になった瞬間エラーになります。計算結果が2024年13月1日になってしまうためです。
では、どうやって防ぐ?
ここまで「なぜ日付や時刻にはバグが混入しやすいのか」について書いてきました。では、ここからはどうしたらそれらのバグを防げるのかについて考えてみたいと思います。
既存ライブラリを使用する
さきほど「独自に日数や時刻計算処理を行うと計算をミスしやすい」と書きましたが、日数や時刻計算のライブラリが存在している言語は多いのでそちらを使用可能な場合は活用しましょう。
実際に「半年後(6ヶ月後)を取得する」処理をPythonで書いてみます。今回は標準ライブラリではありませんが非常に有名なdateutilモジュールの一部であるrelativedeltaを使用します。
from datetime import datetime from dateutil.relativedelta import relativedelta # 現在の日付を取得 current_date = datetime.now() # 半年後の日付を計算 future_date = current_date + relativedelta(months=+6) print(f"半年後の日付: {future_date}")
relativedeltaはうるう年も考慮してくれるため、月末や年末といった境界における分岐処理も不要になります。便利ですね。
ただし、こういったものを信用しすぎるのも危険です。なぜなら、これらのライブラリにもバグが含まれている可能性は十分にあるためです。
テストケースに含める
結局のところ、システムが正しく動くかどうかは開発者自身で確かめるほかありません。今回は私が普段確認に使用しているテストケースを記載してみます。
「特定の日付」によって発生する分岐をテストするなら、
1/1, 2/28(通常の2月月末),2/29(うるう日),12/31 その他そのドメインで意味を持つ境界値
「特定日後の日付の取得」(例:一月後)をテストするケースなら、
1/1→2/1, 1/31→2/28(うるう年でない年),1/31→2/29(うるう年), 12/31→1/31 その他そのドメインで意味を持つ境界値
このようなケースをテストするようにしています。
両者に共通することとしては、「境界を意識する」ことです。処理の分岐はその境界で問題が発生しがちなので、境界を跨ぐようにしてテストケースを設計します。このような技法を、テストの世界では「境界値分析」と呼んでいます。
ただ、ここで大きな問題が出てきます。
「なぜ日付や時刻にはバグが混入しやすいのか」の項で書いた、「条件の再現が難しい」です。
処理の中で日付に依存させない
日付を再現するのが難しいという問題に対する一つの解決策は、処理の中で直接現在日付を参照しないことです。メソッドの引数に処理の条件となる日付や時刻を指定して、処理の内部で日付に依存しないようにすると、単体テスト(自動テスト)で比較的簡単に確認できるようになります。
まずは依存している例を示してみます。こどもの日のイベントを題材にしてみます。
from datetime import datetime def perform_action_based_on_current_date(): current_date = datetime.now() if current_date.month == 5 and current_date.day == 5: return "こどもの日のイベントを実施" return "通常のイベントを実施"
このコードでは、処理内で現在の日付を直接呼び出しているため、実際の5月5日にならないと処理が正しいかを確認できません。こちらを修正すると以下のようになります。
def perform_action_based_on_date(date): if date.month == 5 and date.day == 5: return "こどもの日のイベントを実施" return "通常のイベントを実施"
日付を外から渡せるので、任意の日付で確認できるようになりました。こちらをテストするコードは以下のようになります。
import unittest from datetime import datetime def perform_action_based_on_date(date): if date.month == 5 and date.day == 5: return "こどもの日のイベントを実施" return "通常のイベントを実施" class TestPerformActionBasedOnDate(unittest.TestCase): def test_kodomo_no_hi(self): # こどもの日の日付でテスト date = datetime(2024, 5, 5) self.assertEqual(perform_action_based_on_date(date), "こどもの日のイベントを実施") def test_not_kodomo_no_hi(self): # こどもの日ではない日付でテスト date = datetime(2024, 5, 6) self.assertEqual(perform_action_based_on_date(date), "通常のイベントを実施") # unittestの実行 if __name__ == '__main__': unittest.main()
モックを使用する
処理の内部で日付に依存している状態のコードをテストするにはモックを使用するのが有効です。
from datetime import datetime def is_birthday(user_birthday): today = datetime.now().date() return today.month == user_birthday.month and today.day == user_birthday.day
こちらは、誕生日を判定する関数です。こちらをモックしてテストすると以下のようになります。
import unittest from unittest.mock import patch from datetime import datetime import main class TestIsBirthday(unittest.TestCase): @patch('main.datetime') def test_is_birthday(self, mocked_datetime): mocked_datetime.now.return_value = datetime(2024, 4, 1) # ユーザーの誕生日が4月1日 user_birthday = datetime(2000, 4, 1).date() self.assertTrue(main.is_birthday(user_birthday)) # ユーザーの誕生日が4月2日 user_birthday = datetime(2000, 4, 2).date() self.assertFalse(main.is_birthday(user_birthday)) if __name__ == '__main__': unittest.main()
日付をモックすることで任意の日付でテストできるようになりますが、関数自体は現在の日付に依存しているため、依存しないようにする設計に比べるとテストが複雑になりがちです。
終わりに
今回は日付や時刻について述べてきましたが、それ以外にも各領域によって分岐条件の「境界」が存在していることと思います。
未来の障害を防ぐためにも、今一度日付や時刻をはじめとした「境界」を見直してみるのはいかがでしょうか。
(文:かいり)
「paizaラーニング」では、未経験者でもブラウザさえあれば、今すぐプログラミングの基礎が動画で学べるレッスンを多数公開しております。
詳しくはこちら