はじめに
英語版だけで動作確認を済ませて多言語化に進んだら、DBから返ってきた日本語の顧客名が ??????、ドイツ語のボタンはレイアウトを突き破る——i18n対応で一度は通る道です。
国際化(i18n) のバグは、コード層・ロケール層・翻訳層の3つに発生源が分かれていて、それぞれに合った検証手段が要ります。
3層の地図と、各層に効く手立てを順にたどっていきます。
i18n は「internationalization」の略で、「I」と「n」の間に 18 文字あることから i18n と呼ばれます。コードや UI を特定の言語・地域に依存しない形で作る活動を指します。
TL;DR
- i18n の品質問題は機能テストの延長では捕まらない。バグの発生源は コード層/ロケール層/翻訳層 の3つに分かれる
- 各層には専用の検証手段がある:コードレビューチェックリスト・ロケール切替テスト・疑似ロケール
- 「翻訳が来てから検査する」では遅い。シフトレフトで進めることで現実的なコストに収まる
1. 英語版が通っても、日本語版でバグが噴き出す
機能テストは全部通っている。
コードレビューも問題なし、ステージング環境でも英語版は問題なく動く。
それでも他言語版を試した瞬間にバグが出る、という場面をよく聞きます。
自分も「翻訳ファイルを差し替えれば終わり」くらいの感覚から始めたので、最初は「機能は動いているのに何が問題なのか」が掴めませんでした。
ここでは予約管理 SaaS を例に考えます。
英語版でしか動作確認をしないまま、日本語・ドイツ語・アラビア語版を出そうとした場面で噴き出す典型バグを並べると、こうなります。
| # | 症状 | 例 |
|---|---|---|
| 1 | 文字化け | 日本語の顧客名を保存したら、DB から取り出した時に ?????? になる |
| 2 | 桁区切りミス |
$1,234.56 をそのまま見せたら、ドイツ語ユーザに違和感(ドイツでは 1.234,56 で , と . の役割が逆) |
| 3 | 日付順序違い |
05/20/2026 をイギリスのユーザに見せると「20月5日?」と混乱(実は5月20日) |
| 4 | 通貨記号違い |
$ がコード中にハードコードされていて、日本円表示にしたいのに $1234 のまま出る |
| 5 | 文字数膨張 | 英語の Confirm ボタンをドイツ語に翻訳した Bestätigen がボタンからはみ出す |
| 6 | RTL 未対応 | アラビア語などの 右から左へ読む言語(RTL) で、ナビゲーションが左のまま、テキストも左揃え |
共通するのは、機能としては正しく動いているのに、ロケール・言語の文脈で使い物にならない点です。
では、これらのバグはどこで生まれているのでしょうか。
2. i18n バグは「層が違う」
先ほど並べたバグをよく観察すると、原因が同じ場所にないことに気付きます。
文字化け(バグ1)はコードがバイト列の前提で文字を扱っていることが原因です。
桁区切り(バグ2)は地域ごとのフォーマット規則を実装が無視していることが原因です。
文字数膨張(バグ5)に至っては、翻訳された後にしか発生しません。
つまり、検出できるタイミングと検出に必要な手段が、バグごとに違います。i18n のバグは、発生する 層 で整理すると見通しが立ちます。
| 層 | 何が原因か | 代表的バグ | 専用の検証 |
|---|---|---|---|
| コード層 | 言語・地域を前提にしたコード | ハードコード書式・固定幅 UI・バイト演算 | コードレビューチェックリスト |
| ロケール層 | 地域ごとのフォーマット規則 | 日付順序・通貨記号・桁区切り・タイムゾーン | ロケール切替テスト |
| 翻訳層 | 翻訳テキストの内容と長さ・読み方向 | 文字数膨張・UI 崩れ・RTL 未対応 | 疑似ロケール |
国際化(i18n)は コード側を言語非依存にする活動 を指します。
一方、地域化(L10n)は 翻訳・通貨表示・日付フォーマット等を特定の言語や地域に合わせる活動 を指します。
3層モデルで言えば、コード層とロケール層が i18n の責任、翻訳層が L10n の責任です。
L10n は「Localization」の略で、「L」と「n」の間に 10 文字あることから L10n と呼ばれます。
3. コード層:言語を前提にしないコードを書く
コード層のバグは、後から修正すると影響範囲が広がります。
書式・通貨記号・文字列処理がコード全体に散らばっていれば、多言語対応のたびに大量の箇所を直すことになります。
早い段階で潰すのが最も安いタイミングです。
3-1. コード層で気をつける3類型
コードが「英語のつもり」で書かれているパターンは、大きく3つに分けられます。
| 類型 | 症状 | 例 | 回避策 |
|---|---|---|---|
| ハードコード書式 | 通貨記号・日付フォーマット・桁区切りをコード中に直接書く | f"金額: ¥{amount:,.0f}" |
i18n ライブラリの書式関数に任せる |
| 固定幅 UI | ボタン・入力欄の幅が決め打ちで翻訳テキストが収まらない |
width: 80px で固定 |
可変幅・最小幅指定にする |
| バイト演算前提 | 「先頭3バイトを取る」など 1 バイト = 1 文字の前提 |
s[:3] でバイト列をスライス |
文字列として扱い、UTF-8 で全レイヤを貫く |
文字化けの大半は「どこかの層だけ別のエンコーディングになっていた」が原因です。
コード・ファイル・DB・通信のすべての層でエンコーディングを揃えれば、バイト数で文字を数える落とし穴の多くは避けられます。
現代の標準は UTF-8(世界中の文字を扱える Unicode の符号化方式)です。
3-2. ビフォー・アフターで見る言語非依存のコード
予約管理 SaaS のリマインダーメール生成を例に、ハードコードされたコードと、ライブラリに任せたコードを並べてみます。
# ビフォー:通貨記号・桁区切り・文言がコードに張り付いている
def build_reminder(amount: int, date: datetime) -> str:
return f"ご予約の確認: {date:%Y年%m月%d日 %H時%M分} 金額 ¥{amount:,}"
# アフター:書式とメッセージをライブラリ・リソースに外出しする
from babel.dates import format_datetime
from babel.numbers import format_currency
from gettext import gettext as _
def build_reminder(amount: int, date: datetime, locale: str, currency: str) -> str:
date_str = format_datetime(date, locale=locale)
money_str = format_currency(amount, currency, locale=locale)
# 翻訳カタログからロケールに応じた文言を引く
return _("Your reservation: {date}, amount {money}").format(
date=date_str, money=money_str
)
改善のポイントは3つです。
- 書式は
babelの関数に任せる - 文字列は
gettextで外出ししてリソース化する - ロケールと通貨はコード中に書かず引数として受け取る
これだけで、同じコードが日本語ユーザにもドイツ語ユーザにも対応できるようになります。
こうした活動は i18n enablement(国際化対応) と呼ばれます。
ハードコードされた通貨記号や日付書式を検出する lint ルールを CI に組み込めば、コード層のバグはコードレビューに頼らず自動検出できます。
4. ロケール層:地域ルールに振る舞いを合わせる
コードを言語非依存に書いたとしても、ロケールを切り替えて確かめる手順がなければ、地域ごとのフォーマットが実際に正しく出るかは分かりません。
ロケール層は、機能テストの「期待値1つ」では捕まらない領域です。
同じ機能でもロケールごとに期待値が変わります。
ここで ロケール(locale) という用語を整理しておきます。
ロケールは言語と地域の組み合わせのことで、en-US や en-GB のように表現します。
同じ英語でも US と UK で通貨記号や日付順序が違うため、言語だけでは表示ルールが決まりません。
4-1. 同じデータでもロケールで見え方が変わる
「2026年5月20日 14時30分、1,234,567.89 USD」というまったく同じデータを、ロケールごとに表示すると、こうなります。
| ロケール | 日付・時刻 | 通貨 |
|---|---|---|
| en-US | 05/20/2026 2:30 PM | $1,234,567.89 |
| en-GB | 20/05/2026 14:30 | $1,234,567.89 |
| ja-JP | 2026/05/20 14:30 | US$1,234,567.89 |
| de-DE | 20.05.2026 14:30 | 1.234.567,89 $ |
| hi-IN | 20/5/2026 2:30 अपराह्न | US$ 12,34,567.89 |
特にインドの桁区切りは「右から3桁、その先は2桁ずつ」になります(12,34,567.89 のように区切る)。
US の3桁区切りに慣れた感覚だと気付きにくく、テスト観点として漏れやすい違いです。
4-2. ロケール切替テストでロケール層を検証する
ロケール層を確かめる手段は、ロケールを切り替えてアプリの振る舞いを観察すること です。
OS の言語設定、環境変数(LANG=de_DE.UTF-8 など)、アプリのロケール設定など、切り替え方は環境によって違いますが、考え方は共通です。
テストコードからもロケールを切り替えて期待値を検証できます。
from babel.dates import format_datetime
from babel.numbers import format_currency
from datetime import datetime
def render_reservation(when: datetime, amount: int, locale: str) -> str:
return (
f"{format_datetime(when, locale=locale)} / "
f"{format_currency(amount, 'USD', locale=locale)}"
)
# ロケールごとの期待値を切り替えてテストする
def test_render_reservation_de_de():
when = datetime(2026, 5, 20, 14, 30)
out = render_reservation(when, 1234, locale="de_DE")
# ドイツ語ロケールでは小数点が `,`・桁区切りが `.`
assert "1.234,00" in out
ロケールごとに期待値が変わるテストを CI に組み込めば、「en-US だけ通って de-DE で崩れる」が早期に検出できます。
4-3. ロケール層で確かめる項目
実際にロケール切替テストで確かめるべき項目は、大きく4つです。
- 日付・時刻:年月日順序、24時間表記/12時間表記、月名・曜日名のローカライズ
- 通貨:通貨記号の位置(前か後か)、小数点記号、桁区切り
- 桁区切りの位置:US は3桁ごと、インドは下3桁+上2桁ごと
- タイムゾーンとサマータイム:UTC からのオフセット、夏季の時刻ずらしの有無
タイムゾーンとサマータイムは深掘りすると別記事に値する量になるため、ここでは「ロケール層の検証項目に含まれる」とだけ押さえます。
この層の検証を「ロケールテスト」「i18n バリデーション」「言語テスト」など複数のフェーズ名に分けて整理する流儀もあります。
本記事では「ロケール(地域ルール)が振る舞いを切り替える層」を1つの軸として扱い、フェーズ名の使い分けには深入りしません。
5. 翻訳層:疑似ロケールで翻訳前に潰す
コード層・ロケール層を整えても、実際の翻訳テキストが流し込まれて初めて見えるバグが残ります。
翻訳が手元に来てから検査するのでは遅すぎるため、翻訳者を待たずに先回りで潰す手段が必要です。
5-1. 翻訳が入ってから現れるバグ
翻訳層で典型的に起きるバグを、予約管理 SaaS の例で並べます。
- 英語の
Confirmボタン(7文字)を、ドイツ語訳Bestätigen(10文字)に置き換えるとボタンからはみ出す - 翻訳テーブルに登録し忘れた文字列が、ドイツ語版・日本語版でも英語のまま画面に出る
- アラビア語に切り替えてもナビゲーションバーが左のまま、本文も左揃えのまま(RTL レイアウト未対応)
共通するのは、翻訳テキストを実際に流し込まないと目視でしか見つからない点です。
翻訳が納品されてから人力で全画面を確認するのは、コストもかかりますし、リリース直前の修正には時間が足りません。
5-2. 疑似ロケール:翻訳者を待たずに翻訳層のバグを検出する
ここで使えるのが 疑似ロケール(pseudo-locale) です。
原文を機械的に変換して「翻訳されたっぽい文字列」を生成するテスト用ロケールで、変換ルールはおおむね次の組み合わせです。
原文: Reservation
疑似: [!!! Ŕéšéŕṽâţîöń !!!]
このたった一行に、3種類のバグ検出が同時に組み込まれています。
- アクセント付き文字(
Ŕéšéŕṽâţîöń)への置換:翻訳テーブルから漏れた英語文字列があれば、アクセントがついていない部分として一目で分かる - 前後の境界マーカー(
[!!! !!!]):UI からはみ出していたらマーカーが切れて見える → 文字数膨張バグの検出 - 文字数を 30〜50% 膨張させる:ドイツ語の膨張率に近い状況を再現する
規格と各環境のサポート状況
Unicode コンソーシアムの CLDR(ロケールデータの共通基盤)には、疑似ロケール用の地域コードが定義されています。
-
XA:Pseudo-Accents(アクセント文字+文字数膨張) -
XB:Pseudo-Bidi(右から左の表示方向)
これを en-XA/en-XB の形で Android が標準サポートしています(API レベル 18 以降)。
Web 側でも i18n フレームワーク(react-i18next や FormatJS など)に pseudo-localization 機能があります。
命名規約はフレームワークごとに違うものの、考え方は共通です。
en-XB のような RTL 用の疑似ロケールを使えば、翻訳者を待たずにレイアウト反転の対応漏れも検出できます。
5-3. 翻訳ワークフローのどこに疑似ロケールを置くか
疑似ロケールは、翻訳ワークフローの早い段階に組み込むのが効果的です。
ポイントは2つです。
- 翻訳者に依頼する前に疑似ロケールで一度回す:文字数膨張やハードコード漏れの大半をここで潰せれば、翻訳後の手戻りが激減する
- 翻訳者には文字列だけでなく使用文脈も渡す:同じ単語でも文脈で訳が変わるため、文字列単体を渡すと誤訳が増える
5-4. 疑似ロケールが検出するバグの種類
疑似ロケールが検出できるバグを整理すると、こうなります。
| バグの種類 | 検出される理由 |
|---|---|
| 文字数膨張による UI 崩れ | 30〜50% 膨張させた文字列で境界マーカーが切れる/はみ出す |
| ハードコード文字列 | アクセント文字に置換されていない部分が画面に出る |
| 文字エンコーディング欠陥 | アクセント文字が ?? や豆腐文字(□)になる → UTF-8 が貫けていない |
| RTL 対応漏れ | RTL 疑似ロケールでレイアウトが反転しない |
翻訳テキストが届いてから検査する場合と比べて、検出が 数週間〜数ヶ月前倒し になるのが疑似ロケールの強みです。
翻訳コスト自体は AI でかなり下がりましたが、生成された翻訳を受け入れる UI 側の頑健さは、依然として人間が設計した検証フローで担保するしかありません。
6. シフトレフトで現実的なコストに収める
3層を貫いているのは、i18n のバグは発生する 層 によって、検出できる タイミング と 手段 が違うという考え方です。
各層を適切なタイミングに配置することで、全体のコストを下げられます。
「翻訳が来てから全部検査する」アプローチが破綻するのは、3つの理由からです。
1つ目に、翻訳コストが高いため修正のたびに翻訳し直しが必要になります。
2つ目に、リリース直前にバグが集中して時間が足りません。
3つ目に、コード層の修正は影響範囲が広く、リリース直前にはもう触れません。
開発工程の右側(テスト・リリース)にあった活動を左側(要件・設計・実装)に前倒しする考え方を シフトレフト と呼びます。
i18n は後付けでは直せないタイプの品質特性です。
シフトレフトで3層を段階的に潰すのが、現実的なコストで多言語品質を作るほぼ唯一の道です。
おわりに
i18n の品質は機能テストの延長では作れません。
コード・ロケール・翻訳の3層を分け、それぞれに専用の検証を当て、シフトレフトで早期に潰す。
この視点に立つと、AI 時代に翻訳生成自体は速くなっても、それを受け入れて検証する側のフローは依然として人間が設計する余地が残ることが見えてきます。
自分も最初は「翻訳ファイルを差し替えれば終わり」と思っていましたが、3層に分けて考え始めてからは、何を・いつ・どの手段で確かめるかが整理しやすくなりました。