Stop Reinventing the Wheel: A QA Pattern for Date Math in User-Facing Code
Every backend that touches a date of birth ends up reinventing the same arithmetic. I've watched it happen in three codebases this year alone: a fintech KYC service, a healthcare intake portal, and a small HR tool for a startup. The bug shape is almost always identical — somebody computes age by subtracting birth year from current year, the QA suite passes because it only tests inputs born in 1970 and 2000, and then someone born on February 29 walks in and gets flagged as underage. Or worse, a leap-year-spanning eligibility window quietly lets the wrong person through. This article is a checklist for engineers who inherit one of these systems and need to harden it before the next incident. It assumes you've already decided you need to compute a duration between two dates correctly, and your job is to figure out how to test that computation so you stop shipping regressions. The Two Questions You Actually Need to Answer Before writing a single assertion, ask the business which of these they care about. The implementation is different for each, and conflating them is the source of most date-math bugs. How many full years has the person been alive? This is the classic "age in years" question — what a doctor's office, a voting registration form, or an age-gated content filter wants. The answer changes only on the birthday. Has a duration threshold been crossed? This is the eligibility question — "has this user been enrolled for at least 90 days," "was this contract signed more than 30 days ago," "did the trial expire." The answer is a boolean with a specific reference date. The age-in-years case is subtler because of the birthday boundary, and it's where most teams stumble. The duration-threshold case is just subtraction with a comparison, but the comparison must use the right granularity — comparing a millisecond timestamp to a day count is a famous source of off-by-one-day bugs when daylight saving time shifts occur. The Date and Time Arithmetic section of MDN's Date reference is worth a read if you're implementing in JavaScript, and the datetime topic in the Python docs covers the equivalent pitfall with timedelta. The Reference Test Matrix You Should Commit to Your Repo Most date-math codebases have maybe two test cases: "born today" and "born 30 years ago on the same day." That's not a test suite, that's a smoke test. For age-in-years logic, commit a fixture-driven test that covers these rows at minimum: Born exactly today — must return 0. Born one calendar day before today — must return 0, not 1. Born exactly one year ago to the day — must return 1. Born one year and one day ago — must return 1. Born on Feb 29 in a leap year, queried on Feb 28 of a non-leap year — must return the elapsed years, not treat the missing day as "not yet had a birthday this year." Born on Feb 29 in a leap year, queried on March 1 of a non-leap year — must return the elapsed years (birthday observed March 1). Born on Dec 31, queried on Jan 1 the next year — must return 0, not 1. Born on Jan 1, queried on Dec 31 of the previous year — must return 0. For each row, store the birthday and the "as of" date as explicit ISO-8601 strings in your fixture file, never as new Date() literals in the test body, because the latter will rot the moment someone runs the suite in a different timezone. The ISO 8601 entry on Wikipedia is a reasonable anchor for the team on why the format is preferable to anything locale-driven. Injecting the Clock: The Single Most Useful Refactor Most age-computation bugs are not in the math. They are in the fact that the math depends on now, and now is hidden inside the function being tested. You can't unit-test what you can't control. Refactor every age computation so the reference date is a parameter. Default it to now() only at the public boundary (controller, handler, scheduled job). Internally, everything takes (birth_date, reference_date) explicitly. This sounds pedantic until you see how much it simplifies the test: def age_in_years(birth: date, ref: date) -> int: years = ref.year - birth.year if (ref.month, ref.day) < (birth.month, birth.day): years -= 1 return years Now the test isn't testing "the current moment," it's testing "given these two inputs, does this function produce this output." That single refactor usually eliminates a third of the team's date-bug backlog. When the Rule Has a Time-of-Day Component This is the case that catches mature codebases. The product says "users under 18 cannot purchase," but the actual stored rule is "users whose 18th birthday has not yet passed at the moment of purchase." If your backend stores the birthday with a time component (and many do, because the database column is a TIMESTAMP rather than a DATE), you have to decide whether 2007-03-15 23:59:00 plus 18 years crosses the threshold on 2025-03-15 14:00:00 or 2025-03-16. The answer depends on whether your business rule operates in calendar days or in elapsed time. I've seen teams burn a week on this. The pragmatic answer is: strip the time component at the boundary, then use calendar-day comparison. Almost no product actually cares about the hour; they care about the date. If your column is TIMESTAMP, cast to DATE on read and the question evaporates. For teams operating across jurisdictions, remember that the rules of whose calendar day matters can vary. A patient admitted at 23:50 in Tokyo is in a different calendar day than a physician reviewing the chart at 00:10 in Berlin. If your system crosses timezones, decide explicitly which anchor you use. The IANA Time Zone Database overview is worth skimming so the team at least agrees on terminology before they disagree on policy. A Pragmatic Workflow for the Next Time This Comes Up When a ticket lands asking for an age check or an eligibility-window check, walk through this sequence: Classify the question as age-in-years or duration-threshold. Locate the existing helper. Most codebases already have one, hidden in a utils/ folder, written by someone who left. Read it before writing a new one. Write the fixture file with at least the eight rows above, plus any domain-specific rows (e.g. "free trial expires exactly on day 30 at midnight UTC"). Inject the clock. If the helper takes now implicitly, refactor before adding tests. Add the rule check as a separate function that consumes the helper's output. Age computation and age policy are different concerns and should live in different files. Run the suite in CI with a fixed system clock for at least one test case, using something like freezegun or timecop, to prove the clock is actually injectable. Document the boundary behavior in the helper's docstring, especially what happens on Feb 29 and on month-end edges. If your team has standardized on Excel for any of these calculations — which happens more often than engineers like to admit, especially in HR and operations — pair the engineering helper with a spreadsheet that uses the same logic. The in-depth walkthrough on calculating age between two dates is a useful reference when the business needs a non-engineer to verify the rule. Frequently asked questions What's the difference between using a TIMESTAMP and a DATE column for birthdays? A DATE column stores only the calendar day. A TIMESTAMP stores a moment in time, down to microseconds, and is interpreted in the session timezone. For age logic, you almost always want DATE, because the rule operates on calendar days and the time component is either ignored or, worse, used inconsistently across queries. If you must store TIMESTAMP for legacy reasons, cast to DATE at the application boundary. Should leap-year babies be treated specially? No special-casing is needed if you use the standard "subtract years, decrement if birthday hasn't occurred yet" pattern. The function handles Feb 29 correctly because it compares month-and-day tuples; when the reference date is in a non-leap year and is Feb 28 or March 1, the comparison still works. What you must avoid is birthday.addYears(n) style libraries that throw when the target year is not a leap year — those libraries need a policy on whether to roll forward to Feb 28 or March 1, and not all of them document it. My tests pass locally but fail on the CI server once a month. What's happening? The CI server's clock and yours are fine. The problem is almost certainly that one of your tests uses new Date() directly and runs near midnight UTC. The local machine and the CI runner cross the day boundary at different wall-clock times. This is the single most common reason date tests are flaky, and it's exactly why fixture-driven tests with explicit ISO-8601 strings are non-negotiable. Is there an existing standard I should be citing in my code review comments? Yes. If you're working in Python, PEP 8 covers naming but not date logic; the relevant authority is the datetime module documentation and, for cross-system interchange, ISO 8601. Citing those in a code review makes the date-of-birth column review land faster than re-explaining the Feb 29 case each time. This article was drafted with AI assistance and reviewed for technical accuracy before publishing.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to