🪬

iOSアプリ開発の多言語対応のテスト方法のtips

2023/12/26に公開

能書き

iOSアプリ開発における多言語サポートでは、「Localizable.strings」ファイルを用いてアプリ内で言語特有のコンテンツを実装します。開発者は一般的に、NSLocalizedString("Hello, world", comment: "")のような構文を使用して、これらのローカライズされた文字列をアプリに統合します。

しかし、これらのファイルの長期的な管理と維持は、時折困ったことを引き起こすことがあります。例えば、「Localizable.strings」ファイル内のキー値が誤って変更されたり、タイプミスによりこれらの文字列が正しく適用されないことがあります。これらの問題がレビュープロセスで見落とされた場合、アプリのリリース版に表示エラーが紛れ込む可能性があります。

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」を採用したため、私の開発したシステムは不要となり、日の目を見ることなくお蔵入りとなりました。(普通、最初からSwiftGen入れますよね・・・)

この記事は、実用化されなかったそのコードへの追悼としてだけでなく、同様の課題に取り組む方々の一助になることを期待します。

課題1 - 文字列形式のkey

課題点

  • Localizable.stringsからのkey呼び出しについて、keyが文字列形式であるため、コード補完の恩恵を受けられず、手動入力に依存することから、typoが発生しやすい。

解決1 - Localizable.stringskey管理を効率化するMyStrings構造体の導入

  • MyStrings 構造体を定義
    • Localizable.stringsファイル内のkeyを管理するために、MyStringsという構造体を定義し、この構造体内にカテゴリーごとに分類されたenumを配置することで、keyの整理と参照を容易に行えるように設計しました。

このアプローチにより、keyの呼び出し時にコード補完を活用できるようになり、タイポのリスクを軽減できます。

Localizable.strings
"hello" = "こんにちは"
"good_morning" = "おはようございます"
"good_night" = "おやすみなさい"

"decision" = "決定"
"back" = "戻る"
"cancel" = "キャンセル"
MyStrings.swift
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構造体を導入した結果、タイポのリスクは軽減されたが、コードが冗長になるという新たな課題が生じている。

解決2 - MyStringsProtocolの定義とenum拡張

  1. MyStringsProtocolの定義
    • MyStringsProtocolなどの名前でprotocolを定義し、ローカライズされた文字列を返すための変数localizedStringを実装します。
  2. MyStringsProtocolの拡張
    • MyStringsProtocolに準拠したenumrawValueを使用してlocalizedStringを生成したいので、RawRepresentableSelf.RawValue == Stringwhere節を追加することで、enumrawValueにアクセスした具体的な実装を提供します。
  3. enumの拡張
    • enumがこのMyStringsProtocolに準拠することで、コードの重複なくローカライズされた文字列を返す変数localizedStringをプロパティに持つことができます。

enumに共通のインターフェースを提供するprotocolを導入することで、コードの重複を避けつつ、冗長性を排除して可読性を高めることができます。

MyStrings.swift
+ 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があったケースを検出する手段が提供されていません。

課題3 - MyStrings構造体の品質担保

課題点の再再確認

  • MyStrings内でタイポが発生した場合の検出と修正の仕組みがない。

解決3 - 包括的なテストコードの実装

  1. CaseIterableプロトコルの適用
    • MyStringsの各enumCaseIterableプロトコルを適用し、allCasesプロパティを使用して全てのケースを網羅的にテストできるようにします。
  2. 網羅的なテストの実施
    • allCasesを用いてforループを実行し、各ケースに対してローカライズされた文字列が正しく返されるかどうかをAssert文で確認します。
  3. テストによる追加漏れの防止
    • switch文を使用することで、MyStringsenumに新たなケースが追加された場合、それを呼び出すテストコードにもcase文の追加が要求されるため、テストの追加漏れを自動的に防ぐことができます。(defaultケースの実装はNG)

このテストコードの実装により、Localizable.stringsの扱いにおける信頼性と効率性が大幅に向上することが期待されます。

MyStrings.swift
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"
    }
}
MyStringsTests.swift
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.rawValue.localized()!, "こんにちは")
            case .goodMorning:
                XCTAssertEqual(greeting.rawValue.localized()!, "おはようございます")
            case .goodNight:
                XCTAssertEqual(greeting.rawValue.localized()!, "おやすみなさい")
            }
        }
        // MARK: - Command(操作)
        MyStrings.Command.allCases.forEach { command in
            switch command {
            case .decision:
                XCTAssertEqual(command.rawValue.localized()!, "決定")
            case .back:
                XCTAssertEqual(command.rawValue.localized()!, "戻る")
            case .cancel:
                XCTAssertEqual(command.rawValue.localized()!, "キャンセル")
            }
        }
    }
}

これでkeyvalueが一致して、期待する文言が出力されることを保証できました。

アプリに文言を一つ追加するたびに、Localizable.stringsMyStringstestMyStrings() の追記修正を強要する、非常に開発者フレンドリーな実装が完成しました。(震え声)

ただ、現行のテスト環境では日本語のみのローカライズされた文字列が検証されており、多言語環境下での検証が不足しています。

課題4 - 異なる言語設定での検証

問題の再再々確認

  • 現在のテスト実装では、日本語のローカライズされた文字列のみが検証されており、異なる言語設定での正確な動作を保証できない。
  • UserDefaults.standard.setValue(["en"], forKey: "AppleLanguages")で言語設定の書き換えが可能だが、アプリの再起動が必要のため、テスト実行時に適用できない。

解決4 - 動的な言語設定の切り替え

  1. Bundleクラスの拡張
    • テスト時に特定の言語リソースをロードするための拡張をBundleに追加します。
  2. メソッドスウィズリングの適用
    • テスト時のみBundleの関連メソッドをカスタム実装に置き換え、特定の言語設定を強制します。
  3. 言語ごとのテストケースの作成
    • さまざまな言語環境でのローカライズされた文字列を検証するテストケースを作成します。

このアプローチにより、異なる言語設定での文字列のローカライズが正しく行われているかを包括的に検証することができます。これにより、多言語対応アプリケーションの品質と信頼性がさらに向上することが期待されます。

MyStringsTests.swift
// テスト環境で言語設定を変更するための拡張
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の使用方法と、それに伴う問題点の解決策を実践的に探求しました。結果としては、プロジェクト内での実際の採用には至りませんでしたが、開発過程で得た知見や経験は、将来のプロジェクトにおいて有益な資産となることでしょう。(涙目)

本記事が将来の開発者や、同様の課題に直面している方々の一助となれば幸いです。

最後までお読みいただき、誠にありがとうございました。

Discussion