kodingup.camp

Beranda/Node.js Backend: Database, Auth, Keamanan & Deploy

Testing Backend

Modul 8 dari 105 menit bacaTesting

Isi modul

    Kenapa ini penting

    Setiap kali kamu mengubah kode backend, ada pertanyaan yang menghantui: "apakah aku merusak sesuatu yang lain?". Menambah fitur diskon bisa saja membuat checkout biasa salah menghitung total. Merapikan middleware auth bisa saja membuat user biasa tiba-tiba bisa menghapus produk. Menguji semuanya secara manual dengan curl setiap kali ada perubahan itu melelahkan, dan pasti ada yang terlewat.

    Tes otomatis adalah daftar periksa yang dijalankan komputer dalam hitungan detik. Untuk backend, tes yang paling berharga adalah tes endpoint (sering disebut tes integrasi): kirim request HTTP sungguhan ke aplikasi, lalu periksa status, body, dan header respons. Tes semacam ini menguji routing, middleware, validasi, database, dan aturan akses sekaligus, persis seperti yang dialami klien. Analoginya seperti inspeksi kendaraan sebelum mudik: kamu tidak membongkar mesin satu per satu, tapi menyalakan mobil, menginjak rem, menyalakan lampu sein, dan memastikan semuanya bekerja bersama.

    Tes juga berfungsi sebagai dokumentasi yang selalu benar. Tes berjudul "user lain tidak bisa melihat pesanan orang (404), admin bisa" menjelaskan aturan bisnis lebih jelas daripada komentar di kode, dan ia akan gagal kalau aturan itu dilanggar.

    Konsep inti

    Pilihan test runner: node:test atau Vitest

    node:test (bawaan Node) Vitest 5.0.5
    Instalasi Tidak perlu npm install -D vitest
    Menjalankan node --test npx vitest (mode watch) atau vitest run
    Assertion node:assert/strict (assert.equal, assert.deepEqual) expect(...) gaya Jest (toBe, toEqual, toHaveLength)
    Kelebihan Nol dependency, cocok untuk proyek kecil dan library Mode watch yang cepat, output rapi, ekosistem plugin, coverage (@vitest/coverage-v8 5.0.5)
    Cocok kalau Kamu ingin minimalis Kamu sudah memakai Vite/Vitest di frontend (Fase 3), satu gaya untuk semua

    Keduanya sama-sama baik. Proyek Akhir memakai Vitest supaya gayanya sama dengan tes frontend React-mu.

    Supertest: kirim request tanpa membuka port

    Supertest (7.3.1) menerima objek app Express, menjalankannya di port sementara, mengirim request, lalu mematikannya lagi. Kamu tidak perlu menjalankan npm run dev di terminal lain. Syaratnya, app harus bisa diimpor tanpa memanggil listen(). Itulah alasan kita memisahkan app.js (merakit aplikasi) dari server.js (membuka port) di Modul 7.

    API-nya berantai dan mudah dibaca:

    await request(app)
      .post('/api/products')            // method + path
      .set('Origin', 'http://x.id')     // header
      .send({ name: 'Kaos', price: 1 }) // body JSON (Content-Type otomatis)
      .attach('image', buffer, { filename: 'a.png', contentType: 'image/png' }) // file multipart
      .expect(201)                      // assertion status
      .expect('content-type', /json/);  // assertion header
    

    Untuk endpoint yang butuh login, pakai request.agent(app). Agent bekerja seperti browser kecil: ia menyimpan cookie dari respons (misalnya Set-Cookie: token=... setelah login) dan mengirimkannya lagi di request berikutnya.

    Database terpisah untuk tes

    Aturan paling penting: tes tidak boleh menyentuh database development, apalagi produksi. Tes akan membuat, mengubah, dan menghapus data sesuka hati. Selain itu, setiap tes harus independen: hasilnya tidak boleh bergantung pada tes lain yang berjalan sebelumnya.

    Dengan SQLite, ini sangat mudah: createDb(':memory:') membuat database baru di RAM, lengkap dengan migrasi, dalam hitungan milidetik. Panggil di beforeEach, dan setiap tes mendapat database yang bersih. Inilah keuntungan merancang createApp({ db, config }) yang menerima dependency dari luar (pola dependency injection): tes bisa menyuntikkan database dan konfigurasinya sendiri.

    Untuk Postgres, pola yang umum: database khusus tes (toko_test) yang dikosongkan sebelum setiap file tes, setiap tes dibungkus transaksi yang di-rollback, atau container Postgres sementara (misalnya Testcontainers). Konsepnya sama: data tes tidak pernah bercampur dengan data lain.

    Apa yang dites?

    Untuk setiap endpoint, uji jalur bahagia dan jalur gagal. Bug keamanan hampir selalu bersembunyi di jalur gagal:

    Pola Arrange-Act-Assert membuat tes mudah dibaca: siapkan data (arrange), lakukan aksi (act), periksa hasil (assert).

    Contoh kode

    Mulai dari yang paling sederhana: node:test + Supertest, tanpa Vitest. Aplikasi kecil yang mengekspor createApp:

    // mini-app.js, app kecil untuk dites (export app, JANGAN listen di sini)
    import express from 'express';
    
    export function createApp() {
      const app = express();
      app.use(express.json());
      const todos = [];
      app.get('/todos', (req, res) => res.json(todos));
      app.post('/todos', (req, res) => {
        if (typeof req.body?.title !== 'string' || !req.body.title.trim()) {
          return res.status(400).json({ error: 'title wajib' });
        }
        const todo = { id: todos.length + 1, title: req.body.title.trim(), done: false };
        todos.push(todo);
        res.status(201).json(todo);
      });
      return app;
    }
    
    // test/todos.test.js, jalankan: node --test
    import { test, describe, beforeEach } from 'node:test';
    import assert from 'node:assert/strict';
    import request from 'supertest';
    import { createApp } from '../mini-app.js';
    
    describe('API todos', () => {
      let app;
      beforeEach(() => { app = createApp(); }); // app baru = data baru tiap tes
    
      test('awalnya kosong', async () => {
        const res = await request(app).get('/todos').expect(200);
        assert.deepEqual(res.body, []);
      });
    
      test('POST membuat todo', async () => {
        const res = await request(app).post('/todos').send({ title: '  Belajar testing ' })
          .expect('content-type', /json/).expect(201);
        assert.deepEqual(res.body, { id: 1, title: 'Belajar testing', done: false });
      });
    
      test('POST tanpa title → 400', async () => {
        const res = await request(app).post('/todos').send({});
        assert.equal(res.status, 400);
        assert.equal(res.body.error, 'title wajib');
      });
    });
    
    node --test
    # → ▶ API todos
    # →   ✔ awalnya kosong (15.226459ms)
    # →   ✔ POST membuat todo (7.138833ms)
    # →   ✔ POST tanpa title → 400 (1.528167ms)
    # → ✔ API todos (24.531084ms)
    # → ℹ tests 3
    # → ℹ pass 3
    # → ℹ fail 0
    

    node --test tanpa argumen otomatis mencari file seperti *.test.js dan file di folder test/.

    Sekarang versi Vitest untuk Proyek Akhir. Bagian awal file tes menyiapkan konfigurasi khusus tes dan helper:

    // test/api.test.js (bagian awal)
    import { describe, it, expect, beforeEach, afterAll } from 'vitest';
    import request from 'supertest';
    import argon2 from 'argon2';
    import fs from 'node:fs';
    import os from 'node:os';
    import path from 'node:path';
    import { createDb } from '../src/db/index.js';
    import { createApp } from '../src/app.js';
    import { users } from '../src/db/schema.js';
    
    const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'toko-upload-')); // folder upload sementara
    const config = {
      NODE_ENV: 'test', isProd: false, LOG_LEVEL: 'silent', AUTH_RATE_LIMIT: 1000, // limit longgar untuk tes
      JWT_SECRET: 'rahasia-khusus-tes-yang-panjangnya-lebih-dari-32',
      corsOrigins: ['http://localhost:5173'], UPLOAD_DIR: uploadDir,
    };
    
    let app, db;
    beforeEach(async () => {
      db = createDb(':memory:'); // DB baru tiap tes → tes tidak saling mengganggu
      app = createApp({ db, config });
      db.insert(users).values({ email: 'admin@toko.id', name: 'Admin', role: 'admin',
        passwordHash: await argon2.hash('admin12345') }).run();
    });
    afterAll(() => fs.rmSync(uploadDir, { recursive: true, force: true }));
    
    // agent = "browser" yang menyimpan cookie antar request
    async function loginAs(email, password) {
      const agent = request.agent(app);
      await agent.post('/api/auth/login').send({ email, password }).expect(200);
      return agent;
    }
    async function registerUser(email = 'budi@mail.com') {
      const agent = request.agent(app);
      await agent.post('/api/auth/register').send({ email, name: 'Budi', password: 'rahasia123' }).expect(201);
      return agent;
    }
    async function buatProduk(admin, data = {}) {
      const res = await admin.post('/api/products').send({ name: 'Kaos Polos', price: 75000, stock: 5, ...data }).expect(201);
      return res.body;
    }
    

    Beberapa tes dari file itu. Perhatikan bagaimana judul tes menceritakan aturan bisnisnya:

    describe('auth', () => {
      it('register memasang cookie httpOnly dan tidak mengembalikan hash', async () => {
        const res = await request(app).post('/api/auth/register')
          .send({ email: 'Budi@Mail.com', name: 'Budi', password: 'rahasia123', role: 'admin' });
        expect(res.status).toBe(201);
        // email di-lowercase, role dari body diabaikan, passwordHash tidak ada
        expect(res.body).toEqual({ id: 2, email: 'budi@mail.com', name: 'Budi', role: 'user' });
        expect(res.headers['set-cookie'][0]).toMatch(/HttpOnly/);
      });
    });
    
    describe('pesanan', () => {
      it('stok kurang → 409 dan TIDAK ada perubahan (rollback)', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const a = await buatProduk(admin, { name: 'Produk A', stock: 10 });
        const b = await buatProduk(admin, { name: 'Produk B', stock: 1 });
        const user = await registerUser();
        await user.post('/api/orders')
          .send({ items: [{ productId: a.id, quantity: 3 }, { productId: b.id, quantity: 5 }] })
          .expect(409);
        expect((await request(app).get(`/api/products/${a.id}`)).body.stock).toBe(10); // stok A kembali utuh
        expect((await user.get('/api/orders')).body).toHaveLength(0);
      });
    
      it('user lain tidak bisa melihat pesanan orang (404), admin bisa', async () => {
        const admin = await loginAs('admin@toko.id', 'admin12345');
        const p = await buatProduk(admin);
        const budi = await registerUser('budi@mail.com');
        const siti = await registerUser('siti@mail.com');
        const order = (await budi.post('/api/orders').send({ items: [{ productId: p.id, quantity: 1 }] })).body;
        await siti.get(`/api/orders/${order.id}`).expect(404);
        const detail = await budi.get(`/api/orders/${order.id}`).expect(200);
        expect(detail.body.items[0].product.name).toBe('Kaos Polos');
        await admin.get(`/api/orders/${order.id}`).expect(200);
      });
    });
    

    Tes rollback di atas sangat berharga. Produk A sempat dikurangi stoknya di dalam transaksi sebelum produk B gagal. Tes ini membuktikan pengurangan itu benar-benar dibatalkan.

    Hasil npm test (vitest run) di Proyek Akhir:

    npx vitest run --reporter=verbose
    # →  ✓ test/api.test.js > auth > register memasang cookie httpOnly dan tidak mengembalikan hash 86ms
    # →  ✓ test/api.test.js > auth > email dobel → 409, input jelek → 400 56ms
    # →  ✓ test/api.test.js > auth > password salah → 401, /me tanpa cookie → 401 79ms
    # →  ✓ test/api.test.js > produk & role > user biasa tidak boleh membuat produk (403), tamu 401 52ms
    # →  ✓ test/api.test.js > produk & role > admin CRUD produk, publik bisa membaca & mencari 57ms
    # →  ✓ test/api.test.js > produk & role > upload gambar: PNG asli diterima, teks menyamar jadi PNG ditolak 81ms
    # →  ✓ test/api.test.js > pesanan > checkout mengurangi stok, total dihitung server 78ms
    # →  ✓ test/api.test.js > pesanan > stok kurang → 409 dan TIDAK ada perubahan (rollback) 79ms
    # →  ✓ test/api.test.js > pesanan > user lain tidak bisa melihat pesanan orang (404), admin bisa 108ms
    # →  ✓ test/api.test.js > keamanan dasar > header helmet terpasang dan x-powered-by hilang 26ms
    # →  ✓ test/api.test.js > keamanan dasar > JSON rusak → 400 rapi, bukan stack trace 27ms
    # →  Test Files  1 passed (1)
    # →       Tests  11 passed (11)
    

    Sebelas tes, sekitar 1,3 detik. Bandingkan dengan menguji semua skenario itu secara manual lewat curl.

    Kesalahan umum

    1. Tes memakai database development. Data asli bisa terhapus, dan tes menjadi tidak stabil karena bergantung pada isi database. Selalu pakai database terpisah (:memory: untuk SQLite, database _test untuk Postgres).
    2. Tes saling bergantung. Tes B mengandalkan produk yang dibuat tes A. Saat tes A diubah atau dijalankan sendirian, tes B ikut gagal. Siapkan data di setiap tes atau di beforeEach.
    3. Memanggil app.listen() di file yang diimpor tes. Port bentrok ("EADDRINUSE"), dan proses tes tidak mau selesai karena server masih hidup. Pisahkan app.js dan server.js.
    4. Hanya menguji jalur sukses. Bug otorisasi tidak akan pernah ketahuan kalau tidak ada tes "user biasa → 403" dan "pesanan orang lain → 404".
    5. Rate limiter ikut aktif di tes. Tes ke-11 tiba-tiba mendapat 429. Jadikan batasnya konfigurasi (AUTH_RATE_LIMIT) dan longgarkan di konfigurasi tes, lalu buat satu tes khusus untuk rate limiter itu sendiri.

    Latihan

    1. Mudah, Tulis tes bahwa GET /api/products/abc membalas 400 dengan pesan ID tidak valid.
    2. Sedang, Isi database dengan 25 produk, lalu tulis dua tes: ?limit=100 ditolak (400), dan ?page=2&limit=10 mengembalikan tepat 10 produk, dari "Produk 11" sampai "Produk 20".
    3. Menantang, Tulis tes untuk rate limiter login itu sendiri: dengan AUTH_RATE_LIMIT: 3, empat percobaan login salah berturut-turut harus menghasilkan status [401, 401, 401, 429].
    Lihat solusi

    Ketiga solusi diletakkan di satu file test/latihan.test.js, memakai baseConfig yang sama dengan config di file tes utama.

    1.

    describe('validasi parameter', () => {
      let app;
      beforeEach(() => { app = createApp({ db: createDb(':memory:'), config: baseConfig }); });
    
      it('id bukan angka → 400', async () => {
        const res = await request(app).get('/api/products/abc');
        expect(res.status).toBe(400);
        expect(res.body.error).toBe('ID tidak valid');
      });
    });
    

    2. Isi database langsung lewat Drizzle (lebih cepat daripada lewat API) dengan Array.from.

    import { products } from '../src/db/schema.js';
    
    describe('pagination', () => {
      let app, db;
      beforeEach(() => {
        db = createDb(':memory:');
        app = createApp({ db, config: baseConfig });
        db.insert(products).values(
          Array.from({ length: 25 }, (_, i) => ({ name: `Produk ${i + 1}`, price: 1000 })),
        ).run();
      });
    
      it('limit di atas 50 ditolak', async () => {
        await request(app).get('/api/products?limit=100').expect(400);
      });
    
      it('halaman 2 berisi produk 11-20', async () => {
        const res = await request(app).get('/api/products?page=2&limit=10').expect(200);
        expect(res.body.data).toHaveLength(10);
        expect(res.body.data[0].name).toBe('Produk 11');
        expect(res.body.data.at(-1).name).toBe('Produk 20');
      });
    });
    

    3. Buat app dengan konfigurasi berbeda khusus untuk tes ini.

    describe('rate limit login', () => {
      it('percobaan ke-4 diblokir kalau limit 3', async () => {
        const app = createApp({ db: createDb(':memory:'), config: { ...baseConfig, AUTH_RATE_LIMIT: 3 } });
        const kirim = () => request(app).post('/api/auth/login').send({ email: 'x@x.com', password: 'salah' });
        const status = [];
        for (let i = 0; i < 4; i++) status.push((await kirim()).status);
        expect(status).toEqual([401, 401, 401, 429]);
      });
    });
    
    npx vitest run test/latihan.test.js --reporter=verbose   # (output dipersingkat)
    # →  ✓ validasi parameter > id bukan angka → 400
    # →  ✓ pagination > limit di atas 50 ditolak
    # →  ✓ pagination > halaman 2 berisi produk 11-20
    # →  ✓ rate limit login > percobaan ke-4 diblokir kalau limit 3
    # →       Tests  4 passed (4)
    

    Karena setiap pemanggilan createApp membuat limiter baru, hitungan rate limit tidak bocor antar tes.

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Testing Node Server with Jest and SupertestSam Meech-Ward · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.