Testing i18n — i18n

Testing i18n Testing i18n bukan hanya cek apakah terjemahan muncul. Kita harus test layout RTL, string expansion, format angka/tanggal, dan edge cases…

Testing i18n memastikan kartu pesan benar di setiap bahasa, bukan cuma di bahasa Indonesia yang dilihat developer tiap hari: harga dan tanggal diformat benar, tiap key punya terjemahan, bentuk jamak tepat, teks panjang muat, dan tampilan Arab terbalik dengan benar. Bayangin gladi resik pertunjukan dengan kostum yang sengaja dibuat 40% lebih besar. Kalau kostum itu masih lewat pintu panggung, kostum asli pasti lewat. Pseudo-lokalisasi adalah kostum besar itu, dan test otomatis adalah gladi resik yang diulang tiap pull request.

🧪 Tebakan cepat: money('id-ID', 700000) memanggil Intl.NumberFormat dan menulis "Rp 700.000" di layar. Test expect(money('id-ID', 700000)).toBe('Rp 700.000'), dengan spasi biasa yang kamu ketik, lolos atau gagal di Vitest? Jawabannya ada di bagian "Unit test format: patok locale dan zona waktu".

Unit test format: patok locale dan zona waktu

// src/format.js
export const money = (locale, n) =>
  new Intl.NumberFormat(locale, { style: 'currency', currency: 'IDR', maximumFractionDigits: 0 }).format(n);
export const checkIn = (locale, date, timeZone) =>
  new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone }).format(date);

// src/format.test.js (Vitest)
import { it, expect } from 'vitest';
import { money, checkIn } from './format.js';
const CHECK_IN = new Date('2026-12-12T07:00:00Z'); // Sabtu 14.00 WIB

it('harga id-ID (spasi biasa)', () => expect(money('id-ID', 700000)).toBe('Rp 700.000'));
it('harga id-ID (U+00A0)', () => expect(money('id-ID', 700000)).toBe('Rp\u00a0700.000'));
it('check-in ja-JP, zona dipatok', () => expect(checkIn('ja-JP', CHECK_IN, 'Asia/Jakarta')).toBe('2026/12/12 14:00'));
it('check-in ja-JP, zona dari mesin', () => expect(checkIn('ja-JP', CHECK_IN)).toBe('2026/12/12 14:00'));

Hasil asli Vitest 5.0.3 dengan TZ=UTC (zona runner GitHub Actions):

 × harga id-ID (spasi biasa)
AssertionError: expected 'Rp 700.000' to be 'Rp 700.000' // Object.is equality
Expected: "Rp 700.000"
Received: "Rp 700.000"

 × check-in ja-JP, zona dari mesin
AssertionError: expected '2026/12/12 7:00' to be '2026/12/12 14:00' // Object.is equality

Test harga gagal, padahal Expected dan Received kelihatan sama. Intl menaruh spasi tak terputus U+00A0 antara "Rp" dan angkanya, bukan spasi biasa. Itu jawaban tebakannya. Tulis 'Rp\u00a0700.000' supaya karakternya kelihatan di kode.

Snapshot dari Node bukan snapshot browser

Snapshot (toMatchSnapshot, toMatchInlineSnapshot) menyimpan hasil sekali, lalu membandingkannya di tiap run. Masalahnya, Vitest jalan di Node, sedangkan tamu memakai browser, dan data ICU-nya beda:

Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR' }).format(700000) Hasil
Node 22.22.1 (CLDR 48), tempat Vitest jalan Rp 700.000
Chrome 147, Firefox 146 Rp 700.000,00
Safari 26.4 Rp700.000,00

Snapshot "Rp 700.000" lolos selamanya di CI, padahal kartu di browser menulis ",00". Dan Safari tetap menulis "Rp700.000" tanpa spasi walau maximumFractionDigits: 0. Jadi: jangan snapshot keluaran Intl lintas mesin. Uji opsi yang kamu kirim di unit test, dan uji teks akhir di mesin yang merendernya (Playwright di Chromium, Firefox, dan WebKit).

Pseudo-lokalisasi: kostum yang dilebihkan

Pseudo-lokalisasi membuat bahasa palsu dari bahasa sumber: tiap huruf diganti huruf beraksen yang masih terbaca, teksnya dipanjangkan, dan diberi kurung siku. Konvensinya pakai tag locale en-XA, seperti di Android.

const ACCENT = { a: 'å', b: 'ƀ', c: 'ç', d: 'ð', e: 'é', f: 'ƒ', g: 'ĝ', h: 'ĥ', i: 'î', j: 'ĵ', k: 'ķ', l: 'ļ', m: 'ɱ', n: 'ñ', o: 'ö', p: 'þ', q: 'ǫ', r: 'ŕ', s: 'š', t: 'ţ', u: 'û', v: 'ṽ', w: 'ŵ', x: 'ẋ', y: 'ý', z: 'ž', A: 'Å', B: 'Ɓ', C: 'Ç', D: 'Ð', E: 'É', F: 'Ƒ', G: 'Ĝ', H: 'Ĥ', I: 'Î', J: 'Ĵ', K: 'Ķ', L: 'Ļ', M: 'Ṁ', N: 'Ñ', O: 'Ö', P: 'Þ', Q: 'Ǫ', R: 'Ŕ', S: 'Š', T: 'Ţ', U: 'Û', V: 'Ṽ', W: 'Ŵ', X: 'Ẋ', Y: 'Ý', Z: 'Ž' };

// Huruf beraksen, +40% panjang, kurung siku. Slot {{...}} gak disentuh.
function pseudo(text) {
  const parts = text.split(/(\{\{[^{}]+\}\})/);
  const body = parts.map((p, i) => (i % 2 ? p : [...p].map((c) => ACCENT[c] ?? c).join(''))).join('');
  const letters = [...parts.filter((_, i) => i % 2 === 0).join('')].length;
  return `[${body} ${'~'.repeat(Math.max(2, Math.ceil(letters * 0.4)))}]`;
}
pseudo('Pesan kamar, bayar di tempat'); // '[Þéšåñ ķåɱåŕ, ƀåýåŕ ðî ţéɱþåţ ~~~~~~~~~~~~]', 28 jadi 43 karakter
pseudo('Total {{count}} malam: {{price}}'); // '[Ţöţåļ {{count}} ɱåļåɱ: {{price}} ~~~~~~]'

Yang dicek di layar pseudo: teks yang meluber keluar kotak, teks yang terpotong (] hilang, ... di tengah kata), dan tata letak yang pecah (tombol jadi tiga baris, ikon terdorong). Tombol "Pesan kamar, bayar di tempat" yang 28 karakter jadi 43, lewat batas 32.

Setiap key tampil, gak ada key mentah

// test/i18n.js: di test, key yang hilang di semua bahasa langsung melempar error.
i18next.init({
  /* ...setup kartu... */
  saveMissing: true,
  missingKeyHandler: (lngs, ns, key) => { throw new Error(`Key hilang: ${ns}:${key}`); },
});

const KEYS = ['room', 'perNight', 'checkIn', 'group', 'nights', 'total', 'book', 'cancel', 'breakfast', 'thanks'];
it.each(['id-ID', 'en-US', 'ja-JP', 'ar-EG'])('semua key %s diterjemahkan sendiri', (locale) => {
  const t = i18next.getFixedT(locale);
  for (const key of KEYS) expect(t(key, { count: 2, returnDetails: true }).usedLng).toBe(locale);
});

Jamak Arab: uji semua kategori

it.each([[0, 'zero'], [1, 'one'], [2, 'two'], [3, 'few'], [11, 'many'], [100, 'other']])('ar-EG %i malam pakai nights_%s', (n, category) => {
  const d = i18next.getFixedT('ar-EG')('nights', { count: n, returnDetails: true });
  expect(d.exactUsedKey).toBe(`nights_${category}`);
  expect(d.usedLng).toBe('ar-EG');
});

Hasilnya dengan kamus kita: 0 "0 ليلة", 1 "ليلة واحدة", 2 "ليلتان", 3 "3 ليالٍ", 11 "11 ليلة", 100 "100 ليلة". Hapus nights_few, dan 3 malam jadi "3 nights" dari en-US: test kategori few gagal. Satu angka per kategori cukup, asal semua kategori kebagian.

RTL: screenshot per arah dengan Playwright

// tests/card-rtl.spec.mjs (Playwright)
import { test, expect } from '@playwright/test';

for (const [locale, dir, path] of [['id-ID', 'ltr', '/id/kamar-teras'], ['ar-EG', 'rtl', '/ar/غرفة-التراس']]) {
  test(`kartu ${locale}`, async ({ page }) => {
    await page.goto(path); // baseURL di playwright.config
    await expect(page.locator('html')).toHaveAttribute('dir', dir);
    const stripe = await page.locator('article').evaluate((el) => getComputedStyle(el).borderRightWidth);
    expect(stripe).toBe(dir === 'rtl' ? '4px' : '0px');   // garis hijau pindah ke kanan
    await expect(page.locator('article')).toHaveScreenshot(`kartu-${locale}.png`);
  });
}

Run pertama gagal dengan "A snapshot doesn't exist at .../kartu-ar-EG-chromium-darwin.png, writing actual.", lalu run kedua lolos. Nama file baseline memuat browser dan sistem operasi (chromium-darwin), karena font dirender beda di tiap OS. Buat baseline di OS yang sama dengan CI (Linux), misalnya di image Docker resmi Playwright. Kami menjalankannya dengan Playwright 1.59.1; versi terbaru per Oktober 2026 adalah 1.64.0.

⚠️ Jebakan yang sering ketemu

TL;DR: Unit test format mematok locale dan timeZone, dan menulis spasi Intl apa adanya ('Rp\u00a0700.000'); tanpa itu test lolos di laptop WIB dan gagal di CI UTC, atau gagal dengan Expected dan Received yang kelihatan sama. Jangan snapshot keluaran Intl lintas mesin: Node 22 menulis "Rp 700.000", Chrome "Rp 700.000,00", Safari "Rp700.000,00". Pseudo-lokalisasi (en-XA: huruf beraksen, +40% panjang, kurung siku, slot gak disentuh) membuat teks hardcoded langsung ketahuan karena tetap polos, dan memperlihatkan teks yang meluber, terpotong, atau tata letak yang pecah (tabel IBM: teks pendek bisa 2 sampai 3 kali lebih panjang). Di test, missingKeyHandler yang melempar error dan returnDetails.usedLng memastikan gak ada key mentah atau jatuh ke bahasa lain. Uji jamak ar-EG di 0, 1, 2, 3, 11, 100. Untuk RTL, Playwright mengecek dir="rtl", posisi garis, dan toHaveScreenshot per locale, dengan baseline yang dibuat di OS yang sama dengan CI.

Yang akan kamu pelajari