Python Localization Testing: Babel, gettext, and pytest-i18n
Python's localization stack — gettext, Babel, and framework-specific layers like Flask-Babel or Django's i18n — has plenty of failure modes: missing translations, broken .mo files, incorrect plural forms, and format differences across locales. This guide covers how to test each layer systematically with pytest.
Testing Message Extraction
Before translations can exist, messages must be extracted from source. Babel's extraction should be tested as part of CI to catch unmarked strings:
# tests/test_extraction.py
import subprocess
import sys
from pathlib import Path
def test_no_new_unmarked_strings():
"""Fail if pybabel extract produces strings not already in messages.pot."""
result = subprocess.run(
['pybabel', 'extract', '-F', 'babel.cfg', '-o', '/tmp/messages-new.pot', '.'],
capture_output=True, text=True
)
assert result.returncode == 0, f"Extraction failed: {result.stderr}"
existing = Path('translations/messages.pot').read_text()
new = Path('/tmp/messages-new.pot').read_text()
existing_msgids = set(
line[8:-1] for line in existing.splitlines() if line.startswith('msgid "')
)
new_msgids = set(
line[8:-1] for line in new.splitlines() if line.startswith('msgid "')
)
unadded = new_msgids - existing_msgids - {''}
assert not unadded, f"New strings not in .pot: {unadded}"Run this in CI to ensure every new string gets added to the pot file before merge.
Validating .po Files
A compiled .mo file from a broken .po file fails silently or raises at runtime. Validate .po files in CI:
# tests/test_po_files.py
import polib
from pathlib import Path
import pytest
PO_FILES = list(Path('translations').glob('*/LC_MESSAGES/messages.po'))
@pytest.mark.parametrize("po_path", PO_FILES, ids=lambda p: str(p))
def test_po_file_is_valid(po_path):
po = polib.pofile(str(po_path))
# Check percent translated
assert po.percent_translated() >= 80, (
f"{po_path}: only {po.percent_translated():.0f}% translated"
)
@pytest.mark.parametrize("po_path", PO_FILES, ids=lambda p: str(p))
def test_no_fuzzy_translations_in_production(po_path):
po = polib.pofile(str(po_path))
fuzzy = [e.msgid for e in po.fuzzy_entries()]
assert not fuzzy, f"{po_path}: fuzzy entries found: {fuzzy[:5]}"
@pytest.mark.parametrize("po_path", PO_FILES, ids=lambda p: str(p))
def test_plural_forms_header_present(po_path):
po = polib.pofile(str(po_path))
assert 'Plural-Forms' in po.metadata, f"{po_path}: missing Plural-Forms header"Install polib via pip: pip install polib.
pytest Fixtures for Locale Context
The most common need is switching the active locale in each test. For Flask-Babel:
# tests/conftest.py
import pytest
from myapp import create_app
@pytest.fixture
def app():
return create_app({'TESTING': True, 'BABEL_DEFAULT_LOCALE': 'en'})
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def locale(app):
"""Context manager fixture for switching locale."""
from flask_babel import force_locale
def _locale(lang):
return force_locale(lang)
return _localeUsage in tests:
from flask_babel import gettext as _
def test_error_message_english(app, locale):
with app.app_context():
with locale('en'):
assert _('Page not found') == 'Page not found'
def test_error_message_french(app, locale):
with app.app_context():
with locale('fr'):
assert _('Page not found') == 'Page introuvable'For Django:
# tests/conftest.py
import pytest
from django.utils import translation
@pytest.fixture
def use_locale():
def _activate(lang):
translation.activate(lang)
yield
translation.deactivate()
return _activateTesting Pluralization
Python's ngettext and Babel's plural forms need explicit tests for each plural category:
from flask_babel import ngettext, force_locale
def test_english_plurals(app):
with app.app_context():
with force_locale('en'):
assert ngettext('%(num)d item', '%(num)d items', 1, num=1) == '1 item'
assert ngettext('%(num)d item', '%(num)d items', 0, num=0) == '0 items'
assert ngettext('%(num)d item', '%(num)d items', 5, num=5) == '5 items'
def test_russian_plurals(app):
"""Russian has 3 plural forms: 1, 2-4, 5+."""
with app.app_context():
with force_locale('ru'):
assert ngettext('%(num)d файл', '%(num)d файла', 1, num=1) == '1 файл'
assert ngettext('%(num)d файл', '%(num)d файла', 3, num=3) == '3 файла'
assert ngettext('%(num)d файл', '%(num)d файла', 5, num=5) == '5 файлов'The third Russian plural form (файлов) requires a msgstr[2] entry in the .po file — this test will catch missing plural forms.
Testing Babel Format Functions
Date, number, and currency formatting must be tested per locale, not just translation strings:
from babel.dates import format_date, format_datetime
from babel.numbers import format_currency, format_number
from datetime import date, datetime
def test_date_format_by_locale():
d = date(2024, 3, 15)
assert format_date(d, locale='en_US') == 'Mar 15, 2024'
assert format_date(d, locale='de_DE') == '15.03.2024'
assert format_date(d, locale='fr_FR') == '15 mars 2024'
def test_currency_format_by_locale():
assert format_currency(1234.56, 'USD', locale='en_US') == '$1,234.56'
assert format_currency(1234.56, 'EUR', locale='de_DE') == '1.234,56\xa0€'
assert format_currency(1234.56, 'JPY', locale='ja_JP') == '¥1,235'
def test_number_format_by_locale():
assert format_number(1234567.89, locale='en_US') == '1,234,567.89'
assert format_number(1234567.89, locale='fr_FR') == '1\u202f234\u202f567,89'These tests are pure Python — no HTTP, no database, no mocking needed. Run them fast in CI.
Testing Flask Routes with Locale Headers
Test that your Flask app correctly handles Accept-Language and ?lang= parameters:
def test_locale_from_accept_language_header(client):
response = client.get('/', headers={'Accept-Language': 'fr-FR,fr;q=0.9'})
assert response.status_code == 200
assert b'Bienvenue' in response.data
def test_locale_from_query_param(client):
response = client.get('/?lang=de')
assert b'Willkommen' in response.data
def test_locale_cookie_persists(client):
client.get('/?lang=fr')
response = client.get('/dashboard')
assert b'Tableau de bord' in response.dataValidating .mo Compilation in CI
Add a step to ensure all .po files compile without errors:
# .github/workflows/i18n.yml
- name: Compile translations
run: pybabel compile -d translations -f
# -f = force recompile even if .mo is up to dateA .po file with syntax errors fails pybabel compile with a non-zero exit code, which fails CI before the app reaches production.
Checklist
.potfile updated before merge (extraction test)- All
.pofiles ≥80% translated (polib check) - No fuzzy entries in production locales
- Plural forms tested for each active locale's plural categories
- Date, number, currency formatting tested for each locale
- Flask/Django routes respond correctly to locale negotiation headers
.mofiles compile without errors in CI