iosswiftgithub-actionsxctestxctestcase

Swift MeasurementFormatter Ignores Custom Locale on CI (GitHub Actions) but Works Locally — Always Uses Period Instead of Comma as Decimal Separator


I've created the following class that's a custom formatter supporting formatting both attributed and standard strings:

import Foundation
import SwiftUI

/// A formatter that converts a distance value (in meters) into a localized, styled `AttributedString`.
///
/// This formatter:
/// - Automatically switches between metric (km) and imperial (mi) based on the user's locale.
/// - Applies custom fonts and colors to the numeric value and unit label separately.
/// - Uses increased precision for shorter distances (< 200 km or mi).
final class AttributedDistanceFormatter {
  private let style: AttributedFormatterStyle
  let locale: Locale

  init(style: AttributedFormatterStyle, locale: Locale = .current) {
    self.style = style
    self.locale = locale
  }

  /// Formatter for distances ≥ 200 km/mi (shows up to 1 decimal place).
  private lazy var measurementFormatter: MeasurementFormatter = {
    let formatter = MeasurementFormatter()
    formatter.locale = locale
    formatter.unitOptions = .providedUnit
    formatter.unitStyle = .medium

    let numberFormatter = NumberFormatter()
    numberFormatter.minimumFractionDigits = 0
    numberFormatter.maximumFractionDigits = 1
    formatter.numberFormatter = numberFormatter

    return formatter
  }()

  /// Formatter for distances < 200 km/mi (shows up to 2 decimal places).
  private lazy var shortMeasurementFormatter: MeasurementFormatter = {
    let formatter = MeasurementFormatter()
    formatter.locale = locale
    formatter.unitOptions = .providedUnit
    formatter.unitStyle = .medium

    let numberFormatter = NumberFormatter()
    numberFormatter.minimumFractionDigits = 0
    numberFormatter.maximumFractionDigits = 2
    formatter.numberFormatter = numberFormatter

    return formatter
  }()

  /// Converts a distance (in meters) into a localized and styled `AttributedString`.
  ///
  /// - Parameter distance: The distance in meters.
  /// - Returns: A styled string like “3.2 km” or “0.75 mi”.
  func attributedString(from distance: Double) -> AttributedString {
    let formatted = formattedDistanceString(from: distance)
    let components = formatted.split(separator: " ", maxSplits: 1).map(
      String.init)

    guard components.count == 2 else {
      // Fallback: apply value style to the entire string if format fails
      return attributedValueString(formatted)
    }

    let number = components[0]
    let unit = components[1]

    var result = AttributedString()
    result += attributedValueString(number)
    result += attributedUnitLabel(" \(unit)")
    return result
  }

  /// Converts a distance (in meters) into a plain localized string (e.g., "1.8 mi").
  func string(from distance: Double) -> String {
    formattedDistanceString(from: distance)
  }

  /// Internal: Computes the raw distance string using appropriate precision based on value.
  func formattedDistanceString(from distance: Double) -> String {
    let measurement = Measurement(value: distance, unit: UnitLength.meters)
    let usesMetric = locale.measurementSystem == .metric
    let unit = usesMetric ? UnitLength.kilometers : UnitLength.miles
    let converted = measurement.converted(to: unit)

    return (converted.value < 200)
      ? shortMeasurementFormatter.string(from: converted)
      : measurementFormatter.string(from: converted)
  }

  /// Applies the value font/color style to the numeric part of the distance.
  private func attributedValueString(_ string: String) -> AttributedString {
    var attributed = AttributedString(string)
    attributed.font = style.valueFont
    attributed.foregroundColor = style.valueColor
    return attributed
  }

  /// Applies the label font/color style to the unit part of the distance.
  private func attributedUnitLabel(_ string: String) -> AttributedString {
    var attributed = AttributedString(string)
    attributed.font = style.labelFont
    attributed.foregroundColor = style.labelColor
    return attributed
  }
}


struct AttributedFormatterStyle {
  
  /// The color to apply to the numeric value part of the formatted string.
  let valueColor: Color
  
  /// The font to apply to the numeric value part of the formatted string.
  let valueFont: Font
  
  /// The color to apply to the unit label part of the formatted string.
  let labelColor: Color
  
  /// The font to apply to the unit label part of the formatted string.
  let labelFont: Font
  
  /// Initializes a new style configuration for an attributed formatter.
  ///
  /// - Parameters:
  ///   - valueColor: The color to use for value text.
  ///   - valueFont: The font to use for value text.
  ///   - labelColor: The color to use for label text.
  ///   - labelFont: The font to use for label text.
  init(
    valueColor: Color,
    valueFont: Font,
    labelColor: Color,
    labelFont: Font
  ) {
    self.valueColor = valueColor
    self.valueFont = valueFont
    self.labelColor = labelColor
    self.labelFont = labelFont
  }
}

The formatter is localized, so that it respects the user's preference for decimal, thousands separators, metric or imperial measurement system and others.

To test this behavior I've created the following test:

import SwiftUI
import XCTest

@testable import App

final class AttributedDistanceFormatterTests: XCTestCase {

  let style = AttributedFormatterStyle(
    valueColor: .red,
    valueFont: .system(size: 10, weight: .bold),
    labelColor: .blue,
    labelFont: .system(size: 8, weight: .light)
  )

  private func decimalDigitsCount(in string: String) -> Int {
    // Normalize non-breaking space
    let normalized = string.replacingOccurrences(of: "\u{00a0}", with: " ")

    guard
      let numberPart = normalized.split(separator: " ").first,
      let separatorIndex = numberPart.firstIndex(where: { $0 == "." || $0 == "," })
    else {
      return 0
    }

    return numberPart.distance(from: separatorIndex, to: numberPart.endIndex) - 1
  }

  func testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale() {
    let formatter = AttributedDistanceFormatter(
      style: style, locale: Locale(identifier: "fr_FR"))

    let distanceMeters = 1234.56  // ~1.23456 km < 200

    let string = formatter.string(from: distanceMeters)

    let trimmedString = string.trimmingCharacters(in: .whitespacesAndNewlines)
    XCTAssertTrue(
      trimmedString.contains("km"),
      "Expected suffix 'km' in: \(trimmedString)"
    )
    XCTAssertTrue(
      trimmedString.contains(","),
      "Expected comma as decimal separator in: \(trimmedString)" // French uses comma
    )

    XCTAssertLessThanOrEqual(decimalDigitsCount(in: string), 2)

    let attributed = formatter.attributedString(from: distanceMeters)
    XCTAssertEqual(attributed.runs.count, 2)

    let runs = attributed.runs
    let firstRun = runs[runs.startIndex]
    XCTAssertEqual(firstRun.foregroundColor, style.valueColor)
    XCTAssertEqual(firstRun.font, style.valueFont)

    let secondRunIndex = runs.index(after: runs.startIndex)
    let secondRun = runs[secondRunIndex]
    XCTAssertEqual(secondRun.foregroundColor, style.labelColor)
    XCTAssertEqual(secondRun.font, style.labelFont)
  }
}


This test is executing perfectly on my machine locally. However, when I push this test to GitHub, their CI is failing in a very weird way:

error: -[tests.AttributedDistanceFormatterTests testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale] : XCTAssertTrue failed - Expected comma as decimal separator in: 1.23 km
Test Case '-[tests.AttributedDistanceFormatterTests testMetricLocaleShortDistanceUsesTwoDecimalPlacesAndKmForFrenchLocale]' failed (0.383 seconds).
Test Suite 'AttributedDistanceFormatterTests' failed at 2025-05-28 14:25:50.175.

Yet, when I check the locale in that runner, I get the following:

🟢 Test Locale Info (fr_FR):
   • Identifier: fr_FR
   • Decimal Separator: ','
   • Grouping Separator: ' '
   • Measurement System: metric
   • Region Identifier: FR
   • Language Code: fr

So we have the following situation:

Why could this happen? Why does it work on my machine?

I've noticed this on my machine as well:

This is the output of the current locale:

🟡 Current Locale Info:
   • Identifier: en_US@rg=fizzzz
   • Decimal Separator: ','
   • Grouping Separator: ' '
   • Measurement System: metric
   • Region Identifier: FI
   • Language Code: en

And on the GitHub:

🟡 Current Locale Info:
   • Identifier: en_US
   • Decimal Separator: '.'
   • Grouping Separator: ','
   • Measurement System: ussystem
   • Region Identifier: US
   • Language Code: en

My assumption is that the locale set in the test is not actually used and a default locale is used. Since on my machine, the decimal separator is ",", i.e. comma, the test passes and on GitHub's it's the period and this break the test. No matter what locale I'm using, it's always the period that's used on GitHub.

Questions:

  1. Why this behavior is happening? and is there any way to fix this?
  2. What is this weird locale: en_US@rg=fizzzz on my machine?

This is the code I'm using to print out the locale in the test (just insert in the test):

    let currentLocale = Locale.current
    let testLocale = Locale(identifier: "fr_FR")

    // Print system current locale info
    print("🟡 Current Locale Info:")
    print("   • Identifier: \(currentLocale.identifier)")
    print("   • Decimal Separator: '\(currentLocale.decimalSeparator ?? "nil")'")
    print("   • Grouping Separator: '\(currentLocale.groupingSeparator ?? "nil")'")
    print("   • Measurement System: \(currentLocale.measurementSystem)")
    print("   • Region Identifier: \(currentLocale.region?.identifier ?? "nil")")
    print("   • Language Code: \(currentLocale.language.languageCode?.identifier ?? "nil")")

    // Print test locale info
    print("\n🟢 Test Locale Info (fr_FR):")
    print("   • Identifier: \(testLocale.identifier)")
    print("   • Decimal Separator: '\(testLocale.decimalSeparator ?? "nil")'")
    print("   • Grouping Separator: '\(testLocale.groupingSeparator ?? "nil")'")
    print("   • Measurement System: \(testLocale.measurementSystem)")
    print("   • Region Identifier: \(testLocale.region?.identifier ?? "nil")")
    print("   • Language Code: \(testLocale.language.languageCode?.identifier ?? "nil")")

    // Print all available locales (no trimming)
    let availableLocales = Locale.availableIdentifiers.sorted()
    print("\n📦 Available Locales (\(availableLocales.count)):")
    for localeID in availableLocales {
        print("   • \(localeID)")
    }


Solution

  • The problem was setting locale in the NumberFormatter that's used inside the measurement formatter. It defaulted to the current locale:

    
    
      /// Formatter for distances ≥ 200 km/mi (shows up to 1 decimal place).
      private lazy var measurementFormatter: MeasurementFormatter = {
        let formatter = MeasurementFormatter()
        formatter.locale = locale
        formatter.unitOptions = .providedUnit
        formatter.unitStyle = .medium
    
        let numberFormatter = NumberFormatter()
        numberFormatter.locale = locale // FIX1
        numberFormatter.minimumFractionDigits = 0
        numberFormatter.maximumFractionDigits = 1
        formatter.numberFormatter = numberFormatter
    
        return formatter
      }()
    
      /// Formatter for distances < 200 km/mi (shows up to 2 decimal places).
      private lazy var shortMeasurementFormatter: MeasurementFormatter = {
        let formatter = MeasurementFormatter()
        formatter.locale = locale
        formatter.unitOptions = .providedUnit
        formatter.unitStyle = .medium
    
        let numberFormatter = NumberFormatter()
        numberFormatter.locale = locale // FIX2
        numberFormatter.minimumFractionDigits = 0
        numberFormatter.maximumFractionDigits = 2
        formatter.numberFormatter = numberFormatter
    
        return formatter
      }()