Search code examples
swiftunit-testingnsdatenscalendar

Why are my simple date calculations sometimes failing in Swift 3.1?


I have a unit test that looks like this:

  func testManyYearsAgo() {
    for year in 2...77 {
      let earlierTime = calendar.date(byAdding: .year, value: 0 - year, to: now)
//      print(year)
//      print(dateDifference.itWasEstimate(baseDate: now, earlierDate: earlierTime!))
      XCTAssertEqual(dateDifference.itWasEstimate(baseDate: now, earlierDate: earlierTime!), "\(year) years ago")
    }
  }

now is defined higher up as just Date() calendar is Calendar.current

It's testing a class that looks something like this:

class DateDifference {
  func itWasEstimate(baseDate: Date, earlierDate: Date) -> String {

    let calendar = Calendar.current
    let requestedComponent: Set<Calendar.Component> = [ .year, .month, .day, .hour, .minute, .second]
    let timeDifference = calendar.dateComponents(requestedComponent, from: baseDate, to: earlierDate)

    if timeDifference.year! < 0 {
      if timeDifference.year! == -1 {
        return "Last year"
      } else {
        return "\(abs(timeDifference.year!)) years ago"
      }
    }

    return ""
  }
}

When I run the unit test, I usually (but not always) get an error like:

XCTAssertEqual failed: ("30 years ago") is not equal to ("31 years ago")

Those errors usually begin after the year value is over 12.

If I uncomment out the print statements, it works fine no matter how many times I run the code.

This lends me to believe that maybe there's some weird async thing going on, but I sure can't tell by looking. I'm relatively new to swift development, so there may just be something fundamental that I'm missing.


Solution

  • Here is a self-contained reproducible example demonstrating the problem:

    var calendar = Calendar(identifier: .gregorian)
    calendar.locale = Locale(identifier: "en_US_POSIX")
    calendar.timeZone = TimeZone(secondsFromGMT: 0)!
    
    let formatter = DateFormatter()
    formatter.calendar = calendar
    formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
    
    let d1 = DateComponents(calendar: calendar, year: 2017, month: 1, day: 1, hour: 0,
                            minute: 0, second: 0, nanosecond: 456 * Int(NSEC_PER_MSEC)).date!
    print("d1:", formatter.string(from: d1))
    
    let d2 = calendar.date(byAdding: .year, value: -20, to: d1)!
    print("d2:", formatter.string(from: d2))
    
    let comps: Set<Calendar.Component> = [ .year, .month, .day, .hour, .minute, .second, .nanosecond]
    let diff = calendar.dateComponents(comps, from: d1, to: d2)
    print(diff)
    print("difference in years:", diff.year!)
    

    Output

    d1: 2017-01-01 01:00:00.456
    d2: 1997-01-01 01:00:00.456
    year: -19 month: -11 day: -30 hour: -23 minute: -59 second: -59 nanosecond: -999999756 isLeapMonth: false 
    difference in years: -19
    

    Due to rounding errors (Date uses a binary floating point number as internal representation), the difference is computed as a tiny bit less than 20 years, and the years component of the difference comes out as -19 instead of the expected -20.

    As a workaround, you can round the dates to full seconds, that seems to fix the issue:

        let baseDate = Date(timeIntervalSinceReferenceDate: baseDate
            .timeIntervalSinceReferenceDate.rounded())
        let earlierDate = Date(timeIntervalSinceReferenceDate: earlierDate
            .timeIntervalSinceReferenceDate.rounded())
    

    You might also consider to file a bug report at Apple.