Topik penutup ini mengikat hasil kerja tiga topik sebelumnya menjadi satu paket yang siap dipakai orang lain. Pengujian memakai runner bawaan Node, Dockerfile multi-stage menghasilkan image yang ramping dan berjalan sebagai pengguna non-root, docker-compose.yml menyatukan layanan API dan PostgreSQL, dan README.md menjelaskan cara instalasi serta menjalankan layanan. Setelah membaca topik ini, hasil kerja proyek dapat diuji ulang, dikontainerisasi, dan didokumentasikan secara profesional.
Pengujian dengan node:test#
Pengujian memakai runner bawaan Node yang dipanggil melalui node --test. Runner ini membaca berkas yang berakhiran .test.js, menjalankan setiap fungsi test(), dan melaporkan hasil dalam format yang ringkas. Asersi memakai modul node:assert yang juga bawaan, sehingga tidak ada dependensi tambahan untuk pengujian unit. Pustaka zod dipasang sebagai dependensi karena schema yang diuji merupakan kode produksi yang sama yang dipakai pada endpoint.
Berkas catatan.test.js berikut menguji SkemaCatatan dengan enam kasus yang mencakup cabang sukses dan cabang gagal. Setiap kasus memanggil safeParse dan memverifikasi success serta isi data atau error.issues. Kasus kelima memverifikasi bahwa tag yang tidak diberikan diisi larik kosong secara default, sedangkan kasus keenam memverifikasi batas atas panjang judul.
// tidak-dijalankan: butuh zod yang dipasang di /tmp/pemilihan-proyek
const test = require('node:test');
const assert = require('node:assert');
const { z } = require('zod');
const SkemaCatatan = z.object({
judul: z.string().min(3, 'Judul minimal 3 karakter').max(120, 'Judul maksimal 120 karakter'),
isi: z.string().min(1, 'Isi tidak boleh kosong'),
tag: z.array(z.string()).max(5, 'Maksimal 5 tag').optional().default([]),
});
test('catatan valid lolos schema', () => {
const hasil = SkemaCatatan.safeParse({
judul: 'Belajar Node.js minggu pertama',
isi: 'Catatan tentang modul inti dan stream.',
tag: ['node', 'stream'],
});
assert.equal(hasil.success, true);
assert.deepEqual(hasil.data.tag, ['node', 'stream']);
});
test('catatan dengan judul pendek ditolak', () => {
const hasil = SkemaCatatan.safeParse({ judul: 'ab', isi: 'Isi.' });
assert.equal(hasil.success, false);
const isu = hasil.error.issues.find((i) => i.path[0] === 'judul');
assert.ok(isu);
assert.match(isu.message, /Judul minimal 3 karakter/);
});
test('catatan dengan isi kosong ditolak', () => {
const hasil = SkemaCatatan.safeParse({ judul: 'Judul valid', isi: '' });
assert.equal(hasil.success, false);
const isu = hasil.error.issues.find((i) => i.path[0] === 'isi');
assert.ok(isu);
assert.match(isu.message, /Isi tidak boleh kosong/);
});
test('catatan dengan lebih dari 5 tag ditolak', () => {
const hasil = SkemaCatatan.safeParse({
judul: 'Judul valid',
isi: 'Isi.',
tag: ['a', 'b', 'c', 'd', 'e', 'f'],
});
assert.equal(hasil.success, false);
const isu = hasil.error.issues.find((i) => i.path[0] === 'tag');
assert.ok(isu);
assert.match(isu.message, /Maksimal 5 tag/);
});
test('catatan tanpa tag diisi larik kosong secara default', () => {
const hasil = SkemaCatatan.safeParse({ judul: 'Judul valid', isi: 'Isi.' });
assert.equal(hasil.success, true);
assert.deepEqual(hasil.data.tag, []);
});
test('catatan dengan judul lebih dari 120 karakter ditolak', () => {
const hasil = SkemaCatatan.safeParse({
judul: 'a'.repeat(121),
isi: 'Isi.',
});
assert.equal(hasil.success, false);
const isu = hasil.error.issues.find((i) => i.path[0] === 'judul');
assert.ok(isu);
assert.match(isu.message, /Judul maksimal 120 karakter/);
});# pasang zod sekali untuk demo topik ini: cd /tmp/pemilihan-proyek && npm install zod@^3.25
# jalankan catatan.test.js dari direktori topik, NODE_PATH mengarah ke node_modules demo
# sed menyelaraskan waktu eksekusi yang berubah-ubah tiap jalankan
NODE_PATH=/tmp/pemilihan-proyek/node_modules node --test catatan.test.js 2>&1 \
| sed -E 's/\([0-9.]+ms\)/(N ms)/; s/duration_ms [0-9.]+/duration_ms N/'✔ catatan valid lolos schema (N ms)
✔ catatan dengan judul pendek ditolak (N ms)
✔ catatan dengan isi kosong ditolak (N ms)
✔ catatan dengan lebih dari 5 tag ditolak (N ms)
✔ catatan tanpa tag diisi larik kosong secara default (N ms)
✔ catatan dengan judul lebih dari 120 karakter ditolak (N ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms NKeluaran memperlihatkan enam pengujian yang lulus dengan tanda ✔ dan ringkasan akhir yang menyatakan pass 6 dan fail 0. Waktu eksekusi setiap pengujian dicetak dalam milidetik, yang memperlihatkan bahwa pengujian schema sangat cepat sebab hanya menjalankan safeParse tanpa I/O. Ringkasan akhir juga mencatat duration_ms total yang mencakup waktu pemuatan modul dan eksekusi seluruh pengujian.
Pengujian ini berfokus pada kontrak masukan, yaitu schema yang menjadi gerbang antara klien dan logika bisnis. Pengujian HTTP yang sebenarnya, yang memanggil endpoint utuh melalui fetch atau supertest, sebaiknya ditempatkan pada berkas terpisah di direktori test/ dengan konvensi nama yang sama. Pemisahan pengujian schema dan pengujian HTTP menjaga setiap berkas tetap pendek dan memudahkan penelusuran ketika satu kasus gagal.
Dockerfile multi-stage#
Dockerfile multi-stage memisahkan tahap build dan tahap runtime agar image akhir tidak membawa devDependencies dan berkas build yang tidak diperlukan pada produksi. Tahap build memakai node:24-bookworm-slim sebagai basis, menyalin package.json dan package-lock.json, lalu menjalankan npm ci --omit=dev yang memasang hanya dependensi produksi. Setelah dependensi terpasang, tahap build menyalin prisma/ dan menjalankan npx prisma generate untuk menghasilkan Prisma Client, kemudian menyalin src/.
# Build stage: pasang dependensi dan hasilkan Prisma Client.
FROM node:24-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY prisma ./prisma
RUN npx prisma generate
COPY src ./src
# Runtime stage: image akhir yang ramping tanpa devDependencies.
FROM node:24-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
RUN useradd --system --uid 1001 --create-home nodeapp
COPY --from=build --chown=nodeapp:nodeapp /app/node_modules ./node_modules
COPY --from=build --chown=nodeapp:nodeapp /app/package.json ./package.json
COPY --from=build --chown=nodeapp:nodeapp /app/src ./src
COPY --from=build --chown=nodeapp:nodeapp /app/prisma ./prisma
USER nodeapp
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "src/server.js"]Tahap runtime membuat pengguna sistem nodeapp dengan UID 1001, menyalin berkas dari tahap build dengan kepemilikan nodeapp, dan beralih ke pengguna tersebut dengan USER nodeapp. Pendekatan ini mengikuti prinsip least privilege sebab proses Node tidak berjalan sebagai root di dalam kontainer. Direktif HEALTHCHECK memanggil fetch ke endpoint /healthz setiap 30 detik, yang mengembalikan status 200 bila layanan siap menerima permintaan.
Pemilihan node:24-bookworm-slim alih-alih node:24-alpine didasarkan pada kompatibilitas. Beberapa dependensi native seperti bcrypt memerlukan toolchain dan pustaka glibc yang ada pada Debian tetapi tidak ada pada Alpine. Image slim menyediakan glibc tanpa membawa seluruh perangkat Debian, sehingga ukuran image tetap wajar sambil menjaga kompatibilitas. Bila proyek tidak memakai dependensi native, node:24-alpine dapat dipakai untuk image yang lebih kecil.
docker-compose untuk lingkungan lengkap#
Berkas docker-compose.yml menyatukan layanan API dan PostgreSQL sehingga lingkungan pengembangan dapat dibangkitkan dengan satu perintah. Layanan api membangun image dari Dockerfile di direktori yang sama, memetakan port 3000, dan mengatur variabel lingkungan yang dibutuhkan aplikasi. Layanan db memakai image resmi PostgreSQL 17 dengan kredensial yang dideklarasikan pada variabel lingkungan, serta volume bernama dbdata untuk persistensi data antar-restart.
services:
api:
build: .
image: catatan-api:latest
ports:
- "3000:3000"
environment:
NODE_ENV: production
APP_DB_URL: postgresql://catatan:catatan@db:5432/catatan?schema=public
APP_JWT_SECRET: ganti-dengan-rahasia-produksi
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-bookworm
environment:
POSTGRES_USER: catatan
POSTGRES_PASSWORD: catatan
POSTGRES_DB: catatan
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U catatan"]
interval: 10s
timeout: 3s
retries: 5
restart: unless-stopped
volumes:
dbdata:Direktif depends_on dengan condition: service_healthy memastikan layanan api hanya dimulai setelah db sehat, yang ditentukan oleh healthcheck yang memanggil pg_isready. Tanpa kondisi ini, api dapat dimulai sebelum PostgreSQL siap menerima koneksi, sehingga Prisma melempar error koneksi pada saat mulai. Volume dbdata menyimpan data PostgreSQL di luar kontainer, sehingga data tetap ada ketika kontainer dihancurkan dan dibangun ulang.
Variabel APP_JWT_SECRET pada berkas ini hanya bernilai placeholder untuk dokumentasi. Pada produksi, rahasia tidak dituliskan pada docker-compose.yml melainkan ditempatkan pada berkas .env yang tidak dikomit, atau dikelola oleh orchestrator seperti Docker Swarm dan Kubernetes. Pemisahan rahasia dari berkas konfigurasi mengikuti prinsip twelve-factor app yang menetapkan bahwa konfigurasi yang berbeda antar lingkungan disimpan di lingkungan, bukan di kode.
Dokumentasi README#
Berkas README.md menjadi pintu masuk proyek yang menjelaskan prasyarat, instalasi, cara menjalankan, dan struktur direktori. README yang baik menjawab tiga pertanyaan pembaca baru dalam dua menit pertama: apa proyek ini, bagaimana menjalankannya, dan di mana menemukan informasi lebih lanjut. Berkas ini juga merujuk openapi.yaml dan struktur-proyek.txt sebagai dokumen pendukung yang menjaga README tetap pendek.
# Catatan API
Layanan API catatan pribadi dengan autentikasi JWT dan persistensi Prisma pada PostgreSQL.
## Prasyarat
- Node.js 24 LTS atau lebih baru
- PostgreSQL 17 atau lebih baru
- Docker dan Docker Compose (opsional, untuk menjalankan dengan basis data terkontainer)
## Instalasi lokal
1. Salin repositori: `git clone <repo>`
2. Masuk direktori: `cd catatan-api`
3. Pasang dependensi: `npm ci`
4. Salin berkas env: `cp .env.example .env`
5. Sunting `.env`, isi `APP_DB_URL` dan `APP_JWT_SECRET`
6. Terapkan migrasi: `npx prisma migrate dev`
7. Jalankan server: `npm start`
## Menjalankan dengan Docker
Jalankan `docker compose up --build` dari direktori proyek. Layanan tersedia di http://localhost:3000.
## Endpoint
Lihat `openapi.yaml` untuk dokumentasi endpoint lengkap, mencakup method, jalur, skema permintaan, dan skema respons.
| Method | Jalur | Otentikasi |
|---|---|---|
| POST | /auth/register | publik |
| POST | /auth/login | publik |
| GET | /catatan | JWT |
| POST | /catatan | JWT |
| GET | /catatan/:id | JWT |
| PUT | /catatan/:id | JWT |
| DELETE | /catatan/:id | JWT |
## Pengujian
Jalankan `npm test` dari direktori proyek. Pengujian memakai runner bawaan Node (`node --test`) dan berkas uji pada direktori `test/`.
## Struktur direktori
Lihat `struktur-proyek.txt` untuk tata letak berkas, mencakup `src/`, `prisma/`, dan `test/`.
## Variabel lingkungan
| Nama | Deskripsi |
|---|---|
| `APP_DB_URL` | URL koneksi PostgreSQL, dipakai oleh Prisma |
| `APP_JWT_SECRET` | Rahasia yang dipakai menandatangani JWT |
| `APP_PORT` | Port server, default `3000` |
## Lisensi
Berkas ini bersifat contoh untuk tujuan pembelajaran dan tidak membawa lisensi perangkat lunak.README di atas memakai daftar bernomor untuk langkah instalasi sehingga setiap langkah dapat diikuti berurutan tanpa membuka terminal sambil membaca. Berkas Markdown pada bundle topik dapat dibaca sebagai teks biasa dan dicocokkan dengan blok {file="README.md"} secara harfiah. Tabel endpoint dan tabel variabel lingkungan memberikan ringkasan yang dapat dipindai cepat tanpa membuka openapi.yaml.
Kesalahan umum#
Menulis pengujian yang bergantung pada keadaan global. Pengujian yang membagi variabel Map antar-kasus akan lulus bila dijalankan berurutan tetapi gagal bila dijalankan terbalik. Setiap pengujian sebaiknya membuat instance sendiri, misalnya dengan memanggil buatApp() pada setiap test(), sehingga keadaan satu pengujian tidak memengaruhi pengujian lain.
Memakai image node:24 (full) alih-alih node:24-bookworm-slim pada runtime. Image full berukuran sekitar 1 GB dan membawa toolchain serta pustaka yang tidak diperlukan pada produksi. Image slim berukuran sekitar 250 MB dan cukup untuk menjalankan Node, sehingga image akhir lebih kecil dan waktu pull lebih cepat.
Menulis rahasia langsung pada docker-compose.yml. Praktik ini membuat rahasia tercommit ke kontrol versi dan dapat dibaca oleh siapa pun yang memiliki akses ke repo. Letakkan rahasia pada berkas .env yang diabaikan oleh .gitignore, atau gunakan Docker secrets pada produksi.
Ringkasan#
- Pengujian memakai runner bawaan
node --testdengan modulnode:assert, sehingga tidak ada dependensi runner tambahan. Berkascatatan.test.jsmengujiSkemaCatatandengan enam kasus yang mencakup cabang sukses dan cabang gagal. - Dockerfile multi-stage memisahkan tahap build dan runtime, menghasilkan image yang hanya membawa dependensi produksi dan berjalan sebagai pengguna non-root dengan UID 1001.
docker-compose.ymlmenyatukan layanan API dan PostgreSQL, dengandepends_onbersyaratservice_healthyagar API menunggu PostgreSQL siap, dan volumedbdatauntuk persistensi data.- README merujuk
openapi.yamldanstruktur-proyek.txtalih-alih menggandakan isinya, sehingga perubahan pada endpoint atau struktur direktori tidak menyebabkan README menjadi usang.
Referensi#
- Node.js — Test runner — Node.js
— Dokumentasi resmi runner
node --test, mencakuptest(),assert, dan reporter bawaan. - Docker — Multi-stage builds — Docker Documentation — Panduan resmi build multi-stage untuk memisahkan dependensi build dan runtime.
- Docker Compose — Compose file reference — Docker Documentation
— Referensi resmi
docker-compose.yml, mencakupservices,depends_on,healthcheck, danvolumes. - The Twelve-Factor App — Config — 12factor.net — Prinsip resmi twelve-factor app yang menetapkan pemisahan konfigurasi dan rahasia dari kode.