iOSアプリ開発の多言語対応のテスト方法のtips
はじめに
iOSアプリ開発における多言語サポートでは、「Localizable.strings」ファイルを用いてアプリ内で言語特有のコンテンツを実装します。開発者は一般的に、NSLocalizedString("Hello, world", comment: "")
のような構文を使用して、これらのローカライズされた文字列をアプリに統合します。
しかし、これらのファイルの長期的な管理と維持は、時折困ったことを引き起こすことがあります。例えば、「Localizable.strings」ファイル内のキー値が誤って変更されたり、タイプミスによりこれらの文字列が正しく適用されないことがあります。これらの問題がレビュープロセスで見落とされた場合、アプリのリリース版に表示エラーが紛れ込む可能性があります。
// Localizable.stringsファイル
"Hello, world!" = "こんにちは、世界!"
// ⭕ Localizable.stringsファイルのkeyに対応するvalueが表示される
label.text = NSLocalizedString("Hello, world!", comment: "") // こんにちは、世界!
// ❌ Helloの後に「,」が抜けているためkeyに対応するvalueが見つからず、指定した値がそのまま表示される
label.text = NSLocalizedString("Hello world!", comment: "") // Hello world!
本記事は、このような事態が散見されたあるプロジェクトの経験に触発され、これらの問題を根絶するためのシステムの開発と実装を目指した取り組み(大袈裟)を綴ったものです。
当該プロジェクトは最終的に「SwiftGen」を採用したため、私の開発したシステムは不要となり、日の目を見ることなくお蔵入りとなりました。
この記事は、実用化されなかったそのコードへの追悼としてだけでなく、同様の課題に取り組む方々の一助になることを期待します。
key
課題1 - 文字列形式の課題点
-
Localizable.strings
からのkey
呼び出しについて、key
が文字列形式であるため、コード補完の恩恵を受けられず、手動入力に依存することから、typoが発生しやすい。
Localizable.strings
のkey
管理を効率化するMyStrings
構造体の導入
解決1 - -
MyStrings
構造体を定義-
Localizable.strings
ファイル内のkey
を管理するために、MyStrings
という構造体を定義し、この構造体内にカテゴリーごとに分類されたenum
を配置することで、key
の整理と参照を容易に行えるように設計しました。
-
このアプローチにより、key
の呼び出し時にコード補完を活用できるようになり、タイポのリスクを軽減できます。
"hello" = "こんにちは"
"good_morning" = "おはようございます"
"good_night" = "おやすみなさい"
"decision" = "決定"
"back" = "戻る"
"cancel" = "キャンセル"
struct MyStrings {
// MARK: - 挨拶
enum Greeting: String {
case hello = "hello"
case goodMorning = "good_morning"
case goodNight = "good_night"
}
// MARK: - 操作
enum Command: String {
case decision = "decision"
case back = "back"
case cancel = "cancel"
}
}
この新しい構造体MyStrings
を導入してみます。
label.text = NSLocalizedString(MyStrings.Greeting.hello, comment: "") // こんにちは
コード補完を利用してkeyを入力できるようになりました。
ただ、コードが長いのが少し気になります。
課題2 - コードの冗長性
課題点の再確認
-
MyStrings
構造体を導入した結果、タイポのリスクは軽減されたが、コードが冗長になるという新たな課題が生じている。
MyStringsProtocol
の定義とenum
拡張
解決2 - -
MyStringsProtocol
の定義-
MyStringsProtocol
などの名前でprotocol
を定義し、ローカライズされた文字列を返すための変数localizedString
を実装します。
-
-
MyStringsProtocol
の拡張-
MyStringsProtocol
に準拠したenum
のrawValue
を使用してlocalizedString
を生成したいので、RawRepresentable
とSelf.RawValue == String
のwhere
節を追加することで、enum
のrawValue
にアクセスした具体的な実装を提供します。
-
-
enum
の拡張- 各
enum
がこのMyStringsProtocol
に準拠することで、コードの重複なくローカライズされた文字列を返す変数localizedString
をプロパティに持つことができます。
- 各
各enum
に共通のインターフェースを提供するprotocol
を導入することで、コードの重複を避けつつ、冗長性を排除して可読性を高めることができます。
+ protocol MyStringsProtocol {
+ var localizedString: String { get }
+ }
+
+ extension MyStringsProtocol where Self: RawRepresentable, Self.RawValue == String {
+ var localizedString: String {
+ return NSLocalizedString(self.rawValue, comment: "")
+ }
+ }
struct MyStrings {
// MARK: - 挨拶
- enum Greeting: String {
+ enum Greeting: String, MyStringsProtocol {
case hello = "hello"
case goodMorning = "good_morning"
case goodNight = "good_night"
}
// MARK: - 操作
- enum Command: String {
+ enum Command: String, MyStringsProtocol {
case decision = "decision"
case back = "back"
case cancel = "cancel"
}
}
修正したMyStrings
を導入してみます。
label.text = MyStrings.Greeting.hello.localizedString // こんにちは
かなりスッキリと書けるようになりました。
しかし、この実装にはMyStrings
構造体のenum
にローカライズキーのtypoがあったケースを検出する手段が提供されていません。
MyStrings
構造体の品質担保
課題3 - 課題点の再再確認
-
MyStrings
内でタイポが発生した場合の検出と修正の仕組みがない。
解決3 - 包括的なテストコードの実装
-
CaseIterable
プロトコルの適用-
MyStrings
の各enum
にCaseIterable
プロトコルを適用し、allCases
プロパティを使用して全てのケースを網羅的にテストできるようにします。
-
- 網羅的なテストの実施
-
allCases
を用いてfor
ループを実行し、各ケースに対してローカライズされた文字列が正しく返されるかどうかをAssert
文で確認します。
-
- テストによる追加漏れの防止
-
switch
文を使用することで、MyStrings
のenum
に新たなケースが追加された場合、それを呼び出すテストコードにもcase
文の追加が要求されるため、テストの追加漏れを自動的に防ぐことができます。(default
ケースの実装はNG)
-
このテストコードの実装により、Localizable.strings
の扱いにおける信頼性と効率性が大幅に向上することが期待されます。
struct MyStrings {
// MARK: - 挨拶
- enum Greeting: String, MyStringsProtocol {
+ enum Greeting: String, MyStringsProtocol, CaseIterable {
case hello = "hello"
case goodMorning = "good_morning"
case goodNight = "good_night"
}
// MARK: - 操作
- enum Command: String, MyStringsProtocol {
+ enum Command: String, MyStringsProtocol, CaseIterable {
case decision = "decision"
case back = "back"
case cancel = "cancel"
}
}
import XCTest
@testable import MyApp
class MyStringsTests: XCTestCase {
func testMyStrings() throws {
// MARK: - Greeting(挨拶)
MyStrings.Greeting.allCases.forEach { greeting in
switch greeting {
case .hello:
XCTAssertEqual(greeting.localizedString, "こんにちは")
case .goodMorning:
XCTAssertEqual(greeting.localizedString, "おはようございます")
case .goodNight:
XCTAssertEqual(greeting.localizedString, "おやすみなさい")
}
}
// MARK: - Command(操作)
MyStrings.Command.allCases.forEach { command in
switch command {
case .decision:
XCTAssertEqual(command.localizedString, "決定")
case .back:
XCTAssertEqual(command.localizedString, "戻る")
case .cancel:
XCTAssertEqual(command.localizedString, "キャンセル")
}
}
}
}
これでkey
とvalue
が一致して、期待する文言が出力されることを保証できました。
アプリに文言を一つ追加するたびに、Localizable.strings
とMyStrings
とtestMyStrings()
の追記修正を強要する、非常に開発者フレンドリーな実装が完成しました。(震え声)
ただ、現行のテスト環境では日本語のみのローカライズされた文字列が検証されており、多言語環境下での検証が不足しています。
課題4 - 異なる言語設定での検証
問題の再再々確認
- 現在のテスト実装では、日本語のローカライズされた文字列のみが検証されており、異なる言語設定での正確な動作を保証できない。
-
UserDefaults.standard.setValue(["en"], forKey: "AppleLanguages")
で言語設定の書き換えが可能だが、アプリの再起動が必要のため、テスト実行時に適用できない。
解決4 - 動的な言語設定の切り替え
-
Bundle
クラスの拡張- テスト時に特定の言語リソースをロードするための拡張を
Bundle
に追加します。
- テスト時に特定の言語リソースをロードするための拡張を
- メソッドスウィズリングの適用
- テスト時のみ
Bundle
の関連メソッドをカスタム実装に置き換え、特定の言語設定を強制します。
- テスト時のみ
- 言語ごとのテストケースの作成
- さまざまな言語環境でのローカライズされた文字列を検証するテストケースを作成します。
このアプローチにより、異なる言語設定での文字列のローカライズが正しく行われているかを包括的に検証することができます。これにより、多言語対応アプリケーションの品質と信頼性がさらに向上することが期待されます。
// テスト環境で言語設定を変更するための拡張
private extension Bundle {
// バンドルを保持するためのキー
private static var associatedBundleKey: UInt8 = 0
/// 言語を設定
static func setLanguage(_ language: String?) {
if let language = language,
let path = Bundle.main.path(forResource: language, ofType: "lproj") {
// 指定された言語がnilでない場合、指定言語のリソースバンドルのパスを取得
// 指定言語のリソースバンドルパスから指定言語のバンドルを作成
let languageBundle = Bundle(path: path)
// 現在のバンドルに関連付けられたオブジェクトとして、作成した指定言語のバンドルを保存
objc_setAssociatedObject(Bundle.main, &associatedBundleKey, languageBundle, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
} else {
// 指定された言語がnilの場合、関連付けられたオブジェクトをクリア
objc_setAssociatedObject(Bundle.main, &associatedBundleKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
// ローカライズメソッドのスワッピング
swizzleLocalization()
}
/// ローカライズされた文字列を返す
@objc private func myLocalizedString(forKey key: String, value: String?, table: String?) -> String {
if let associatedBundle = objc_getAssociatedObject(self, &Bundle.associatedBundleKey) as? Bundle {
return associatedBundle.localizedString(forKey: key, value: value, table: table)
} else {
return self.myLocalizedString(forKey: key, value: value, table: table)
}
}
/// メソッドのスワッピング(入れ替え)
private static func swizzleLocalization() {
// 元のlocalizedString(forKey:value:table:)メソッドのセレクタ(識別子)を取得
let originalSelector = #selector(localizedString(forKey:value:table:))
// myLocalizedString(forKey:value:table:)メソッドのセレクタ(識別子)を取得
let swizzledSelector = #selector(myLocalizedString(forKey:value:table:))
// それぞれのメソッドオブジェクトを取得
guard let originalMethod = class_getInstanceMethod(self, originalSelector),
let swizzledMethod = class_getInstanceMethod(self, swizzledSelector) else {
return
}
// 元のメソッドとスワップするメソッドの実装(処理)を入れ替える
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
テスト実行時にBundle.setLanguage("en")
のように実行することで、引数に指定した言語設定を参照してくれるので、日本語以外の言語対応のテストができるようになります。
後書き
この記事では、多言語対応のためのLocalizable.strings
の使用方法と、それに伴う問題点の解決策を実践的に探求しました。結果としては、プロジェクト内での実際の採用には至りませんでしたが、開発過程で得た知見や経験は、将来のプロジェクトにおいて有益な資産となることでしょう。(涙目)
本記事が将来の開発者や、同様の課題に直面している方々の一助となれば幸いです。
GitHub -> https://github.com/yordgenome03/MultilingualTestSample/tree/master
最後までお読みいただき、誠にありがとうございました。
Discussion