Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Execute unit tests in Xcode that test localization in several languages

I have an app that uses several different languages, and my goal is to have unit tests that can verify the translated text matches our source material.

So far, I have tried & read the following without great success:

  • How to force NSLocalizedString to use a specific language

This is partially successful, but the problem is that the language change does not take effect until the next time the app launches. I can live with this, but would like to have more control, such that I don't have to launch the tests and then re-launch to test the language I want.

  • Building from the Command Line with Xcode FAQ

I read this Apple document on command line builds, and it looks promising since I can specify a scheme. I duplicated my app's scheme, and modified the language under Run in this case to be Spanish, as seen here:

enter image description here

However, under Test, I have options to use the arguments & environment variables set in Run, but nothing about using its Options.

enter image description here

Launching the app under my modified scheme with the Run language set to Spanish does not get me Spanish strings unfortunately.

Finally, I know there is an option under Test / Options to show non-localized strings, but this does not solve my problem of are they correct.

enter image description here

To perform on device testing is extremely time consuming due to the need to trigger various edge cases that generate errors, which is why I want the unit tests to do it for me.

like image 439
CodeBender Avatar asked Jun 27 '17 18:06

CodeBender


2 Answers

Now with Test Plans in Xcode 11 you can define different configurations for each test. So define different configurations that each one launch the app with specific language. If specific language like persian not shown in Application Language, add its language code in Arguments Passed On Launch like this:

-AppleLanguages (fa)
like image 51
Mohammad Eslami Avatar answered Nov 08 '22 14:11

Mohammad Eslami


I'm not sure there is a straight answer to your question directly so I'm providing you with some additional ideas on how to solve your problem. I use the structures below to test all my string localisations but only that they resolve to a valid dictionary entry, not what that dictionary entry actually contains. However, I believe you could add the extra check without too much difficulty.

This applies to Swift only

I no longer use NSLocalizedString. Instead, I use a combination of a localised .plist ("Strings.plist" for want of a better name) file and an extension on String thus:

extension String {
var localized: String {
    return EKTLocaliser.sharedInstance.localize(string: self)
}

}

The EKTLocaliser object referenced above looks like this:

final class EKTLocaliser: NSObject {

static let sharedInstance : EKTLocaliser = {
    let instance = EKTLocaliser()
    return instance
}()

private var localizableDictionary: NSDictionary

private override init() {
    self.localizableDictionary = [:]
}

func setDictionary(lDict: NSDictionary) {
    self.localizableDictionary = lDict
}

func localize(string: String) -> String {
    guard let localizedString = ((localizableDictionary.value(forKey: string) as AnyObject).value(forKey: "value") as? String) else {
        assert(true, "Missing translation for: \(string)")
        return "--"
    }
    return localizedString
}

}

This localised class requires that each entry in the .plist file is a dictionary with a "value" key (there is also a "comment" key where you can put a note for the translator but the localiser object is not dependent on the comment)

Here is an example of one entry in the Base (.plist) dictionary:

Base localisation example entry

In your application code you can use this construct (depends, see below):

myLabel.text = someTextString.localized

Now to your point regarding unit testing. In your unit test, you can instantiate an instance of the localizer, load a .plist dictionary into it and you have full access to all of your localised strings. The specific .plist that you load will depend on which localisation you are wanting to test. You can get a list of all localisations in your project from the main bundle thus:

       self.availableLanguages = Bundle.main.localizations

Then, using that list, load each localisation dictionary (and test) in turn thus:

        if let dict = loadDictionary(root: self.availableLanguages[i]) {
        self.stringLocaliser?.setDictionary(lDict: dict)
    }
    else {
        XCTFail("Cannot load Base localisation dictionary")
    }

where loadDictionary is:

    func loadDictionary(root: String) -> NSDictionary? {
    if let path = Bundle.main.path(forResource: root + ".lproj/Strings", ofType: "plist") {
        return NSDictionary(contentsOfFile: path)
    }
    else {
        assertionFailure("Cannot instantiate path for Strings.plist")
    }
    return nil
}

Finally, I create enum constants for all keys into the localisation dictionary and iterate over the enum to test all strings. The only testing that I do is to verify that the localisation resolves to a valid dictionary entry but you could extend this to test that it resolves to what you expect (i.e., the correct translation):

enum Ls:String {
case kLearnShu              = "shu"
case kLearnHa               = "ha"
case kLearnRi               = "ri"
case kLearnExercise1        = "ex1"
case kLearnExercise2        = "ex2"
case kLearnExercise3        = "ex3"

}

The unfortunate thing with this enum approach is that it is invasive in your code. Instead of writing

mLabel.text = kLearnExercise1.localized

you have to write:

myLabel.text = Ls.kLearnExercise1.rawValue.localized

which is an unfortunate pain. Maybe there is a better way out there...

Ah, final point... iterating over a Dictionary is a bit seat-of-the-pants but I use this code from other postings on SO. As it is only test code and never appears in your application itself, I'm OK using it.

func iterateEnum<T: Hashable>(_: T.Type) -> AnyIterator<T> {
var i = 0
return AnyIterator {
    let next = withUnsafeBytes(of: &i) { $0.load(as: T.self) }
    if next.hashValue != i { return nil }
    i += 1
    return next
}

}

Hope that gives you some ideas...

Acknowledgments and respect to all the SO postings from which I cobbled together this approach...

like image 27
Andrew Coad Avatar answered Nov 08 '22 13:11

Andrew Coad