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)memanggilIntl.NumberFormatdan menulis "Rp 700.000" di layar. Testexpect(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.
- Test tanggal tanpa
timeZonelolos di laptop (TZ=Asia/Jakarta) dan gagal di CI (UTC): jam 7:00, bukan 14:00. Selalu kirimtimeZoneke pemformat, dan tulis locale lengkap ('ja-JP', bukan bahasa browser). - Intl juga memakai U+202F (spasi tipis tak terputus) di beberapa format waktu Inggris dan U+2009 di rentang tanggal. Kalau ragu, bandingkan dengan
formatToParts():parts.find((p) => p.type === 'hour').value === '14'.
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}} ~~~~~~]'
- Teks hardcoded langsung ketahuan. Developer menulis "Sudah termasuk sarapan" langsung di JSX, bukan
t('breakfast'). Di layar pseudo, semua teks beraksen dan berkurung, kecuali baris itu: polos tanpa kurung. Gak perlu menunggu penerjemah untuk menemukannya. - Kurung siku menandai awal dan akhir. Kalau
]gak kelihatan, teksnya terpotong. - Huruf beraksen menguji font dan encoding: kalau ada kotak kosong, ada masalah.
- Panjang ekstra meniru terjemahan yang lebih panjang. Tabel IBM yang dikutip W3C ("Text size in translation"): teks sampai 10 karakter bisa jadi 200 sampai 300% panjang aslinya, teks di atas 70 karakter sekitar 130%. Lab ini memakai +40%; tombol pendek butuh ruang lebih.
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);
});
- Dengan i18next 26.4.3,
t('booking.cta')(typo) melemparError: Key hilang: booking:booking.cta, jadi test-nya gagal, bukan halaman yang menampilkan "booking.cta". missingKeyHandlercuma terpanggil kalau key gak ada di semua bahasa rantai. Keycancelyang hilang dija-JPdiam-diam jatuh keen-US.returnDetails: truememberiusedLng; test di atas menuntutusedLngsama dengan bahasa yang diuji.
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
- Spasi biasa di expected. Intl memakai U+00A0 dan U+202F. Tulis
\u00a0atau bandingkan bagian denganformatToParts. - Zona waktu dari mesin. Lolos di laptop WIB, gagal di runner UTC. Patok
timeZone. - Snapshot dari Node dianggap hasil browser. Node menulis "Rp 700.000", Chrome "Rp 700.000,00", Safari "Rp700.000,00".
- Cuma menguji bahasa Indonesia. Teks hardcoded, bentuk jamak
few, dan tombol yang meluber baru ketahuan di bahasa lain. Jalankan pseudo-locale dan semua kategori jamak di CI.
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.