Di antara tiga uji Bagian 1 ada satu klaim yang berbunyi tegas: “gagal satu sumber menolak keseluruhan muatan”. Untuk kalkulator, kegagalan total seperti itu wajar — satu operasi, satu hasil. Dashboard bermain di medan yang berbeda: ia berdiri di atas tiga sumber yang hidup sendiri-sendiri, dan menolak semuanya gara-gara satu sumber rewel berarti pengguna kehilangan dua sumber yang sehat. Dashboard yang baik justru sebaliknya: tampilkan apa yang berhasil datang, dan laporkan dengan jelas sumber mana yang gagal serta alasannya.
Bagian penutup proyek ini mengerjakan dua hal. Pertama, kegagalan ditata per sumber: class ApiError yang membawa nama sumbernya, muatDashboardToleran berbasis Promise.allSettled yang tidak pernah menolak, dan terapkanLaporan yang menerjemahkan laporan menjadi keadaan siap, sebagian, atau galat. Kedua, seluruh strategi pengujian proyek ditinjau sebagai satu gambaran utuh — peta sebelas suite, pola asersi yang dipakai, dan keluaran node --test yang dibaca lengkap.
ApiError: Galat yang Membawa Nama Sumbernya #
Ketika satu sumber gagal, pertanyaan pertama kita bukan “apa pesannya” melainkan “sumber mana yang gagal”. Error biasa tidak membawa jawaban itu — pesannya hanya teks. Karena itu logika menyediakan galat khusus:
const NAMA_SUMBER = ['statistik', 'transaksi', 'aktivitas'];
export class ApiError extends Error {
constructor(sumber, pesan) {
super(pesan);
this.name = 'ApiError';
this.sumber = sumber;
}
}Mari kita bedah:
- Baris 62 (
const NAMA_SUMBER = ['statistik', 'transaksi', 'aktivitas'];): peta nama sumber dalam urutan yang sama dengan array promise dimuatDashboardToleran— indeks hasil nanti disambungkan ke nama di sini. - Baris 64 (
export class ApiError extends Error {): pewarisan class membuatApiErrorpunya semua yang dimilikiError—messagedan stack — sementarainstanceof Errortetap benar, sehingga penanganan galat lama tidak perlu berubah. Pola ini dibahas tuntas pada chapter Custom Errors di section Error Handling. - Baris 65–69 (
constructor(sumber, pesan) { super(pesan); ... }):super(pesan)memasang message di induk;this.name = 'ApiError'membuat keluaran laporan terbaca sebagai ApiError, bukan Error generik; danthis.sumbermenyimpan konteks yang tidak dimilikiErrorbiasa — siapa yang gagal.
Dengan konteks itu, kode penangan bisa berbicara spesifik: “sumber transaksi gagal” berbeda maknanya dengan “sumber statistik gagal”, meski pesan fisiknya sama.
muatDashboardToleran: Melaporkan, Bukan Menolak #
Pengganti Promise.all untuk laporan toleran memakai saudaranya, Promise.allSettled — pasangan yang dibahas bersama pada chapter Promise Combinators:
export async function muatDashboardToleran(pemuat) {
const hasil = await Promise.allSettled([
pemuat.statistik(),
pemuat.transaksi(),
pemuat.aktivitas(),
]);
const data = { statistik: null, transaksi: [], aktivitas: [] };
const gagal = [];
for (const [i, satu] of hasil.entries()) {
const sumber = NAMA_SUMBER[i];
if (satu.status === 'fulfilled') {
data[sumber] = satu.value;
} else {
const alasan =
satu.reason instanceof Error ? satu.reason.message : String(satu.reason);
gagal.push(new ApiError(sumber, alasan));
}
}
return { data, gagal };
}Mari kita bedah:
- Baris 73 (
const hasil = await Promise.allSettled([): berbeda dariPromise.all,Promise.allSettledtidak pernah ditolak. Ia menunggu ketiga anggotanya selesai apa pun nasibnya, lalu melaporkan tiap anggota sebagai{ status: 'fulfilled', value }atau{ status: 'rejected', reason }. - Baris 78 (
const data = { statistik: null, transaksi: [], aktivitas: [] };): nilai default berbentuk state — objectnulluntuk statistik, array kosong untuk dua daftar. Sumber yang gagal otomatis memakai bentuk kosong yang sah ini, sehingga isidataselalu punya wujud yang sama. - Baris 80 (
for (const [i, satu] of hasil.entries()) {):entries()menyandingkan indeks dengan hasil, dan indeks dipetakan keNAMA_SUMBER— inilah yang memberi tahu kita sumber mana yang sedang diperiksa. - Baris 82–83 (
if (satu.status === 'fulfilled') { data[sumber] = satu.value; }): sumber sehat masuk laporan apa adanya. - Baris 85–87 (
const alasan = ...; gagal.push(new ApiError(sumber, alasan));): alasan kegagalan diringkas dulu —messagebila berupaError,String()bila bukan — lalu dibungkusApiErrorbersama nama sumbernya. Bungkusan ini penting karena JavaScript memperbolehkan melempar apa pun, termasuk string. - Baris 90 (
return { data, gagal };): hasilnya laporan dua saku — isi yang terselamatkan dan daftar kegagalan — bukan data langsung, bukan juga penolakan.
Keputusan antarmuka ditentukan oleh jumlah kegagalan dalam laporan:
| Kondisi laporan | Keadaan hasil | Yang tampil bagi pengguna |
|---|---|---|
gagal.length === 3 — ketiga sumber gagal | 'galat' | pesan “Data gagal dimuat.”; tanpa kartu |
0 < gagal.length < 3 — gagal sebagian | 'sebagian' | kartu dari sumber sehat + daftar kegagalan |
gagal.length === 0 — semuanya sehat | 'siap' | seluruh kartu dan panel |
terapkanLaporan menerapkan tabel itu sebagai state:
export function terapkanLaporan(state, laporan) {
const { data, gagal } = laporan;
let keadaan = 'siap';
if (gagal.length === NAMA_SUMBER.length) {
keadaan = 'galat';
} else if (gagal.length > 0) {
keadaan = 'sebagian';
}
return { ...state, ...data, gagal, keadaan };
}Mari kita bedah:
- Baris 95–100 (
let keadaan = 'siap'; if (gagal.length === NAMA_SUMBER.length) { keadaan = 'galat'; } else if (gagal.length > 0) { keadaan = 'sebagian'; }): keadaan dihitung dari satu perbandingan — panjang daftar gagal terhadap jumlah sumber. Ketiganya gagal berarti galat penuh; sebagian gagal berarti keadaan baru'sebagian'; kosong berarti siap. - Baris 101 (
return { ...state, ...data, gagal, keadaan };): isi laporan dan daftar gagal menempel ke state lewat pintu yang sama dengan Bagian 2. Fieldgagalinilah yang kelak mengisi slot#daftar-gagalyang sudah terpasang di antarmuka sejak Bagian 3.
Demo: Satu Sumber Gagal, Dashboard Tetap Tampil #
Berkas demo berikut memanggang skenario paling sering terjadi di lapangan: satu sumber gagal, dua lainnya sehat:
// demo-galat.mjs — satu sumber gagal, dashboard tetap menampilkan sisanya.
import { buatState, muatDashboardToleran, terapkanLaporan } from './dashboard.mjs';
const pemuat = {
statistik: async () => ({ pengunjung: 3420, pesanan: 128, pendapatan: 34500000 }),
transaksi: async () => {
throw new Error('waktu tunggu habis');
},
aktivitas: async () => [{ waktu: '09:12', teks: 'Pesanan TRX-1042 dibayar' }],
};
const laporan = await muatDashboardToleran(pemuat);
const state = terapkanLaporan(buatState(), laporan);
console.log('keadaan :', state.keadaan);
console.log('statistik:', state.statistik === null ? 'kosong' : 'tersaji');
console.log('transaksi:', state.transaksi.length === 0 ? 'kosong' : `${state.transaksi.length} baris`);
console.log('aktivitas:', state.aktivitas.length, 'baris');
for (const galat of state.gagal) {
console.log('laporan :', `${galat.name} [${galat.sumber}]: ${galat.message}`);
}Jalankan dari folder bagian ini:
node demo-galat.mjsOutput yang Dihasilkan:
keadaan : sebagian
statistik: tersaji
transaksi: kosong
aktivitas: 1 baris
laporan : ApiError [transaksi]: waktu tunggu habisMari kita bedah jalannya demo:
- Baris 6–8 (
transaksi: async () => { throw new Error('waktu tunggu habis'); },): sumber transaksi sengaja melemparError— mensimulasikan server yang menjawab lambat hingga waktu tunggunya habis. - Baris 12–13 (
const laporan = await muatDashboardToleran(pemuat); const state = terapkanLaporan(buatState(), laporan);): laporan dikumpulkan, lalu diterjemahkan menjadi state lewat pintu yang sudah diuji. - **Baris 20 (
console.log('laporan :', \${galat.name} [${galat.sumber}]: ${galat.message}`);)**: format keluaran memakai tiga informasi yang dibawaApiError` — name, sumber, dan message.
Keluarannya menceritakan keputusan tabel di atas: keadaan 'sebagian' karena satu gagal — bukan nol, bukan semua; statistik: tersaji karena sumber sehat tetap masuk state; transaksi: kosong karena sumber gagal memakai bentuk kosong yang sah; dan laporan ApiError [transaksi]: waktu tunggu habis menyebut sumber dan alasannya dalam satu baris.
dashboard.mjs pada Akhir Proyek #
Berikut dashboard.mjs lengkap pada kondisi akhir proyek. Dibanding versi Bagian 3, berkas ini menambah blok kegagalan per sumber: NAMA_SUMBER, ApiError, muatDashboardToleran, dan terapkanLaporan:
// dashboard.mjs — logika inti dashboard data, terpisah dari tampilan (DOM).
// Pemuat data di-inject dari luar (dependency injection): pemuat nyata bisa
// memanggil fetch, sedangkan uji memasang pemuat tiruan yang langsung selesai.
// Seluruh fungsi murni: state masuk, state baru keluar, tanpa DOM.
export async function muatDashboard(pemuat) {
const [statistik, transaksi, aktivitas] = await Promise.all([
pemuat.statistik(),
pemuat.transaksi(),
pemuat.aktivitas(),
]);
return { statistik, transaksi, aktivitas };
}
// Satu tempat menyimpan wujud dashboard; satu-satunya pintu perubahannya
// adalah fungsi-fungsi di bawah — aliran satu arah, state lama tak tersentuh.
export function buatState() {
return { statistik: null, transaksi: [], aktivitas: [], keadaan: 'memuat' };
}
export function setState(state, stateBaru) {
return { ...stateBaru };
}
export function patchState(state, potongan) {
return { ...state, ...potongan };
}
export function terapkanData(state, data) {
return { ...state, ...data, keadaan: 'siap' };
}
// Formatter murni: menerjemahkan angka mentah menjadi teks yang siap tampil.
// Satu Intl.NumberFormat dibuat sekali di luar fungsi agar bisa dipakai ulang.
const RINGKAS = new Intl.NumberFormat('id-ID', {
notation: 'compact',
maximumFractionDigits: 1,
});
export function ringkasAngka(nilai) {
// ICU menyelipkan spasi non-breaking (U+00A0) sebelum satuan; kita rapikan
// menjadi spasi biasa agar tampilan aman di font mana pun.
return RINGKAS.format(nilai).replace(/\u00a0/g, ' ');
}
export function formatPersen(rasio, digit = 1) {
return new Intl.NumberFormat('id-ID', {
style: 'percent',
maximumFractionDigits: digit,
}).format(rasio);
}
export function buatBar(nilai, maks, lebar = 10) {
const terisi = Math.round((nilai / maks) * lebar);
return '█'.repeat(terisi) + '░'.repeat(lebar - terisi);
}
// Galat per sumber dan muat ulang yang toleran kegagalan sebagian.
const NAMA_SUMBER = ['statistik', 'transaksi', 'aktivitas'];
export class ApiError extends Error {
constructor(sumber, pesan) {
super(pesan);
this.name = 'ApiError';
this.sumber = sumber;
}
}
export async function muatDashboardToleran(pemuat) {
const hasil = await Promise.allSettled([
pemuat.statistik(),
pemuat.transaksi(),
pemuat.aktivitas(),
]);
const data = { statistik: null, transaksi: [], aktivitas: [] };
const gagal = [];
for (const [i, satu] of hasil.entries()) {
const sumber = NAMA_SUMBER[i];
if (satu.status === 'fulfilled') {
data[sumber] = satu.value;
} else {
const alasan =
satu.reason instanceof Error ? satu.reason.message : String(satu.reason);
gagal.push(new ApiError(sumber, alasan));
}
}
return { data, gagal };
}
export function terapkanLaporan(state, laporan) {
const { data, gagal } = laporan;
let keadaan = 'siap';
if (gagal.length === NAMA_SUMBER.length) {
keadaan = 'galat';
} else if (gagal.length > 0) {
keadaan = 'sebagian';
}
return { ...state, ...data, gagal, keadaan };
}Strategi Pengujian Proyek Ini #
Seluruh perilaku di atas dikunci oleh satu berkas uji: tests/dashboard.test.mjs, dua puluh empat uji dalam sebelas suite. Strateginya berdiri di atas keputusan yang sama sejak Bagian 1 — logika murni yang menerima pemuat dari luar — sehingga seluruh suite berjalan tanpa jaringan dan tanpa browser:
| Suite | Uji | Yang dikunci |
|---|---|---|
muatDashboard | 3 | isi ketiga sumber; urutan mulai–selesai paralel; fail-fast satu kegagalan |
buatState | 1 | bentuk state awal yang tetap |
setState | 2 | penggantian wujud penuh; salinan baru, bukan objek argumen |
patchState | 2 | hanya field yang diberikan; state lama tidak tersentuh |
terapkanData | 1 | muatan tersimpan dan keadaan menjadi 'siap' |
ringkasAngka | 3 | rb, tampil apa adanya, jt |
formatPersen | 2 | satu desimal; rasio bulat tanpa desimal |
buatBar | 3 | proporsi; kasus nol dan penuh; pembulatan petak |
ApiError | 1 | membawa nama sumber; mewarisi Error |
muatDashboardToleran | 3 | laporan bersih; kegagalan dilaporkan; alasan non-Error diringkas |
terapkanLaporan | 3 | keadaan 'siap', 'sebagian', 'galat' |
Tabel itu memperlihatkan tiga kebiasaan yang bekerja sepanjang proyek. Pertama, pemuat tiruan menggantikan jaringan — dan karena pemuatTiruan() mengembalikan object baru setiap dipanggil, satu uji bebas menimpa satu sumbernya tanpa mengganggu uji lain. Kedua, asersi dipilih sesuai klaimnya: deepStrictEqual untuk isi sampai ke setiap object, strictEqual dan notStrictEqual untuk membedakan nilai dengan referensi, serta assert.rejects dengan regex untuk penolakan yang dijanjikan. Ketiga, suite mengikuti nama fungsinya, sehingga keluaran runner terbaca seperti daftar isi dashboard.mjs.
Berkas ujinya secara utuh — salinan unduhannya menyertai halaman ini, berpasangan dengan dashboard.mjs versi akhir di atas:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
muatDashboard,
buatState,
setState,
patchState,
terapkanData,
ringkasAngka,
formatPersen,
buatBar,
ApiError,
muatDashboardToleran,
terapkanLaporan,
} from '../src/dashboard.mjs';
// Pemuat tiruan: menggantikan fetch agar seluruh uji berjalan tanpa jaringan.
const DATA_TIRUAN = {
statistik: { pengunjung: 3420, pesanan: 128, pendapatan: 34500000 },
transaksi: [
{ id: 'TRX-1042', pembeli: 'Budi Santoso', total: 1250000, status: 'lunas' },
{ id: 'TRX-1041', pembeli: 'Siti Rahma', total: 890000, status: 'lunas' },
],
aktivitas: [{ waktu: '09:12', teks: 'Pesanan TRX-1042 dibayar' }],
};
function pemuatTiruan() {
return {
statistik: async () => DATA_TIRUAN.statistik,
transaksi: async () => DATA_TIRUAN.transaksi,
aktivitas: async () => DATA_TIRUAN.aktivitas,
};
}
describe('muatDashboard', () => {
test('mengumpulkan data dari ketiga sumber', async () => {
const hasil = await muatDashboard(pemuatTiruan());
assert.deepStrictEqual(hasil, DATA_TIRUAN);
});
test('ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya', async () => {
const jejak = [];
const pemuatBerjejak = (nama, jeda) => async () => {
jejak.push(`mulai ${nama}`);
await new Promise((selesai) => setTimeout(selesai, jeda));
jejak.push(`selesai ${nama}`);
};
await muatDashboard({
statistik: pemuatBerjejak('statistik', 50),
transaksi: pemuatBerjejak('transaksi', 80),
aktivitas: pemuatBerjejak('aktivitas', 10),
});
assert.deepStrictEqual(jejak, [
'mulai statistik',
'mulai transaksi',
'mulai aktivitas',
'selesai aktivitas',
'selesai statistik',
'selesai transaksi',
]);
});
test('gagal satu sumber menolak keseluruhan muatan', async () => {
const pemuat = pemuatTiruan();
pemuat.transaksi = async () => {
throw new Error('server transaksi sedang diperbaiki');
};
await assert.rejects(muatDashboard(pemuat), /diperbaiki/);
});
});
describe('buatState', () => {
test('state awal belum berisi data dan masih memuat', () => {
assert.deepStrictEqual(buatState(), {
statistik: null,
transaksi: [],
aktivitas: [],
keadaan: 'memuat',
});
});
});
describe('setState', () => {
test('mengganti state keseluruhan dengan wujud baru', () => {
const lama = buatState();
const baru = setState(lama, {
statistik: null,
transaksi: [],
aktivitas: [],
keadaan: 'galat',
});
assert.strictEqual(baru.keadaan, 'galat');
assert.notStrictEqual(baru, lama);
});
test('mengembalikan salinan baru, bukan object argumen', () => {
const wujudBaru = {
statistik: null,
transaksi: [],
aktivitas: [],
keadaan: 'siap',
};
const hasil = setState(buatState(), wujudBaru);
assert.notStrictEqual(hasil, wujudBaru);
assert.deepStrictEqual(hasil, wujudBaru);
});
});
describe('patchState', () => {
test('mengubah hanya field yang diberikan', () => {
const state = buatState();
const hasil = patchState(state, { keadaan: 'siap' });
assert.strictEqual(hasil.keadaan, 'siap');
assert.strictEqual(hasil.statistik, null);
assert.strictEqual(hasil.transaksi, state.transaksi);
});
test('state lama tidak ikut berubah', () => {
const state = buatState();
patchState(state, { transaksi: [{ id: 'TRX-1040' }] });
assert.strictEqual(state.transaksi.length, 0);
assert.strictEqual(state.keadaan, 'memuat');
});
});
describe('terapkanData', () => {
test('menyimpan data muatan dan menandai keadaan siap', async () => {
const data = await muatDashboard(pemuatTiruan());
const hasil = terapkanData(buatState(), data);
assert.strictEqual(hasil.keadaan, 'siap');
assert.deepStrictEqual(hasil.statistik, DATA_TIRUAN.statistik);
assert.deepStrictEqual(hasil.transaksi, DATA_TIRUAN.transaksi);
assert.deepStrictEqual(hasil.aktivitas, DATA_TIRUAN.aktivitas);
});
});
describe('ringkasAngka', () => {
test('angka ribuan diringkas menjadi rb', () => {
assert.strictEqual(ringkasAngka(3420), '3,4 rb');
});
test('angka di bawah seribu tampil apa adanya', () => {
assert.strictEqual(ringkasAngka(128), '128');
});
test('angka jutaan diringkas menjadi jt', () => {
assert.strictEqual(ringkasAngka(34500000), '34,5 jt');
});
});
describe('formatPersen', () => {
test('rasio dikonversi menjadi persen dengan satu desimal', () => {
assert.strictEqual(formatPersen(0.0374), '3,7%');
});
test('rasio yang bulat tampil tanpa desimal', () => {
assert.strictEqual(formatPersen(0.5), '50%');
});
});
describe('buatBar', () => {
test('bar terisi sesuai perbandingan nilai terhadap maksimum', () => {
assert.strictEqual(buatBar(3420, 5000, 10), '███████░░░');
});
test('nilai nol dan nilai maksimum menghasilkan bar kosong dan penuh', () => {
assert.strictEqual(buatBar(0, 100, 10), '░░░░░░░░░░');
assert.strictEqual(buatBar(100, 100, 10), '██████████');
});
test('pecahan petak dibulatkan ke jumlah terdekat', () => {
assert.strictEqual(buatBar(128, 150, 10), '█████████░');
});
});
describe('ApiError', () => {
test('membawa nama sumber dan mewarisi Error', () => {
const galat = new ApiError('transaksi', 'waktu tunggu habis');
assert.ok(galat instanceof Error);
assert.ok(galat instanceof ApiError);
assert.strictEqual(galat.name, 'ApiError');
assert.strictEqual(galat.sumber, 'transaksi');
assert.strictEqual(galat.message, 'waktu tunggu habis');
});
});
describe('muatDashboardToleran', () => {
test('laporan tanpa kegagalan saat semua sumber sehat', async () => {
const laporan = await muatDashboardToleran(pemuatTiruan());
assert.deepStrictEqual(laporan.gagal, []);
assert.deepStrictEqual(laporan.data, DATA_TIRUAN);
});
test('sumber gagal dilaporkan tanpa menjatuhkan yang sehat', async () => {
const pemuat = pemuatTiruan();
pemuat.transaksi = async () => {
throw new Error('waktu tunggu habis');
};
const laporan = await muatDashboardToleran(pemuat);
assert.strictEqual(laporan.gagal.length, 1);
assert.strictEqual(laporan.gagal[0].sumber, 'transaksi');
assert.strictEqual(laporan.gagal[0].message, 'waktu tunggu habis');
assert.strictEqual(laporan.data.statistik, DATA_TIRUAN.statistik);
assert.deepStrictEqual(laporan.data.transaksi, []);
});
test('alasan yang bukan Error tetap diringkas menjadi ApiError', async () => {
const pemuat = pemuatTiruan();
// Melempar teks langsung bukan praktik yang baik, tetapi Promise.allSettled
// menerimanya apa adanya — kode kita harus tetap siap.
pemuat.aktivitas = async () => {
throw 'server tidak menjawab';
};
const laporan = await muatDashboardToleran(pemuat);
assert.strictEqual(laporan.gagal.length, 1);
assert.strictEqual(laporan.gagal[0].sumber, 'aktivitas');
assert.strictEqual(laporan.gagal[0].message, 'server tidak menjawab');
});
});
describe('terapkanLaporan', () => {
test('laporan bersih menghasilkan keadaan siap', async () => {
const laporan = await muatDashboardToleran(pemuatTiruan());
const state = terapkanLaporan(buatState(), laporan);
assert.strictEqual(state.keadaan, 'siap');
assert.deepStrictEqual(state.gagal, []);
assert.deepStrictEqual(state.statistik, DATA_TIRUAN.statistik);
});
test('laporan sebagian menghasilkan keadaan sebagian', async () => {
const pemuat = pemuatTiruan();
pemuat.transaksi = async () => {
throw new Error('waktu tunggu habis');
};
const laporan = await muatDashboardToleran(pemuat);
const state = terapkanLaporan(buatState(), laporan);
assert.strictEqual(state.keadaan, 'sebagian');
assert.strictEqual(state.transaksi.length, 0);
assert.strictEqual(state.statistik, DATA_TIRUAN.statistik);
assert.strictEqual(state.gagal[0].sumber, 'transaksi');
});
test('laporan gagal seluruhnya menghasilkan keadaan galat', async () => {
const pemuat = {
statistik: async () => {
throw new Error('satu');
},
transaksi: async () => {
throw new Error('dua');
},
aktivitas: async () => {
throw new Error('tiga');
},
};
const laporan = await muatDashboardToleran(pemuat);
const state = terapkanLaporan(buatState(), laporan);
assert.strictEqual(state.keadaan, 'galat');
assert.strictEqual(state.gagal.length, 3);
assert.strictEqual(state.statistik, null);
});
});Mari kita bedah baris-baris penentu:
- Baris 15 (
} from '../src/dashboard.mjs';): jalur relatif naik satu folder daritests/kesrc/— uji selalu mengimpor kode final disrc/, bukan salinan di folder bagian. - Baris 18 (
const DATA_TIRUAN = {): satu sumber kebenaran data uji;DATA_TIRUAN.statistikdipakai ulang lintas suite sebagai pembanding, sehingga perubahan bentuk data menyentuh satu tempat. - Baris 27 (
function pemuatTiruan() {): pabrik pemuat — setiap uji memanggilnya dan mendapat object pemuat sendiri, yang boleh ditimpa satu sumbernya untuk skenario gagal (misalnya Baris 65 dan Baris 196). - Baris 212 (
throw 'server tidak menjawab';): uji “alasan yang bukan Error” sengaja melempar string — bukan praktik yang baik, tetapiPromise.allSettledmenerimanya apa adanya, dan uji menuntut logika tetap merangkumnya menjadiApiErrordengan message utuh. - Baris 230–241 (
test('laporan sebagian menghasilkan keadaan sebagian', async () => { ... })): contoh Arrange-Act-Assert yang diperkenalkan section Testing — arrange berupa pemuat yang sumber transaksinya ditimpa melempar, act berupamuatDashboardTolerandisusulterapkanLaporan, assert berupa empat klaim: keadaan'sebagian', transaksi kosong, statistik utuh, dangagal[0].sumberbernilai'transaksi'. - Baris 258 (
assert.strictEqual(state.gagal.length, 3);): kasus tepi penutup — ketiga sumber gagal bersamaan harus menghasilkan keadaan'galat'dan statistik kembalinull, bukan keadaan'sebagian'dengan isi kosong.
Menjalankan Seluruh Uji #
Dari folder proyek, seluruh rangkaian dijalankan dengan satu perintah:
cd dashboard-data
node --testOutput yang Dihasilkan:
▶ muatDashboard
✔ mengumpulkan data dari ketiga sumber (1.161587ms)
✔ ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya (79.754865ms)
✔ gagal satu sumber menolak keseluruhan muatan (0.56894ms)
✔ muatDashboard (82.459635ms)
▶ buatState
✔ state awal belum berisi data dan masih memuat (0.113094ms)
✔ buatState (0.230162ms)
▶ setState
✔ mengganti state keseluruhan dengan wujud baru (0.137505ms)
✔ mengembalikan salinan baru, bukan object argumen (0.086857ms)
✔ setState (0.343636ms)
▶ patchState
✔ mengubah hanya field yang diberikan (0.137588ms)
✔ state lama tidak ikut berubah (0.088141ms)
✔ patchState (0.357649ms)
▶ terapkanData
✔ menyimpan data muatan dan menandai keadaan siap (0.24761ms)
✔ terapkanData (0.311453ms)
▶ ringkasAngka
✔ angka ribuan diringkas menjadi rb (0.371935ms)
✔ angka di bawah seribu tampil apa adanya (0.126575ms)
✔ angka jutaan diringkas menjadi jt (0.111914ms)
✔ ringkasAngka (0.735157ms)
▶ formatPersen
✔ rasio dikonversi menjadi persen dengan satu desimal (0.187498ms)
✔ rasio yang bulat tampil tanpa desimal (0.079938ms)
✔ formatPersen (0.324725ms)
▶ buatBar
✔ bar terisi sesuai perbandingan nilai terhadap maksimum (0.078106ms)
✔ nilai nol dan nilai maksimum menghasilkan bar kosong dan penuh (0.071971ms)
✔ pecahan petak dibulatkan ke jumlah terdekat (0.038408ms)
✔ buatBar (0.253139ms)
▶ ApiError
✔ membawa nama sumber dan mewarisi Error (0.187507ms)
✔ ApiError (0.247476ms)
▶ muatDashboardToleran
✔ laporan tanpa kegagalan saat semua sumber sehat (0.215298ms)
✔ sumber gagal dilaporkan tanpa menjatuhkan yang sehat (0.161219ms)
✔ alasan yang bukan Error tetap diringkas menjadi ApiError (0.081176ms)
✔ muatDashboardToleran (0.553326ms)
▶ terapkanLaporan
✔ laporan bersih menghasilkan keadaan siap (0.133506ms)
✔ laporan sebagian menghasilkan keadaan sebagian (0.141512ms)
✔ laporan gagal seluruhnya menghasilkan keadaan galat (0.14402ms)
✔ terapkanLaporan (0.486409ms)
▶ muatDashboard
✔ mengumpulkan data dari ketiga sumber (1.21692ms)
✔ ketiga pemuat dimulai bersamaan dan selesai mengikuti kecepatannya (80.388835ms)
✔ gagal satu sumber menolak keseluruhan muatan (0.584588ms)
✔ muatDashboard (83.216707ms)
▶ buatState
✔ state awal belum berisi data dan masih memuat (0.111639ms)
✔ buatState (0.230354ms)
▶ setState
✔ mengganti state keseluruhan dengan wujud baru (0.13427ms)
✔ mengembalikan salinan baru, bukan object argumen (0.088903ms)
✔ setState (0.343475ms)
▶ patchState
✔ mengubah hanya field yang diberikan (0.134926ms)
✔ state lama tidak ikut berubah (0.089642ms)
✔ patchState (0.354266ms)
▶ terapkanData
✔ menyimpan data muatan dan menandai keadaan siap (0.146585ms)
✔ terapkanData (0.203824ms)
▶ ringkasAngka
✔ angka ribuan diringkas menjadi rb (0.302813ms)
✔ angka di bawah seribu tampil apa adanya (0.136368ms)
✔ angka jutaan diringkas menjadi jt (0.120813ms)
✔ ringkasAngka (0.697626ms)
▶ formatPersen
✔ rasio dikonversi menjadi persen dengan satu desimal (0.173718ms)
✔ rasio yang bulat tampil tanpa desimal (0.079348ms)
✔ formatPersen (0.310489ms)
▶ buatBar
✔ bar terisi sesuai perbandingan nilai terhadap maksimum (0.087307ms)
✔ nilai nol dan nilai maksimum menghasilkan bar kosong dan penuh (0.0659ms)
✔ pecahan petak dibulatkan ke jumlah terdekat (0.047862ms)
✔ buatBar (0.262356ms)
▶ ApiError
✔ membawa nama sumber dan mewarisi Error (0.228526ms)
✔ ApiError (0.268096ms)
▶ muatDashboardToleran
✔ laporan tanpa kegagalan saat semua sumber sehat (0.307086ms)
✔ sumber gagal dilaporkan tanpa menjatuhkan yang sehat (0.148986ms)
✔ alasan yang bukan Error tetap diringkas menjadi ApiError (0.091873ms)
✔ muatDashboardToleran (0.662702ms)
▶ terapkanLaporan
✔ laporan bersih menghasilkan keadaan siap (0.157166ms)
✔ laporan sebagian menghasilkan keadaan sebagian (0.121641ms)
✔ laporan gagal seluruhnya menghasilkan keadaan galat (0.14235ms)
✔ terapkanLaporan (0.512012ms)
ℹ tests 48
ℹ suites 22
ℹ pass 48
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 316.662852Ringkasan itu melaporkan tests 48 dan suites 22 — dua kali dua puluh empat dan dua kali sebelas. Penyebabnya bukan uji ganda pada logika, melainkan penemuan berkas: node --test tanpa argumen menjelajahi seluruh pohon folder proyek dan menemukan berkas uji yang sama di dua tempat — tests/dashboard.test.mjs dan salinan unduhannya di folder Bagian 4 ini. Kedua salinan identik dan keduanya lulus semua; setiap suite tampil dua kali pada keluaran di atas. Bila folder Anda hanya berisi src/, styles/, dan tests/, perintah yang sama melaporkan:
ℹ tests 24
ℹ suites 11
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 170.185796Kedua ringkasan mengabarkan hal yang sama: dua puluh empat uji, sebelas suite, nol kegagalan. Angka duration_ms berbeda-beda di setiap mesin karena mengukur waktu eksekusi — uji paralel sengaja menunggu jeda 80 ms. Untuk menyasar satu berkas uji saja, tulis namanya utuh: node --test tests/dashboard.test.mjs.
Ringkasan Bagian 4 dan Penutup Proyek #
Empat bagian menghasilkan satu dashboard yang utuh: tiga sumber data dimuat paralel lewat pemuat yang disuntik; state bergerak satu arah melalui tiga pintu murni; angka mentah diterjemahkan formatter Intl dan bar teks yang teruji; dan kegagalan dilaporkan per sumber dengan ApiError, Promise.allSettled, serta keadaan 'sebagian' — dua puluh empat uji mengunci semuanya.
Satu catatan jujur menutup proyek: muatUlang di index.html.txt masih memakai muatDashboard yang berhenti pada kegagalan pertama, sehingga antarmuka masih menampilkan keadaan 'galat' penuh saat satu sumber gagal. Jalur toleran sudah teruji penuh di tingkat logika dan demo, sementara slot #daftar-gagal beserta perlakuan keadaan 'sebagian' sudah terpasang sejak Bagian 3 — memutakhirkan muatUlang untuk memakai muatDashboardToleran dan terapkanLaporan hanyalah mengganti blok try/catch dengan dua baris, dan menjadi latihan kecil yang tepat untuk menguji pemahaman kita.
Empat keputusan desain proyek ini layak dibawa ke pekerjaan mana pun berikutnya: suntikkan ketergantungan yang tidak bisa diuji; simpan state di satu tempat dan ubah lewat pintu murni; jadikan pemformatan fungsi murni yang teruji di terminal; dan laporkan kegagalan sebagian alih-alih menolak semuanya. Rangkaian kebiasaan itu kita bawa ke proyek terakhir course ini — aplikasi catatan — yang menutup rangkaian dengan refactoring terukur: merapikan kode tanpa mengubah perilaku, dibuktikan oleh uji yang tetap hijau.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/styles.css
Unduh berkas/* styles.css — tampilan dashboard data */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 24px 16px;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
color: #111827;
}
.dashboard {
max-width: 880px;
margin: 0 auto;
}
.dashboard-atas {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.dashboard-atas h1 {
margin: 0;
font-size: 1.5rem;
}
.tombol-muat {
padding: 8px 16px;
border: none;
border-radius: 8px;
background-color: #2563eb;
color: #ffffff;
font-size: 0.95rem;
cursor: pointer;
}
.tombol-muat:hover {
background-color: #1d4ed8;
}
.status {
margin: 0 0 16px;
padding: 10px 12px;
background-color: #e5e7eb;
border-radius: 8px;
}
.daftar-gagal {
margin: 0 0 16px;
padding: 10px 12px 10px 28px;
background-color: #fee2e2;
color: #991b1b;
border-radius: 8px;
}
.daftar-gagal:empty {
display: none;
}
.kartu {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.kartu-item {
padding: 14px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.kartu-item h2 {
margin: 0 0 8px;
font-size: 0.85rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.kartu-angka {
margin: 0 0 8px;
font-size: 1.6rem;
font-weight: 700;
}
.kartu-bar {
margin: 0;
font-family: monospace;
font-size: 0.9rem;
color: #2563eb;
letter-spacing: 1px;
}
.konversi {
margin: 0 0 16px;
color: #6b7280;
}
.panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.panel-item {
padding: 14px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.panel-item h2 {
margin: 0 0 10px;
font-size: 1rem;
}
.tabel-transaksi {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.tabel-transaksi th,
.tabel-transaksi td {
padding: 6px 8px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.tabel-transaksi th {
color: #6b7280;
font-weight: 600;
}
.daftar-aktivitas {
margin: 0;
padding-left: 18px;
font-size: 0.9rem;
}
.daftar-aktivitas li {
margin-bottom: 6px;
color: #374151;
}