Menguji Aplikasi NestJS: Panduan Unit, Integrasi, E2E

Foto oleh qubodup via Openverse (CC BY 2.0)
Unit test memakai Test.createTestingModule dengan setiap dependency ditiru, sehingga memverifikasi logika bisnis sebuah service secara sepenuhnya terisolasi dan berjalan dalam hitungan milidetik. Integration test memasang database sungguhan namun sekali pakai ke dalam testing module yang sama untuk memverifikasi bahwa query dan pemetaan ORM benar-benar berfungsi. End-to-end test menjalankan aplikasi Nest secara penuh dan memakai supertest untuk mengirim permintaan HTTP sungguhan, memverifikasi seluruh pipeline dari route hingga respons persis seperti yang dialami klien.
Daftarkan token provider yang sama dengan yang dipakai service Anda untuk injeksi, misalnya token khusus dari sintaks provide atau hasil getModelToken, tetapi berikan useValue yang menunjuk ke objek polos yang metodenya berupa fungsi tiruan Jest. Karena service hanya bergantung pada token, bukan implementasi konkretnya, service itu tidak bisa membedakan repository sungguhan dari tiruannya, sehingga tidak perlu ada perubahan kode sumber untuk pengujian.
app.getHttpServer() yang dipasangkan dengan fungsi request milik supertest adalah pola standar untuk adapter Express bawaan NestJS, dan inilah yang didemonstrasikan dokumentasi resmi Nest untuk end-to-end test. Jika Anda memakai adapter Fastify, pendekatan setaranya adalah app.inject dengan method dan url, karena Fastify tidak mengekspos HTTP server mentah dengan cara yang sama seperti Express.
Tidak — itu hanya menduplikasi assertion yang sama dengan biaya yang berbeda. Logika bisnis, perhitungan, dan percabangan kondisional cukup diuji dengan unit test yang cepat, sementara alur yang benar-benar kritis bagi pengguna seperti login, checkout, atau webhook pembayaran barulah masuk end-to-end test. Kebanyakan service cukup punya cakupan unit test yang menyeluruh dan hanya muncul di e2e test jika benar-benar berada di jalur kritis, sehingga bentuk suite pengujian secara keseluruhan tetap seperti piramida, bukan tembok pengujian yang lambat.
Jangan pernah mengarahkan integration test ke database staging atau development yang dipakai bersama, karena sesi yang berjalan bersamaan akan saling merusak data satu sama lain. Sebagai gantinya, berikan setiap sesi pengujian instans Postgres, MySQL, atau SQLite sekali pakai miliknya sendiri, sinkronkan skema di awal suite, dan tutup testing module di hook afterAll agar koneksi dilepaskan dan sesi berikutnya mulai dari kondisi yang bersih dan dapat diprediksi.

Foto oleh qubodup via Openverse (CC BY 2.0)
Setiap basis kode NestJS pada akhirnya mengumpulkan tiga jenis pengujian yang berbeda, dan kebanyakan tim menulisnya berdasarkan insting, bukan rancangan yang jelas. Hasilnya bisa berupa aplikasi dengan ratusan end-to-end test yang lambat dan rapuh sehingga butuh sepuluh menit untuk berjalan, atau aplikasi dengan seribu unit test yang semuanya lolos padahal endpoint HTTP-nya sendiri rusak. Modul pengujian NestJS, yang dibangun di atas Jest dan kontainer dependency injection milik Nest sendiri, memberi Anda perangkat yang tepat untuk memisahkan ketiga hal ini dengan benar: provider tiruan untuk unit test yang cepat, database sungguhan namun sekali pakai untuk integration test, dan aplikasi yang benar-benar dijalankan penuh untuk end-to-end test. Panduan ini membahas ketiganya, lengkap dengan kode yang berjalan untuk masing-masing, serta kerangka berpikir yang jelas untuk menentukan di lapisan mana sebuah pengujian sebenarnya harus berada.
Unit test memverifikasi satu kelas secara terisolasi — logika bisnis sebuah service, dengan setiap kolaboratornya diganti dengan tiruan. Pertanyaannya hanya satu: dengan input dan respons dependency tertentu, apakah metode yang diuji menghasilkan output yang benar dan memanggil dependency-nya dengan tepat. Integration test memverifikasi sepotong sistem di mana setidaknya satu dependency infrastruktur sungguhan ikut dipasang, paling umum berupa instans database sungguhan yang dibatasi khusus untuk sesi pengujian. Pertanyaannya adalah apakah query, migrasi, dan pemetaan ORM Anda benar-benar tepat, sesuatu yang tidak pernah bisa dijawab oleh repository tiruan. End-to-end test memverifikasi seluruh siklus hidup permintaan — lapisan HTTP, guard, pipe, interceptor, controller, service, dan (biasanya) database sungguhan atau yang menyerupainya — dengan melakukan panggilan HTTP sungguhan terhadap aplikasi Nest yang sudah dijalankan penuh. Pertanyaannya adalah apakah klien sungguhan yang memanggil route sungguhan mendapatkan respons yang dibutuhkan produk Anda. Setiap lapisan menghabiskan biaya jalan dan perawatan lebih besar dari lapisan sebelumnya, itulah sebabnya rasio antar lapisan sama pentingnya dengan menuliskan pengujiannya itu sendiri.
Modul pengujian Nest, yang diimpor dari paket at-nestjs slash testing, mengekspos Test.createTestingModule, yang membangun kontainer dependency injection Nest sungguhan dari sebuah objek metadata modul, persis seperti yang dilakukan dekorator at-Module pada kode produksi. Bedanya, dalam unit test Anda memberinya array providers polos, bukan kelas at-Module lengkap, dan Anda mengganti setiap provider yang menjadi dependency service yang diuji dengan tiruan. Memanggil compile pada builder yang dikembalikan akan menyelesaikan kontainer tersebut, dan module.get mengambil instans service yang sudah terpasang penuh, lengkap dengan dependency tiruannya yang sudah disuntikkan. Karena tidak ada satu pun bagian ini yang menyentuh database sungguhan, panggilan jaringan, atau filesystem, pengujian ini berjalan dalam hitungan milidetik dan bisa dijalankan paralel dengan aman hingga ratusan sekaligus.
Kebanyakan service NestJS bergantung pada provider repository atau akses data, bukan langsung pada kelas ORM konkret, dan justru itulah yang membuatnya mudah ditiru. Jika service Anda disuntikkan lewat token khusus, yang didefinisikan dengan sintaks provide alih-alih referensi kelas, modul pengujian Anda cukup mendaftarkan token yang sama itu dengan useValue yang menunjuk ke objek polos yang metodenya berupa fungsi tiruan Jest. Service yang diuji tidak pernah tahu bedanya antara repository sungguhan dan tiruan, karena keduanya memenuhi token injeksi yang sama. Ini adalah pola provider useValue yang sama yang didokumentasikan Nest untuk mengganti dengan test double untuk model Mongoose, model Sequelize, atau dependency lain yang disuntikkan — mekanismenya identik apa pun yang sedang Anda tiru.
// invoices.service.spec.ts — unit test with a mocked repository provider
import { Test, TestingModule } from "@nestjs/testing"
import { InvoicesService } from "./invoices.service"
import { INVOICE_REPOSITORY } from "./invoices.constants"
describe("InvoicesService", () => {
let service: InvoicesService
const mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
InvoicesService,
{
provide: INVOICE_REPOSITORY,
useValue: mockRepository,
},
],
}).compile()
service = module.get<InvoicesService>(InvoicesService)
})
afterEach(() => {
jest.clearAllMocks()
})
it("throws when an invoice is already marked paid", async () => {
mockRepository.findOne.mockResolvedValue({ id: 1, status: "PAID" })
await expect(service.markAsPaid(1)).rejects.toThrow(
"Invoice is already paid"
)
expect(mockRepository.save).not.toHaveBeenCalled()
})
it("marks a pending invoice as paid and persists it", async () => {
mockRepository.findOne.mockResolvedValue({ id: 1, status: "PENDING" })
mockRepository.save.mockImplementation((entity) => Promise.resolve(entity))
const result = await service.markAsPaid(1)
expect(result.status).toBe("PAID")
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: "PAID" })
)
})
})
Jaga agar tiruan pada unit test tetap berupa objek polos dengan fungsi tiruan Jest, bukan kelas palsu buatan tangan yang berisi logika sungguhan di dalamnya. Begitu sebuah tiruan mulai mengimplementasikan ulang aturan bisnis hanya agar sebuah assertion lolos, Anda sudah membuat salinan kedua dari kode yang seharusnya diuji, tanpa pengujian sama sekali, dan salinan itu akan diam-diam melenceng dari implementasi aslinya.
Unit test tidak bisa menangkap nama kolom yang salah, join yang keliru, indeks yang hilang sehingga mengubah urutan hasil, atau ketidaksesuaian halus antara definisi entity Anda dengan skema sungguhan, karena repository tiruan selalu mengembalikan persis apa yang Anda perintahkan. Integration test menutup celah itu dengan memasang database sungguhan namun sekali pakai ke dalam alur Test.createTestingModule yang sama, alih-alih meniru repository-nya. Trade-off-nya adalah kecepatan dan kompleksitas persiapan: pengujian ini membutuhkan instans Postgres, MySQL, atau SQLite yang benar-benar berjalan, serta strategi untuk menjaga setiap sesi pengujian tetap terisolasi satu sama lain agar baris data sisa dari satu pengujian tidak merusak assertion pengujian berikutnya.
Pendekatan paling umum adalah mengarahkan TypeOrmModule.forRoot, atau konfigurasi setara di Prisma atau Mongoose, ke instans database yang hanya ada selama sesi pengujian berlangsung — kontainer Postgres khusus pengujian, skema sekali pakai, atau database SQLite in-memory untuk kasus yang lebih ringan. Karena Test.createTestingModule menerima array imports penuh, bukan sekadar array providers, Anda bisa mengimpor konfigurasi TypeOrmModule atau MongooseModule yang persis sama dengan yang dipakai AppModule produksi Anda, hanya diarahkan ke pengaturan koneksi yang berbeda. Menjalankan sinkronisasi skema dan menghapus skema di antara file pengujian menjaga setiap sesi tetap terisolasi, dan menutup modul di hook afterAll melepaskan koneksinya sehingga Jest bisa keluar dengan bersih alih-alih menggantung karena koneksi database yang masih terbuka.
// invoices.repository.integration-spec.ts — real query, test-scoped database
import { Test, TestingModule } from "@nestjs/testing"
import { TypeOrmModule } from "@nestjs/typeorm"
import { Invoice } from "./invoice.entity"
import { InvoicesRepository } from "./invoices.repository"
describe("InvoicesRepository (integration)", () => {
let module: TestingModule
let repository: InvoicesRepository
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
host: process.env.TEST_DB_HOST,
port: 5433,
database: "invoices_test",
entities: [Invoice],
synchronize: true,
dropSchema: true,
}),
TypeOrmModule.forFeature([Invoice]),
],
providers: [InvoicesRepository],
}).compile()
repository = module.get<InvoicesRepository>(InvoicesRepository)
})
afterAll(async () => {
await module.close()
})
it("finds only overdue invoices for a given tenant", async () => {
await repository.save([
{ tenantId: "t1", status: "OVERDUE", dueDate: new Date("2026-01-01") },
{ tenantId: "t1", status: "PAID", dueDate: new Date("2026-01-01") },
{ tenantId: "t2", status: "OVERDUE", dueDate: new Date("2026-01-01") },
])
const results = await repository.findOverdueForTenant("t1")
expect(results).toHaveLength(1)
expect(results[0].status).toBe("OVERDUE")
})
})
Jangan pernah mengarahkan suite integration test ke database staging atau development yang dipakai bersama. Sesi pengujian yang berjalan bersamaan akan saling berebut baris data yang sama, migrasi yang dijalankan satu branch bisa merusak assertion di branch lain, dan bug pada langkah pembersihan satu pengujian bisa diam-diam merusak data yang sedang aktif di-debug oleh engineer lain. Berikan setiap sesi pengujian database atau skemanya sendiri, dan bongkar secara deterministik di afterAll — perlakukan database pengujian sebagai infrastruktur yang sepenuhnya sekali pakai, bukan sumber daya bersama.
End-to-end test adalah satu-satunya lapisan yang benar-benar menjalankan seluruh pipeline permintaan persis seperti yang akan dipicu oleh klien sungguhan — pipe global yang memvalidasi body yang masuk, guard yang memeriksa otorisasi, interceptor yang mengubah respons, dan pengkabelan controller-ke-service semuanya berjalan sesuai urutan yang benar-benar diterapkan Nest di produksi. Dokumentasi resmi Nest sendiri merekomendasikan supertest karena request(app.getHttpServer()) memberi Anda referensi langsung ke HTTP listener sungguhan milik aplikasi, sehingga permintaan yang dikirim pengujian Anda tidak bisa dibedakan, dari sudut pandang framework, dari permintaan yang benar-benar datang lewat jaringan.
Persiapannya mencerminkan pemanggilan Test.createTestingModule pada unit test, tetapi alih-alih berhenti di compile, Anda memanggil createNestApplication pada referensi modul yang sudah diselesaikan lalu await app.init(), yang menjalankan runtime Nest secara penuh persis seperti yang dilakukan main.ts. Dari situ, app.getHttpServer() memberi Anda instans HTTP server yang mendasarinya, dan fungsi request milik supertest membungkusnya sehingga Anda bisa merangkai get, post, dan verb HTTP lain langsung ke path route sungguhan. Karena pengujian ini kini bergantung pada seluruh graf modul, di sinilah tempat yang tepat untuk hanya men-stub batas eksternal yang benar-benar ada, penyedia email, gateway pembayaran, API pihak ketiga, sambil membiarkan sisa kode aplikasi sungguhan berjalan persis seperti di produksi.
// invoices.e2e-spec.ts — full HTTP flow via supertest
import * as request from "supertest"
import { Test, TestingModule } from "@nestjs/testing"
import { INestApplication } from "@nestjs/common"
import { AppModule } from "../src/app.module"
describe("Invoices (e2e)", () => {
let app: INestApplication
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile()
app = moduleRef.createNestApplication()
await app.init()
})
afterAll(async () => {
await app.close()
})
it("POST /invoices/:id/pay marks an invoice paid and returns 200", async () => {
const server = app.getHttpServer()
const created = await request(server)
.post("/invoices")
.send({ tenantId: "t1", amount: 1500000 })
.expect(201)
await request(server)
.post(`/invoices/${created.body.id}/pay`)
.expect(200)
.expect((res) => {
if (res.body.status !== "PAID") {
throw new Error("expected invoice status to be PAID")
}
})
})
it("POST /invoices/:id/pay returns 409 for an already-paid invoice", async () => {
const server = app.getHttpServer()
const created = await request(server)
.post("/invoices")
.send({ tenantId: "t1", amount: 500000 })
.expect(201)
await request(server).post(`/invoices/${created.body.id}/pay`).expect(200)
return request(server)
.post(`/invoices/${created.body.id}/pay`)
.expect(409)
})
})
Kesalahan paling umum bukanlah menulis terlalu sedikit pengujian, melainkan menulis assertion yang sama tiga kali di tiga lapisan karena tidak ada yang memutuskan lapisan mana yang bertanggung jawab atas jenis kebenaran yang mana. Gunakan unit test ketika Anda memverifikasi logika bisnis, perhitungan, percabangan kondisional, dan kasus tepi dalam metode sebuah service, karena di situlah Anda mendapatkan umpan balik tercepat dan cakupan termurah untuk setiap kombinasi input. Gunakan integration test ketika Anda memverifikasi bahwa query tertentu mengembalikan baris yang benar, bahwa migrasi menghasilkan skema yang Anda harapkan, atau bahwa pemetaan ORM benar-benar berjalan bolak-balik dengan tepat, karena tiruan sebanyak apa pun tidak bisa menangkap kesalahan SQL sungguhan. Simpan end-to-end test untuk alur yang benar-benar kritis bagi pengguna, login, checkout, webhook pembayaran, di mana Anda butuh keyakinan bahwa seluruh pipeline bekerja bersama, dan terimalah bahwa Anda hanya akan punya segelintir pengujian jenis ini dibanding ratusan unit test. Suite pengujian NestJS yang sehat berbentuk piramida, bukan tembok, dan bentuk piramida itu adalah keputusan desain, bukan kebetulan.