If you have ever worked on a project that supports more than one language, you have definitely been through the headaches of keeping Localizable.strings
files consistent for different languages. This article tries to solve the most common problems with the help of unit tests.
Let's assume that, your project supports English (as default) and Italian. You most probably fall into one of the following traps:
- You add a new word into
en.lproj/Localizable.strings
, but, you forget to updateit.lproj/Localizable.strings
- You add a new word into
en.lproj/Localizable.strings
and you put the same word intoit.lproj/Localizable.strings
just because Italian translation is not ready yet. - You delete a few words accidentally or modify any of above files which leads to an inconsistency.
Solution
Language
- is an enum that groups the languages we are going to support.
enum Language: String, Codable {
case english = "en"
case italian = "it"
// Alternatively, you can conform to CaseIterable and use allCases property instead
static let supported: [Language] = [.english, .italian]
static let defaultLanguage: Language = .english
var displayName: String {
switch self {
case .english:
return "English"
case .italian:
return "Italiano"
}
}
}
To open Localizable.strings
file, we need to load language specific bundle.
func getBundle(for language: Language) -> Bundle? {
guard let path = Bundle.main.path(forResource: language.rawValue, ofType: "lproj") else {
return nil
}
return Bundle(path: path)
}
This function below loads Localizable.strings
from given bundle and converts into dictionary
func getLocalizableDictionary(from bundle: Bundle) -> [String: String]? {
guard let path = bundle.path(forResource: "Localizable", ofType: "strings") else {
return nil
}
let dictionary = NSDictionary(contentsOfFile: path)
return dictionary?.reduce(into: [String: String](), { $0[$1.key as! String] = $1.value as? String})
}
Here goes another helper function that simplifies the loading process
func getLocalizedDictionary(for language: Language) -> [String: String]? {
guard let bundle = getBundle(for: language) else {
return nil
}
return getLocalizableDictionary(from: bundle)
}
The fun part begins here.
func test_translatedWords_basedOnDefaultLocale() {
let defaultLanguage = Language.defaultLanguage
let defaultLocalizedDictionary = getLocalizedDictionary(for: defaultLanguage)!
let supportedLanguagesExceptDefault = Language.supported.filter { $0 != defaultLanguage }
for language in supportedLanguagesExceptDefault {
let localizedDictionary = getLocalizedDictionary(for: language)!
for (key, defaultTranslation) in defaultLocalizedDictionary {
if let translation = localizedDictionary[key] {
if translation == defaultTranslation {
debugPrint("⚠️Warning: '\(key)' has identical translations at \(defaultLanguage.displayName) and \(language.displayName)")
}
} else {
XCTFail("'\(key)' is not translated into \(language.displayName)")
}
}
}
}
defaultLocalizedDictionary
contains key-value pairs for default language as dictionary. In our example, It is English and it is also considered to be the source of truth.
Inside for-loop, we check if other language dictionaries contain all words from default language. If any word is not found in localizedDictionary
, the test fails. If the language under test has identical word translation as in default language, a warning gets printed in the logs. In helps us to avoid the second problem mentioned in the beginning of the article.
Summary
This test prevents us from many localization related mistakes and lets us find missing pieces quickly. Feel free to give it a try in your project as well and let me know your thoughts.
Source code: LocalizationTests.swift