Python Localization Testing: Babel, gettext, and pytest-i18n

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 _locale

Usage 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 _activate

Testing 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.data

Validating .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 date

A .po file with syntax errors fails pybabel compile with a non-zero exit code, which fails CI before the app reaches production.

Checklist

  • .pot file updated before merge (extraction test)
  • All .po files ≥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
  • .mo files compile without errors in CI

Read more