Mert Özen Her Satırda Daha İleri
Backend Node.js Temelleri — #16

Node.js'te Test: node:test, Mocking, Coverage ve supertest ile API Testi

Mert Özen 14 Kas 2026 12 dk 12 görüntülenme
Node.js'te Test: node:test, Mocking, Coverage ve supertest ile API Testi

Node.js'te test için ek paket gerekmez: node:test ile describe/it, t.mock.fn ve t.mock.method, mock timer'lar ve snapshot yerleşik gelir; supertest ise Express app'ini port açmadan, sahte repository'lerle uçtan uca çağırır. Gerçek çıktılarla await'siz assert.rejects'in yeşil tiki ve kırmızı dosyası, 0,2 saniyede 26 test, stok ve BOLA testleri, eksik snapshot hatası ve SQL dosyaları eklenince %80'den %62'ye düşen coverage.

Sürüm notu: Bu yazıdaki örnekler Node.js 26.13.1 (LTS) yerleşik test runner'ı, Express 5.3.0, supertest 7.3.2, jose 6.2.17 ve TypeScript 7.0.2 ile çalıştırılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'da (bir online kitapçı) Emre, bilinmeyen bir ISBN (kitapların uluslararası standart numarası) ile verilen siparişin reddedildiğini test ediyor. Testte bir hata var: Dune katalogda olduğu için sipariş başarılı olacak, yani assertion yanlış. Bir de await unutulmuş:

import { it } from "node:test";
import assert from "node:assert/strict";
import { UnknownBookError } from "#domain/errors";
import { createTestServices, DUNE } from "../../tests/helpers.ts";

// Dune katalogda, placeOrder başarılı olur ve bu assertion yanlış. Test fail eder mi?
it("rejects an unknown ISBN", () => {
    const { orders } = createTestServices();
    assert.rejects(orders.placeOrder("ada", [{ isbn: DUNE, quantity: 1 }]), UnknownBookError);
});

Test yeşil mi kırmızı mı?

node --test labs/16/guess.test.ts
✔ rejects an unknown ISBN (0.567542ms)
ℹ Error: Test "rejects an unknown ISBN" at labs/16/guess.test.ts:7:1 generated asynchronous activity after the test ended. This activity created the error "AssertionError [ERR_ASSERTION]: Missing expected rejection (UnknownBookError)." and would have caused the test to fail, but instead triggered an unhandledRejection event.
✖ labs/16/guess.test.ts (115.454458ms)
ℹ tests 2
ℹ pass 1
ℹ fail 1

İkisi birden. Test fonksiyonu senkron döndüğü için test "geçti" olarak işaretlendi; assert.rejects'in döndürdüğü promise ancak sonra reddedildi. Node'un test runner'ı bu geç gelen hatayı yakalayıp dosyayı fail etti, bu yüzden CI kırmızı kalır. Ama satırda yeşil tik var ve dikkatsiz okuyan biri testin geçtiğini sanır. xUnit'te async void test yazmanın karşılığı bu. Bu yazıda API'nin şimdiye kadar elle denediğimiz davranışlarını kalıcı testlere çeviriyoruz: node:test, mock'lar, mock timer'lar, supertest ile port açmadan API testi, snapshot ve coverage.

node:test ile İlk Testler

Node 20'den beri yerleşik test runner'ı stable. Paket kurmaya gerek yok; node --test verilen dosyaları bulur, her dosyayı ayrı bir process'te çalıştırır ve sonuçları toplar. Type stripping sayesinde testler de .ts. xUnit bilen biri için eşleme:

xUnitnode:test
[Fact]it(...) ya da test(...)
[Theory] + [InlineData]Bir tablo üzerinde döngüyle it üretmek
Test class'ıdescribe(...)
Constructor / IDisposablebeforeEach / afterEach
IClassFixturebefore / after
Assert.Equal, Assert.ThrowsAsyncassert.equal, await assert.rejects
Moq, NSubstitutet.mock.fn, t.mock.method

12'deki para fonksiyonları için bir tablo testi:

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { formatAmount, InvalidAmountError, parseMinorUnits } from "#domain/money";

describe("parseMinorUnits", () => {
    // Bir case tablosu: xUnit'teki [Theory] + [InlineData] karşılığı
    const cases: [text: string, expected: number][] = [
        ["349,90", 34990],
        ["349.9", 34990],
        ["349", 34900],
        ["19,99", 1999],
        [" 0,05 ", 5],
    ];
    for (const [text, expected] of cases) {
        it(`parses "${text}" as ${expected}`, () => {
            assert.equal(parseMinorUnits(text), expected);
        });
    }

    for (const text of ["", "abc", "1.234,50", "-5", "1,999"]) {
        it(`rejects "${text}"`, () => {
            assert.throws(() => parseMinorUnits(text), InvalidAmountError);
        });
    }
});

node:assert/strict'i kullan: assert.equal orada === ile karşılaştırır, gevşek modun "1" == 1 sürprizini yaşamazsın. package.json'a üç script ekledik: test, test:watch ve test:coverage. Glob'u tırnak içinde yazdık ki shell değil Node genişletsin; Windows'ta da aynı davranır.

npm test
▶ POST /api/orders
  ✔ 401 without a token (12.424709ms)
  ✔ 201 with Location, the customer taken from the token (3.377375ms)
  ✔ 409 when the stock is not enough (1.342458ms)
  ✔ 400 with every validation error in one Problem Details body (2.038917ms)
✔ POST /api/orders (19.664542ms)
▶ GET /api/orders/:id
  ✔ 404 for someone else's order, 200 for the owner and for store staff (3.6645ms)
✔ GET /api/orders/:id (3.761459ms)
...
ℹ tests 26
ℹ suites 8
ℹ pass 26
ℹ fail 0
ℹ duration_ms 204.986042

Altı dosyada 26 test 0,2 saniyede bitiyor; hiçbiri SQL Server'a, ağa ya da gerçek bir identity provider'a gitmiyor.

Sahte Repository'ler ve Mocking

12'de repository'leri interface'lerin arkasına koyup service'lere constructor'dan vermiştik; testlerde bunun karşılığını alıyoruz. SQL Server yerine bellekteki implementasyonlar, stok seviyesi her testte ayrı:

// Sahte repository'ler: bellekteki implementasyonlar, stok seviyesi testte seçilir
export function createTestServices(stock: Record<string, number> = { [DUNE]: 5, [EMMA]: 5 }) {
    const bookRepository = new InMemoryBookRepository(catalog);
    const orderRepository = new InMemoryOrderRepository(catalog, new Map(Object.entries(stock)));
    return {
        bookRepository,
        orderRepository,
        books: new BookService(bookRepository),
        orders: new OrderService(bookRepository, orderRepository),
    };
}

Plandaki stok kontrolü testi: Dune'dan yalnız bir kopya varken iki kopya isteyen sipariş reddedilmeli, orderPlaced event'i yayınlanmamalı ve stok değişmemeli.

it("rejects an order that does not fit the stock and announces nothing", async (t) => {
    const { orders, orderRepository } = createTestServices({ [DUNE]: 1, [EMMA]: 5 });
    const listener = t.mock.fn();
    orders.on("orderPlaced", listener);
    // Repository method'unu izle; gerçek implementasyon yine çalışır
    const add = t.mock.method(orderRepository, "add");

    await assert.rejects(orders.placeOrder("ada", [{ isbn: EMMA, quantity: 1 }, { isbn: DUNE, quantity: 2 }]), (err: unknown) => {
        assert.ok(err instanceof OutOfStockError);
        assert.deepEqual(err.isbns, [DUNE]);
        return true;
    });
    assert.equal(add.mock.callCount(), 1);
    assert.equal(listener.mock.callCount(), 0);
    // Stoktan hiçbir şey düşülmedi: son kopya için gelen bir sonraki sipariş yine başarılı olur
    await orders.placeOrder("alan", [{ isbn: DUNE, quantity: 1 }]);
});

t.mock.fn() çağrıları kaydeden bir fonksiyon üretiyor; t.mock.method() var olan bir method'u sarıp çağrıları sayıyor, istersen implementasyonu da değiştiriyor. t.mock ile oluşturulan her mock test bitince kendiliğinden geri alınıyor; elle restore etmek gerekmiyor. Moq'taki Verify(x => x.Add(...), Times.Once)'ın karşılığı add.mock.callCount(). 15'teki BOLA (başkasının kaydına ID değiştirerek erişebilme açığı) kuralı da service'te olduğu için HTTP'siz test ediliyor: getForUser Alan için OrderNotFoundError fırlatmalı, Grace (StoreStaff) için siparişi dönmeli.

Bir not: bellekteki repository, SQL Server'daki transaction'ın ve koşullu UPDATE'in yerini tutmaz. Bu testler service'in kuralını doğruluyor; iki eşzamanlı siparişin son kopya için yarışı ancak gerçek veritabanıyla test edilebilir (aşağıda).

Mock Timer'lar

14'teki readiness kontrolü, SQL Server bir saniye içinde cevap vermezse sorguyu iptal ediyor. Bu davranışı test etmek için bir saniye beklemek istemiyoruz; mock timer'lar setTimeout'u sahte bir saatle değiştiriyor:

it("reports not-ready when SQL Server does not answer within a second", async (t) => {
    // Sahte timer'lar: 1 saniyelik timeout bir anda geçer
    t.mock.timers.enable({ apis: ["setTimeout"] });
    // İptal edilene kadar asılı kalan sorgusuyla sahte bir pool, erişilemeyen bir sunucu gibi
    let cancel = (): void => {};
    const request = {
        query: () => new Promise((resolve, reject) => (cancel = () => reject(new Error("Canceled.")))),
        cancel: () => cancel(),
    };
    const pool = { request: () => request } as unknown as sql.ConnectionPool;

    const check = createReadinessCheck(pool, { shuttingDown: false });
    const pending = check();
    t.mock.timers.tick(1_000);

    assert.deepEqual(await pending, { status: "not-ready", checks: { sql: "Canceled." } });
});

tick(1_000) sahte saati bir saniye ilerletiyor; timeout callback'i hemen çalışıyor ve test 3 ms'de bitiyor. Node dokümanına göre mock'lanabilen API'ler setTimeout, setInterval, setImmediate ve Date; setTimeout'u mock'lamak node:timers/promises'teki karşılığını da kapsıyor. .NET 8'den beri gelen TimeProvider ve FakeTimeProvider aynı işi görüyor; fark, Node'da zamanı constructor'dan vermek yerine global timer'ların değiştirilmesi.

supertest ile API Testleri

ASP.NET Core'da WebApplicationFactory, uygulamayı bellekte ayağa kaldırıp gerçek bir HttpClient ile çağırmanı sağlar. 11'den beri createApp'i listen'dan ayrı tutmamızın sebebi buydu: supertest, Express app'ini alıp request'leri ona doğrudan veriyor. Test yardımcısı aynı sahte service'leri ve sahte bir token doğrulayıcı kullanıyor:

// Sahte bir token doğrulayıcı: token'lar yalnız isim, testler key ya da imzayla uğraşmaz
const PRINCIPALS: Record<string, Principal> = {
    "token-ada": { userId: "ada", roles: ["Customer"] },
    "token-alan": { userId: "alan", roles: ["Customer"] },
    "token-grace": { userId: "grace", roles: ["StoreStaff"] },
};
// supertest Express app'ini bellekte çağırır: port yok, SQL Server yok (WebApplicationFactory karşılığı)
describe("POST /api/orders", () => {
    it("401 without a token", async () => {
        const { app } = createTestApp();
        const res = await request(app).post("/api/orders").send({ lines: [{ isbn: DUNE, quantity: 1 }] });
        assert.equal(res.status, 401);
        assert.equal(res.headers["www-authenticate"], "Bearer");
    });

    it("201 with Location, the customer taken from the token", async () => {
        const { app } = createTestApp();
        const res = await request(app)
            .post("/api/orders")
            .set("authorization", "Bearer token-ada")
            .send({ lines: [{ isbn: DUNE, quantity: 3 }, { isbn: EMMA, quantity: 1 }] })
            .expect("content-type", /json/)
            .expect(201);
        assert.equal(res.headers.location, `/api/orders/${res.body.id}`);
        assert.equal(res.body.customerId, "ada");
        assert.deepEqual(res.body.total, { amount: 1294.7, currency: "TRY" });
    });
});

Bu testler gerçek middleware zincirinden geçiyor: request id, helmet, CORS (tarayıcının başka origin'lere istek izin kuralları), rate limit, JSON parser, authentication, validation, hata middleware'i ve Problem Details. Yalnız iki şey sahte: repository'ler ve token doğrulama. Rate limit testi limiti 2'ye indirip üçüncü isteğin 429 aldığını kontrol ediyor; her test kendi createApp'ini kurduğu için sayaçlar testler arasında paylaşılmıyor.

Token doğrulamanın kendisi sahte kalmasın diye createJwtVerifier'ı ayrı bir dosyada gerçek jose ile test ediyoruz. before, testin başında bir RSA (public/private key çiftiyle çalışan asimetrik algoritma) key çifti üretip JWKS'i (public key'lerin yayımlandığı JSON belgesi) 0 numaralı portta (işletim sistemi boş bir port seçer) yayımlıyor, after server'ı kapatıyor:

// before/after: xUnit'teki class fixture'ın karşılığı
before(async () => {
    const pair = await generateKeyPair("RS256");
    privateKey = pair.privateKey;
    const jwks = JSON.stringify({ keys: [{ ...(await exportJWK(pair.publicKey)), kid: "k1", alg: "RS256" }] });
    server = http.createServer((req, res) => res.end(jwks)).listen(0, "127.0.0.1");
    await new Promise((resolve) => server.once("listening", resolve));
    verify = createJwtVerifier({ issuer, audience: "bookstore-api", jwksUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}/jwks` });
});

after(() => {
    server.close();
});

Key her koşuda yeniden üretiliyor ve hiçbir yere yazılmıyor; repo'da test için bile bir private key durmuyor. Bu dosya 15'teki saldırıların üçünü (yanlış audience, süresi dolmuş token, alg: "none") kalıcı hâle getiriyor.

Snapshot Testleri

Validation hatasının Problem Details cevabı uzun bir JSON; her alanı tek tek assert etmek yerine snapshot ile karşılaştırıyoruz:

// İlk koşuda --test-update-snapshots dosyayı yazar; sonraki koşular onunla karşılaştırır
t.assert.snapshot(res.body);

Snapshot dosyası yokken ilk koşu fail ediyor, dosyayı kendiliğinden üretmiyor:

Error [ERR_INVALID_STATE]: Invalid state: Cannot read snapshot file '/home/user/bookstore-api/tests/api/orders.api.test.ts.snapshot.' Missing snapshots can be generated by rerunning the command with the --test-update-snapshots flag.

npm run test:update-snapshots dosyayı yazıyor; dosya repo'ya commit'leniyor ve kod incelemesinde değişiklik olarak görünüyor:

exports[`POST /api/orders > 400 with every validation error in one Problem Details body 1`] = `
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "instance": "/api/orders",
  "traceId": "req-16-snapshot",
  "errors": {
    "lines[0].isbn": [
      "must be 13 digits starting with 978 or 979"
    ],
    "lines[0].quantity": [
      "Too small: expected number to be >=1"
    ],
    "$": [
      "Unrecognized key: \\"customerId\\""
    ]
  }
}
`;

Snapshot, Angular istemcisinin bağımlı olduğu sözleşmeyi koruyor: biri hata formatını değiştirirse test kırılıyor ve değişikliğin bilinçli olup olmadığına karar vermek gerekiyor. Request id'yi testte sabit verdik; aksi hâlde her koşuda farklı bir traceId snapshot'ı bozardı. Tarih, rastgele id gibi değişken alanları snapshot'a koyma ya da önce sabitle. Snapshot testi Node 23.4'ten beri bayrak gerektirmiyor.

Coverage

Coverage Node 26'da hâlâ experimental; --experimental-test-coverage bayrağıyla açılıyor. test:coverage script'i yalnız src'yi ölçüyor ve satır kapsamı %70'in altına düşerse fail ediyor:

npm run test:coverage
ℹ file                   | line % | branch % | funcs % | uncovered lines
ℹ  middleware            |        |          |         |
ℹ   auth.ts              |  88.89 |    90.91 |   75.00 | 31-34
ℹ   errorHandler.ts      |  69.49 |    50.00 |   50.00 | 20 27-29 31-34 47 49-50 52-58
ℹ  services              |        |          |         |
ℹ   orderExport.ts       |  41.89 |   100.00 |    0.00 | 23-37 40-43 46-49 53-70 73-74
ℹ   orderService.ts      |  93.33 |    94.44 |   81.82 | 31 43-44 63-64
ℹ all files              |  80.41 |    89.47 |   71.32 |

Toplam %80, eşiği geçiyor. Ama bu sayı bir şeyi saklıyor: varsayılan olarak yalnız testlerin yüklediği dosyalar raporlanıyor ve SQL repository'leri hiç yüklenmediği için listede yok. --test-coverage-include-all eklenince:

ℹ Error: 62.32% line coverage does not meet threshold of 70%.
ℹ   pool.ts                |   0.00 |   100.00 |  100.00 | 1-15
ℹ   sqlBookRepository.ts   |  10.47 |   100.00 |  100.00 | 1 4-11 19-86
ℹ   sqlOrderRepository.ts  |  11.41 |   100.00 |  100.00 | 1-2 5 7 10 18 24-149
ℹ  server.ts               |   0.00 |   100.00 |  100.00 | 1-44
ℹ all files                |  62.32 |    89.47 |   71.32 |

Toplam %62'ye düştü ve script exit code 1 ile bitti. Doğru tablo bu: 13'te yazdığımız transaction, stok kontrolü ve export'un hiçbiri bu testlerde çalışmıyor. Coverage bir hedef değil, nerenin test edilmediğini gösteren bir harita; eşik de yalnız gerilemeyi yakalamak için var.

Gerçek SQL Server, Watch Modu ve Diğer Runner'lar

SQL repository'lerini test etmenin doğru yolu gerçek bir SQL Server. Testcontainers'ın Node paketi testin başında bir SQL Server container'ı açıp sonunda kapatabiliyor; testler openPool'u o container'a bağlar, 13'teki db-setup şemayı kurar ve son kopya yarışı gibi senaryolar gerçek kilitlerle test edilir. Bunu bu yazıda kurmadım; yaklaşımın tamamı ve .NET karşılığı Integration ve E2E (uçtan uca) Test serisinde. Yerel geliştirmede npm run test:watch değişen dosyalarda testleri yeniden çalıştırıyor; src/domain/money.ts'e dokununca:

Restarted at 11/14/2026, 11:10:20 PM
▶ parseMinorUnits
✔ parseMinorUnits (1.666ms)

Watch modu dokümanda hâlâ experimental. Vitest ya da Jest'e ne zaman gidilir? Frontend tarafında (Angular, React) zaten Vitest kullanıyorsan, aynı araçla kalmak mantıklı; zengin mock ekosistemi, paralel izolasyon seçenekleri ve editör entegrasyonları orada daha olgun. Saf bir Node backend'i için node:test yeterli ve bağımlılık getirmiyor; Vitest ve Jest'i bu seri için çalıştırmadım.

Üretimde Dikkat

CI'da npm test her pull request'te çalışsın ve kırmızıyken merge edilmesin. 0,2 saniyelik bir suite'i atlamak için hiçbir sebep yok.

Coverage eşiğini mevcut değerin biraz altına koy ve --test-coverage-include-all ile gerçek tabloya bak. Eşiği yükseltmek için test yazılır, testleri saymak için değil.

Snapshot dosyalarını commit'le ve değişikliklerini kod incelemesinde oku. "Snapshot'ları güncelle" komutunu körlemesine çalıştırmak testi anlamsız kılar.

Secret'ları testlerde de üret. Test key'leri, token'ları ve şifreleri koşu sırasında üretilsin; repo'da gerçek görünen bir key, gerçek olmasa bile tarayıcıların ve insanların alarmını tetikler.

Sık Düşülen Tuzaklar

assert.rejects'i await'siz çağırmak. Önce Tahmin Et'teki gibi: yeşil tik, kırmızı dosya.

Global state'i testler arasında paylaşmak. Module seviyesinde tek bir app ya da repository, rate limit sayaçlarını ve stok seviyelerini testten teste taşır; her test kendi createApp'ini kursun.

Gevşek assert kullanmak. node:assert'in eski equal'ı == ile karşılaştırır; node:assert/strict kullan.

Testte log gürültüsü. Test yardımcısı logger.level = "silent" diyor; yoksa her istek bir JSON satırı basar ve gerçek hata satırları kaybolur.

Bellekteki sahte repository'ye güvenip SQL davranışını test edilmiş saymak. Transaction, kilit ve tip dönüşümleri ancak gerçek SQL Server'la test edilir.

Küçük Bir Deneme

★☆☆ labs/16/guess.test.ts'teki eksik await'i ekle ve testi düzelt (gerçekten bilinmeyen bir ISBN kullan). Çıktı nasıl değişiyor?

★★☆ GET /api/orders/export için bir supertest testi yaz: Grace 200 ve text/csv almalı, Ada 403. Bellekteki repository'ye iki sipariş verip CSV'nin (virgülle ayrılmış değerler) başlık ve iki satırdan oluştuğunu kontrol et.

★★★ 13'teki son kopya yarışını bir integration testine çevir: DB_CONNECTION_STRING verilmişse çalışan, verilmemişse t.skip() ile atlanan bir test; aynı anda iki sipariş gönder ve birinin 201, diğerinin 409 aldığını doğrula.

Seri boyunca kurduğumuz API artık test ediliyor. Son yazıda geriye ve ileriye bakıyoruz: Node.js 22'den 26'ya gelen değişiklikler (require(esm), type stripping, Permission Model, Temporal) ve yeni release takvimiyle bir sonraki sürüme geçerken nelere dikkat edileceği.