14

While developing a set of date calculations and language rules for converting numeric values and dates to strings, I'm writing tests that assert the outcome of the string formatting method. An imaginary assertion for it might looks like this:

NSAssert([dateString isEqualToString:@"Three days, until 6:00 PM"], @"Date string should match expectation");

However, because the app is localized for several languages, and my fellow developers are also from and in different locales than I, it can happen that your device or simulator is set to a different locale than the one that the tests are being written for. In a scenario like this, the contents of the dateString might be something like:

@"Drie dagen, tot 18:00" // the assertion fails
@"Drei Tage, bis 18 Uhr" // the assertion also fails

This may or may not be the correct date notation for these locales, but the part that my question is about, is how to be able to run tests to a specific locale, when the underlying code makes use of Apple API like this:

[NSDateFormatter localizedStringFromDate:date 
                 dateStyle:NSDateFormatterNoStyle 
                 timeStyle:NSDateFormatterShortStyle];

I would love to cover at two or more languages in my assertions, with something like this:

[NSSomething actionToSetTheLocaleTo:@"en_US"];
dateString = ...; // the formatting
NSAssert([dateString isEqualToString:@"Three days, until 6:00 PM"], @"match en_US");

[NSSomething actionToSetTheLocaleTo:@"nl_NL"];
dateString = ...; // the formatting
NSAssert([dateString isEqualToString:@"Drie dagen, tot 18:00"], @"match nl_NL");

Who knows how to achieve this effect?

Notes:

  • Changing the preferred language does not cut it, it also needs to influence the NSDateFormatter and NSNumberFormatter behavior.
  • Because this is for unit testing purposes only, I'd be content with using private API. However, for the benefit of other people stumbling on this post, public API is preferred.
  • Passing a custom locale to each and every date or number formatting API might be a final consideration, but I'm posting this question hoping to avoid falling back to those extreme measures. If you however know this to be the only solution, please provide some reference and I'll waste no more time

Links on the topic:

epologee
  • 11,229
  • 11
  • 68
  • 104
  • 4
    Have you tried method swizzling to provide your own implemenation of `+[NSLocale currentLocale]`? Of course this would only work if `NSDateFormatter` uses this method internally as well. – Desmond Oct 11 '13 at 15:38
  • @Desmond, awesome! No I hadn't tried it and yes, the NSDateFormatter class method(s) do use the autoupdatingCurrentLocale and currentLocale methods, and swizling works! If you could post this as an answer to this question, I will give it my check mark and add the category methods I'm using with my tests for clarity. Thanks! – epologee Oct 11 '13 at 19:16
  • @Desmond you have 22 hours left to claim the bounty. Act fast and add an answer! – epologee Oct 14 '13 at 13:41
  • 2
    Whoops, I didn't check back on this in time. No matter though, glad I could help! – Desmond Oct 24 '13 at 11:55
  • **it happens. Thanks! – epologee Oct 24 '13 at 12:00

3 Answers3

19

@Desmond pointed out a working solution. Until he places an answer in here to put this info in, let me summarize what I ended up doing with a bit of code.

The solution, turns out, is "as easy" as swizzling the methods that the class methods use internally:

beforeEach(^{
    [NSBundle ttt_overrideLanguage:@"nl"];
    [NSLocale ttt_overrideRuntimeLocale:[NSLocale localeWithLocaleIdentifier:@"nl_NL"]];
});

afterEach(^{
    [NSLocale ttt_resetRuntimeLocale];
    [NSBundle ttt_resetLanguage];
});

The ttt_... methods you see above use the categories on NSObject, NSLocale and NSBundle to check at runtime whether it should use the original methods or return something else. This method works flawlessly when writing your tests, and although it doesn't technically use any private API, I would strongly suggest only to use this in your test setup, not for anything you submit to the App Store for review.

In this gist you'll find the Objective-C categories I added to my app's test target to achieve the required behavior.

epologee
  • 11,229
  • 11
  • 68
  • 104
  • Awesome answer and the GIST was HUGELY helpful. – Mike Jun 27 '14 at 19:36
  • Glad to be of help :) – epologee Jun 30 '14 at 21:21
  • You sir, are a GOD! :) There are TONS of answers here on SO but none of them do it as best as you do :) Cheers! – Mihai Fratu Dec 09 '15 at 00:04
  • Will it work in Swift, because I thinking about rewriting it? – mkkrolik Sep 26 '19 at 10:34
  • Swizzling does not exist in Swift. In the early days of Swift it would have worked, because DateFormatter was a bridged Objective-C class. I’m not aware of the current state of bridging between Swift and Objective-C, however, maybe this doesn’t work anymore. Did you try it out? – epologee Sep 27 '19 at 11:36
3

Since this is in a unit test, have you tried setting the locale on the NSDateFormatter? You should not have to set the locale on the whole simulator, you can make it a parameter of your tests. Most cocoa methods that are dependent on locale can take it as a parameter or property.

Something like:

NSLocale *locale = [NSLocale localeWithLocaleIdentifier:@"en-US"];
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setLocale:locale];
[formatter setDateStyle: NSDateFormatterNoStyle];
[formatter setTimeStyle: NSDateFormatterShortStyle];
[formatter setFormatterBehavior:NSDateFormatterBehavior10_4];
thing = [formatter stringForObjectValue:date];

The localizedStringFromDate:dateStyle:timeStyle: method you're using is documented here, and it's pretty explicit about everything but the locale. So you can do the same steps, but set the locale to something other than the system's current locale using the steps outlined above.

quellish
  • 21,123
  • 4
  • 76
  • 83
  • Note that this doesn't change the simulator locale, but shows how to set the locale for what you're testing. – quellish Oct 08 '13 at 09:55
  • Yes, this would be my fallback scenario. Since this is an existing calendar app though, there are *a lot* of places where the class methods of the date and number formatters are used. But I'm hoping to escape changing them all... – epologee Oct 08 '13 at 14:54
2

The only way I see is what quellish has mentioned. However, you mentioned it exists in a lot of places.

Instead of rewriting all your current code, in your pch you could do something fancy like

#import "UnitTestDateFormatter.h"
#define NSDateFormatter UnitTestDateFormatter

And then simply create a subclass to handle it:

@implementation UnitTestDateFormatter

- (id) init
{
    self = [super init];

    if(self != nil)
    {
        [self setLocale:...];
    }

    return self;
}

@end

At least then your code can remain unchanged.

SomeGuy
  • 9,670
  • 3
  • 32
  • 35
  • Interesting hack. I've never used defines to steal built in class names, but for testing this seems like something to explore. However, there's no need to test the behavior of the NSDateFormatter, but the composition of its elements by the project's formatting classes is what I'm curious about. Thanks for sharing. – epologee Oct 11 '13 at 14:20
  • Something to know about the #define is that it operates at compile time, and works like a find and replace. In "#define XXX YYY" will take any places in your code that have XXX and replace them with YYY. Of course, this only applies to classes that have imported and seen the #define. Putting it in the pch like I mentioned will affect all source files (not existing binary) because the pch is auto imported by all source files. – SomeGuy Oct 12 '13 at 02:36
  • Also I realised is that in the .h the #define would also replace the right subclass NSDateFormatter, so it would become something like @interface UnitTestDateFormatter : UnitTestDateFormatter. If you wanted to use this, you'd probably need to use #undef before the class declaration, then #define again at the bottom. – SomeGuy Oct 12 '13 at 02:38
  • Why don't you edit your answer to contain this info, instead of placing it here in the comments? – epologee Oct 14 '13 at 13:45