Tiga bagian sebelumnya membangun aplikasi todo selangkah demi selangkah: menambah dan menghapus tugas, mengenali status beserta filternya, lalu menyimpan semuanya ke localStorage. Setiap langkah selalu ditutup dengan kebiasaan yang sama: jalankan node --test dari folder proyek, pastikan seluruh uji tetap hijau, baru melanjutkan. Kebiasaan itu bukan seremoni — ia yang membuat setiap perubahan berani dilakukan, karena kontrak lama yang sudah benar tidak bisa rusak diam-diam. Bagian terakhir ini tidak menambah satu pun fitur; ia berhenti sejenak dan meninjau pengujian itu sendiri sebagai satu strategi utuh: apa yang diuji, mengapa setiap hal diuji di tempatnya, dan bagaimana membaca keluarannya.
Yang akan kita kerjakan di sini: memetakan sepuluh suite uji beserta keputusan yang dikunci masing-masing, membawa berkas uji final secara utuh beserta salinan unduhannya, memperkenalkan pola Arrange-Act-Assert yang membentuk setiap uji di proyek ini, menjalankan seluruh uji dan membedah keluarannya, serta menjawab pertanyaan yang menggantung sejak Bagian 3 — kalau logika bisa diuji di terminal, di mana tempat uji antarmukanya?
Peta Uji Proyek Ini #
Strategi pengujian proyek ini berdiri di atas satu keputusan desain yang kita pegang sejak bagian pertama: logika murni terpisah dari tampilan. Konsekuensinya, tempat ujinya terbagi dua: seluruh fungsi di tugas.mjs diuji otomatis oleh Node, dan skrip antarmuka diuji mata di browser. Seluruh dua puluh empat uji tinggal dalam satu berkas tests/tugas.test.mjs dan terbagi dalam sepuluh suite — delapan mengikuti nama fungsi yang diujinya, dua sisanya berupa alur gabungan:
| Suite | Uji | Yang dikunci |
|---|---|---|
tambahTugas | 5 | id menyusul id tertinggi, trim(), guard teks kosong, immutability |
hapusTugas | 2 | id yang dicari pergi; id tak dikenal tidak mengubah isi |
daftarTugas | 1 | salinan yang setara tetapi terpisah dari state |
ubahStatus | 3 | toggle selesai; item lain mempertahankan referensinya |
filterTugas | 4 | tiga nilai sah; status asing ditolak dengan galat |
hitungAktif | 1 | jumlah tugas yang belum selesai |
skenario pengguna | 1 | alur gabungan: tambah, tandai, hitung, filter |
simpanTugas | 1 | array menjadi teks JSON |
muatTugas | 5 | null, JSON valid, JSON rusak, bukan array, entri tak sah |
siklus data | 1 | round-trip simpan–muat mengembalikan daftar yang sama |
Tabel di atas memperlihatkan tiga prinsip yang bekerja sepanjang proyek. Pertama, uji mengunci perilaku, bukan urutan baris: uji “daftar lama tidak ikut berubah” tidak peduli bagaimana tambahTugas menyusun hasilnya, selama array lama tetap utuh. Kedua, asersi dipilih sesuai klaimnya — deepStrictEqual untuk isi array sampai ke tiap object, strictEqual dan notStrictEqual untuk membedakan nilai dengan referensi, dan assert.throws untuk galat yang dijanjikan. Ketiga, dua suite alur — skenario pengguna dan siklus data — merangkai beberapa fungsi menjadi satu cerita pengguna, memastikan fungsi yang lulus sendiri-sendiri juga lulus ketika disambungkan.
Berkas Uji Utuh #
Selama tiga bagian, berkas uji ditampilkan tumbuh lewat blok berjudul — dulu “kondisi Bagian 1”, lalu “tambahan Bagian 2” dan “tambahan Bagian 3”. Berikut wujud finalnya secara utuh. Salinan tugas.test.mjs beserta salinan tugas.mjs versi akhir menyertai halaman ini, sehingga pasangan logika–uji bisa diunduh dari satu tempat:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import {
tambahTugas,
hapusTugas,
daftarTugas,
ubahStatus,
filterTugas,
hitungAktif,
simpanTugas,
muatTugas,
} from '../src/tugas.mjs';
describe('tambahTugas', () => {
test('menambahkan tugas pertama ke daftar kosong', () => {
const hasil = tambahTugas([], 'Belajar module ES');
assert.deepStrictEqual(hasil, [
{ id: 1, teks: 'Belajar module ES', selesai: false },
]);
});
test('id baru menyusul id tertinggi yang sudah ada', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = tambahTugas(daftar, 'Tiga');
assert.deepStrictEqual(daftar.map((tugas) => tugas.id), [1, 2, 3]);
});
test('teks dipangkas spasi di kedua ujungnya', () => {
const hasil = tambahTugas([], ' Belajar module ES ');
assert.strictEqual(hasil[0].teks, 'Belajar module ES');
});
test('teks yang kosong setelah dipangkas ditolak', () => {
assert.throws(() => tambahTugas([], ' '), /tidak boleh kosong/);
});
test('daftar lama tidak ikut berubah', () => {
const lama = [{ id: 1, teks: 'Satu', selesai: false }];
const hasil = tambahTugas(lama, 'Dua');
assert.notStrictEqual(hasil, lama);
assert.strictEqual(lama.length, 1);
assert.strictEqual(hasil.length, 2);
});
});
describe('hapusTugas', () => {
test('menghapus tugas dengan id yang dicari', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
const hasil = hapusTugas(daftar, 1);
assert.deepStrictEqual(hasil.map((tugas) => tugas.id), [2]);
});
test('id yang tidak ada tidak mengubah isi daftar', () => {
const daftar = tambahTugas([], 'Satu');
const hasil = hapusTugas(daftar, 99);
assert.deepStrictEqual(hasil, daftar);
assert.notStrictEqual(hasil, daftar);
});
});
describe('daftarTugas', () => {
test('mengembalikan salinan yang setara tetapi terpisah', () => {
const daftar = tambahTugas([], 'Satu');
const salinan = daftarTugas(daftar);
assert.deepStrictEqual(salinan, daftar);
assert.notStrictEqual(salinan, daftar);
salinan[0].teks = 'diubah';
assert.strictEqual(daftar[0].teks, 'Satu');
});
});
describe('ubahStatus', () => {
test('menandai tugas sebagai selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 1);
assert.strictEqual(daftar[0].selesai, true);
assert.strictEqual(daftar[1].selesai, false);
});
test('menandai kembali menjadi aktif', () => {
let daftar = tambahTugas([], 'Satu');
daftar = ubahStatus(daftar, 1);
daftar = ubahStatus(daftar, 1);
assert.strictEqual(daftar[0].selesai, false);
});
test('tugas lain mempertahankan referensinya', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
const hasil = ubahStatus(daftar, 2);
assert.strictEqual(hasil[0], daftar[0]);
assert.notStrictEqual(hasil[1], daftar[1]);
});
});
describe('filterTugas', () => {
test('filter "semua" mengembalikan seluruh tugas dalam array baru', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
const hasil = filterTugas(daftar, 'semua');
assert.strictEqual(hasil.length, 2);
assert.notStrictEqual(hasil, daftar);
});
test('filter "aktif" hanya menampilkan tugas yang belum selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 1);
assert.deepStrictEqual(filterTugas(daftar, 'aktif').map((tugas) => tugas.id), [2]);
});
test('filter "selesai" hanya menampilkan tugas yang sudah selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 1);
assert.deepStrictEqual(filterTugas(daftar, 'selesai').map((tugas) => tugas.id), [1]);
});
test('status yang tidak dikenal ditolak', () => {
const daftar = tambahTugas([], 'Satu');
assert.throws(() => filterTugas(daftar, 'tertunda'), /tidak dikenal/);
});
});
describe('hitungAktif', () => {
test('menghitung tugas yang belum selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = tambahTugas(daftar, 'Tiga');
daftar = ubahStatus(daftar, 2);
assert.strictEqual(hitungAktif(daftar), 2);
});
});
describe('skenario pengguna', () => {
test('tambah dua tugas, tandai satu selesai, sisa satu aktif', () => {
let daftar = tambahTugas([], 'Menyiram tanaman');
daftar = tambahTugas(daftar, 'Membaca materi map');
daftar = ubahStatus(daftar, 1);
assert.strictEqual(hitungAktif(daftar), 1);
assert.deepStrictEqual(
filterTugas(daftar, 'aktif').map((tugas) => tugas.teks),
['Membaca materi map'],
);
});
});
describe('simpanTugas', () => {
test('mengubah daftar menjadi teks JSON', () => {
const daftar = [{ id: 1, teks: 'Satu', selesai: false }];
assert.strictEqual(
simpanTugas(daftar),
'[{"id":1,"teks":"Satu","selesai":false}]',
);
});
});
describe('muatTugas', () => {
test('null dikembalikan sebagai daftar kosong', () => {
assert.deepStrictEqual(muatTugas(null), []);
});
test('mengurai teks JSON menjadi daftar tugas', () => {
const hasil = muatTugas('[{"id":7,"teks":"Tugas lama","selesai":true}]');
assert.deepStrictEqual(hasil, [
{ id: 7, teks: 'Tugas lama', selesai: true },
]);
});
test('JSON rusak ditolak dengan pesan yang jelas', () => {
assert.throws(() => muatTugas('{bukan json'), /bukan JSON yang valid/);
});
test('data yang bukan array ditolak', () => {
assert.throws(() => muatTugas('{"id":1}'), /bukan daftar tugas/);
});
test('entri yang tidak valid dilewati', () => {
const teks = '[{"id":1,"teks":"A","selesai":false},{"teks":"tanpa id"},42]';
assert.deepStrictEqual(muatTugas(teks), [{ id: 1, teks: 'A', selesai: false }]);
});
});
describe('siklus data', () => {
test('simpan lalu muat mengembalikan daftar yang sama', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = ubahStatus(daftar, 2);
assert.deepStrictEqual(muatTugas(simpanTugas(daftar)), daftar);
});
});Mari kita bedah baris-baris yang menentukan bentuknya:
- Baris 1 (
import { test, describe } from 'node:test';): test runner bawaan Node — tidak ada paket yang perlu dipasang untuk menjalankan berkas ini; mesin proyek ini memakai Node 24. - Baris 2 (
import assert from 'node:assert/strict';): modul asersi mode strict, sehinggadeepStrictEqualmembandingkan isi tanpa konversi tipe dan ketidakcocokan tipe tidak lolos tanpa suara. - Baris 12 (
} from '../src/tugas.mjs';): jalur relatif naik satu folder daritests/kesrc/— hubungan “uji mengimpor kode yang diujinya” yang membuatnode --testmenemukan sasarannya dari folder proyek. - Baris 14 (
describe('tambahTugas', () => {): setiap fungsi logika punya satu suite yang dinamai sesuai fungsinya, sehingga keluaran runner terbaca seperti daftar isi module. - Baris 35 (
assert.throws(() => tambahTugas([], ' '), /tidak boleh kosong/);): guard clause diuji dengan RegExp, bukan teks persis — pesan galat boleh dirapikan kelak tanpa membuat uji goyah. - Baris 138 (
describe('skenario pengguna', () => {): suite alur pertama; ujinya memanggiltambahTugas,ubahStatus,hitungAktif, danfilterTugasberurutan seperti tangan pengguna. - Baris 192 (
assert.deepStrictEqual(muatTugas(simpanTugas(daftar)), daftar);): uji round-trip penutup — state yang dibangun lewat fungsi nyata harus kembali utuh setelah diputar melalui simpan dan muat.
Pola Arrange-Act-Assert #
Section Testing memperkenalkan pola baku untuk isi setiap uji: Arrange-Act-Assert — siapkan data, jalankan satu langkah yang diuji, lalu klaim hasilnya. Uji hitungAktif di proyek ini memperlihatkannya dalam bentuk paling rapat:
describe('hitungAktif', () => {
test('menghitung tugas yang belum selesai', () => {
let daftar = tambahTugas([], 'Satu');
daftar = tambahTugas(daftar, 'Dua');
daftar = tambahTugas(daftar, 'Tiga');
daftar = ubahStatus(daftar, 2);
assert.strictEqual(hitungAktif(daftar), 2);
});
});- Arrange — empat baris pertama: state disiapkan lewat fungsi-fungsi nyata (
tambahTugas,ubahStatus), bukan dengan mengetik array mentah. Bila bentuk tugas berubah kelak — field baru, misalnya — uji ikut menangkapnya di titik yang sama. - Act —
hitungAktif(daftar): satu pemanggilan yang sedang diuji; tidak ada operasi lain yang ikut bekerja di dalam uji. - Assert —
assert.strictEqual(..., 2): klaim hasilnya, satu angka yang maknanya jelas — tiga tugas dengan satu selesai menyisakan dua aktif.
Pada uji sesempit ini, Act dan Assert menumpang satu baris: hasil pemanggilan langsung dibandingkan. Itu wajar dan tetap terbaca. Bila alurnya lebih panjang, simpan hasil Act ke variabelnya sendiri (const sisa = hitungAktif(daftar);) agar bagian klaim tetap satu baris pendek. Perhatikan juga bahwa Arrange di seluruh proyek ini nyaris tidak pernah butuh tiruan (mock): fungsi logika murni menerima array dan mengembalikan array, sehingga data uji cukup dibangun dengan memanggil fungsi itu sendiri. Kemudahan itu adalah buah langsung dari desain immutability yang dipegang sejak Bagian 1.
Menjalankan Seluruh Uji #
Dari folder proyek, seluruh rangkaian dijalankan dengan satu perintah:
cd aplikasi-todo
node --testOutput yang Dihasilkan:
▶ tambahTugas
✔ menambahkan tugas pertama ke daftar kosong (1.342588ms)
✔ id baru menyusul id tertinggi yang sudah ada (0.224595ms)
✔ teks dipangkas spasi di kedua ujungnya (0.158408ms)
✔ teks yang kosong setelah dipangkas ditolak (0.380195ms)
✔ daftar lama tidak ikut berubah (0.184028ms)
✔ tambahTugas (3.416154ms)
▶ hapusTugas
✔ menghapus tugas dengan id yang dicari (0.219284ms)
✔ id yang tidak ada tidak mengubah isi daftar (0.121265ms)
✔ hapusTugas (0.510205ms)
▶ daftarTugas
✔ mengembalikan salinan yang setara tetapi terpisah (0.284967ms)
✔ daftarTugas (0.424506ms)
▶ ubahStatus
✔ menandai tugas sebagai selesai (0.254329ms)
✔ menandai kembali menjadi aktif (0.17393ms)
✔ tugas lain mempertahankan referensinya (1.235756ms)
✔ ubahStatus (1.882514ms)
▶ filterTugas
✔ filter "semua" mengembalikan seluruh tugas dalam array baru (0.218721ms)
✔ filter "aktif" hanya menampilkan tugas yang belum selesai (0.107277ms)
✔ filter "selesai" hanya menampilkan tugas yang sudah selesai (0.07701ms)
✔ status yang tidak dikenal ditolak (0.113497ms)
✔ filterTugas (0.656001ms)
▶ hitungAktif
✔ menghitung tugas yang belum selesai (0.106268ms)
✔ hitungAktif (0.158169ms)
▶ skenario pengguna
✔ tambah dua tugas, tandai satu selesai, sisa satu aktif (0.078018ms)
✔ skenario pengguna (0.11827ms)
▶ simpanTugas
✔ mengubah daftar menjadi teks JSON (0.085846ms)
✔ simpanTugas (0.123936ms)
▶ muatTugas
✔ null dikembalikan sebagai daftar kosong (0.170443ms)
✔ mengurai teks JSON menjadi daftar tugas (0.185255ms)
✔ JSON rusak ditolak dengan pesan yang jelas (0.133059ms)
✔ data yang bukan array ditolak (0.083345ms)
✔ entri yang tidak valid dilewati (0.069796ms)
✔ muatTugas (0.760058ms)
▶ siklus data
✔ simpan lalu muat mengembalikan daftar yang sama (0.083725ms)
✔ siklus data (0.135955ms)
ℹ tests 24
ℹ suites 10
ℹ pass 24
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 81.670704Angka yang penting ada di blok ringkasan: tests 24 (jumlah uji yang dijalankan), suites 10, pass 24, dan fail 0. Setiap tanda ✔ adalah satu uji yang lulus; baris ▶ membuka suite dan baris ✔ bertanda nama suite menutupnya. duration_ms mengukur waktu eksekusi dan berbeda-beda di setiap mesin — angka itu bukan bagian dari hasil uji.
Bila ingin menyasar satu berkas uji, tulis nama berkasnya utuh: node --test tests/tugas.test.mjs. Node 24 memperlakukan argumen folder seperti tests/ sebagai satu berkas uji tunggal dan melaporkannya gagal — penjelasannya ada pada bagian Menjalankan Pengujian di halaman overview proyek.
Bagaimana dengan Uji Antarmuka? #
Fungsi render() dan semua listener di index.html tidak ikut dijalankan node --test — dan itu keputusan yang disengaja. Skrip antarmuka proyek ini sengaja dibuat tipis: ia hanya menggambar ulang state dan meneruskan peristiwa ke logika, sehingga biaya memeriksanya dengan mata tetap rendah. Alur pemeriksaan manualnya singkat: buka halaman, tambah tugas, centang, coba ketiga filter, tutup tab, buka lagi, dan pastikan daftar pulih.
Ketika antarmuka bertumbuh lebih kompleks, pemeriksaan mata tidak lagi cukup cepat dan jalan otomatisnya tersedia: jsdom, implementasi DOM standar web yang berjalan di Node. jsdom memparsing teks HTML menjadi pohon elemen, mendukung querySelector dan event, sehingga fungsi semacam render() bisa dijalankan ke DOM tiruan dan hasil strukturnya diklaim dengan asersi biasa. Wujud kasarnya:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { JSDOM } from 'jsdom';
test('daftar tugas menggambar satu baris per tugas', () => {
const dom = new JSDOM('<ul id="daftar-tugas"></ul>');
const elemenDaftar = dom.window.document.querySelector('#daftar-tugas');
// menjalankan render ke DOM tiruan, lalu mengklaim jumlah <li> yang lahir
});Di titik ini strategi proyek terlihat utuh: logika diuji otomatis di terminal, antarmuka diuji mata — dan bila kelak diperlukan, jsdom menjadikan uji antarmuka pun otomatis, tanpa membuka satu pun tab browser.
Ringkasan Bagian 4 dan Penutup Proyek #
Empat bagian menghasilkan satu aplikasi yang utuh: state berupa array object ber-id yang tidak pernah diubah langsung; tujuh fungsi murni di tugas.mjs yang menutup operasi tambah, hapus, baca, ubah status, filter, hitung, dan penyimpanan; antarmuka yang hanya merender state dan meneruskan peristiwa; serta localStorage yang dipasang tanpa melemahkan keterujian logika. Dua puluh empat uji mengunci semuanya, dan Bagian 4 ini memastikan rangkaian itu terbaca sebagai strategi, bukan kebetulan.
Kebiasaan yang layak dibawa dari proyek ini — dan dari kalkulator sebelumnya — ke proyek mana pun berikutnya: pisahkan keputusan data dari tampilannya; bangun fungsi murni yang mudah dipanggil ulang; kunci setiap kontrak dengan uji yang mengklaim perilaku, bukan implementasi; dan jalankan uji itu setiap kali kode berubah. Kedua proyek di course ini selalu diawali dari logika yang bisa dibuktikan di terminal — pola yang sama yang membuat aplikasi berapa pun ukurannya tetap bisa diandalkan.
Untuk melanjutkan belajar setelah proyek, section Sumber Belajar menyusun peta dokumentasi resmi JavaScript — dari MDN hingga spesifikasi ECMA-262 — tempat setiap topik yang dipakai di proyek ini bisa digali lebih dalam.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
styles/styles.css
Unduh berkas/* styles.css — tampilan aplikasi todo sederhana */
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
font-family: system-ui, sans-serif;
}
.aplikasi {
width: 340px;
padding: 20px;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.aplikasi h1 {
margin: 0 0 16px;
font-size: 1.4rem;
}
.form-tugas {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.form-tugas input {
flex: 1;
padding: 10px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 1rem;
}
.tombol-tambah {
padding: 10px 16px;
border: none;
border-radius: 8px;
background-color: #3b82f6;
color: #ffffff;
font-size: 1rem;
cursor: pointer;
}
.tombol-tambah:hover {
background-color: #2563eb;
}
.pesan {
margin: 0 0 12px;
color: #b91c1c;
font-size: 0.9rem;
}
.filter {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.filter-tombol {
flex: 1;
padding: 8px 0;
border: 1px solid #d1d5db;
border-radius: 8px;
background-color: #f9fafb;
font-size: 0.9rem;
cursor: pointer;
}
.filter-tombol:hover {
background-color: #e5e7eb;
}
.ringkasan {
margin: 0 0 12px;
color: #6b7280;
font-size: 0.9rem;
}
.daftar-tugas {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.tugas {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background-color: #f9fafb;
border-radius: 8px;
}
.tugas span {
flex: 1;
}
.tugas--selesai span {
color: #9ca3af;
text-decoration: line-through;
}
.tombol-hapus {
padding: 6px 10px;
border: none;
border-radius: 6px;
background-color: #fecaca;
font-size: 0.85rem;
cursor: pointer;
}
.tombol-hapus:hover {
background-color: #fca5a5;
}