Pengujian

Menulis pengujian dengan Vitest dan mengetik utilitas pengujian.

Daftar isi

Pengujian otomatis membandingkan perilaku program dengan harapan yang ditulis lebih dahulu, sehingga perubahan yang merusak perilaku lama tertangkap sebelum program dipakai. Topik ini membahas penulisan pengujian dengan Vitest atas sebuah fungsi murni, pembuktian perilakunya melalui pemeriksa mandiri yang berjalan dengan node, serta pengetikan utilitas pengujian dengan pasangan tipe Expect dan Equal. Data contoh pengujian disebut fixture dan diketik dengan kombinasi as const satisfies agar kesalahan bentuknya tertangkap compiler. Setelah membacanya, sebuah fungsi dapat diuji dengan Vitest dan utilitas pengujian beserta fixture-nya dapat diketik dengan ketat.

Fungsi murni yang diuji#

Fungsi yang paling mudah diuji adalah fungsi murni, yaitu fungsi yang keluarannya hanya ditentukan masukannya dan tidak menimbulkan efek samping. Fungsi bagiDaftar berikut membagi satu daftar menjadi potongan berukuran tetap, misalnya untuk menyusun jadwal piket dua hari per kelompok. Fungsi ini diletakkan sebagai modul tersendiri agar berkas pengujian dan pemeriksa lain dapat mengimpornya.

ts bagi-daftar.ts
export function bagiDaftar<T>(nilai: readonly T[], ukuran: number): T[][] {
  if (ukuran < 1) {
    throw new RangeError("ukuran potongan minimal 1");
  }
  const hasil: T[][] = [];
  for (let i = 0; i < nilai.length; i += ukuran) {
    hasil.push(nilai.slice(i, i + ukuran));
  }
  return hasil;
}

Parameter readonly T[] membuat fungsi ini menerima array biasa maupun tuple readonly yang berasal dari as const, sedangkan parameter tipe T menjaga agar tipe elemen pada hasil mengikuti masukannya. Ukuran di bawah satu ditolak dengan RangeError karena potongan berukuran nol tidak bermakna. Perilaku pada kasus tepi juga terdefinisi dengan rapi, yaitu daftar kosong yang menghasilkan potongan kosong, serta ukuran yang melebihi panjang daftar sehingga seluruh isi dikembalikan sebagai satu potongan. Kombinasi sifat tersebut membuat perilakunya dapat dinyatakan dalam beberapa kasus uji pendek tanpa menyiapkan lingkungan apa pun.

Berkas pengujian dengan Vitest#

Vitest menjalankan pengujian dari berkas berakhiran .test.ts, dan utilitas describe, it, serta expect diimpor dari modul "vitest" yang hanya diselesaikan oleh runner-nya, yaitu program yang menjalankan pengujian. Konfigurasi minimal berikut membatasi berkas mana yang dianggap berkas pengujian.

ts vitest.config.ts
// tidak-dijalankan
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["**/*.test.ts"],
  },
});

Berkas pengujian berikut menyatakan empat perilaku bagiDaftar di dalam satu kelompok describe, lengkap dari kasus normal sampai kasus yang melempar error.

ts bagi-daftar.test.ts
// tidak-dijalankan
import { describe, expect, it } from "vitest";
import { bagiDaftar } from "./bagi-daftar.ts";

describe("bagiDaftar", () => {
  it("membagi daftar menjadi potongan berukuran sama", () => {
    expect(bagiDaftar([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]);
  });

  it("menyimpan sisa elemen pada potongan terakhir", () => {
    expect(bagiDaftar([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
  });

  it("mengembalikan potongan kosong untuk daftar kosong", () => {
    expect(bagiDaftar([], 3)).toEqual([]);
  });

  it("melempar RangeError untuk ukuran di bawah satu", () => {
    expect(() => bagiDaftar([1, 2, 3], 0)).toThrow(RangeError);
  });
});
bash
# tidak-dijalankan
npm install --save-dev vitest
npx vitest run

Perintah vitest run menjalankan seluruh pengujian sekali lalu berhenti, sehingga cocok untuk CI, sedangkan tanpa argumen run Vitest masuk mode watch dan menjalankan ulang pengujian pada setiap perubahan berkas. Pada keluarannya, runner menampilkan satu baris per pengujian beserta status lulusnya, lalu ringkasan jumlah yang lulus dan gagal pada akhirnya. Fungsi pembanding (matcher) toEqual membandingkan struktur nilai, toThrow memeriksa error yang dilempar, dan expect membungkus nilai aktual yang diperiksa oleh keduanya.

Kedua berkas di atas tidak dieksekusi pada lingkungan kerja topik ini karena vitest tidak terpasang di sana, sehingga keduanya diberi tanda tanpa eksekusi. Strukturnya mengikuti dokumentasi Vitest, dan perilaku fungsi yang dinyatakan kedua berkas itu tetap dapat dibuktikan dengan pemeriksa mandiri pada bagian berikut.

Pemeriksa mandiri dengan node#

Perilaku fungsi yang diuji tidak harus menunggu runner pengujian. Berkas uji-mandiri.ts berikut memanggil bagiDaftar, membandingkan hasilnya dengan harapan melalui JSON.stringify, dan mencetak status tiap pemeriksaan beserta jumlah totalnya di akhir. Berkas ini mengimpor modul lokal, sehingga diberi tanda tanpa eksekusi dan dijalankan lewat perintah di bawahnya.

ts uji-mandiri.ts
// tidak-dijalankan
import { bagiDaftar } from "./bagi-daftar.ts";

let lulus = 0;
let gagal = 0;

function periksaSama(nama: string, hasil: unknown, harapan: unknown): void {
  if (JSON.stringify(hasil) === JSON.stringify(harapan)) {
    lulus += 1;
    console.log(`lulus: ${nama}`);
  } else {
    gagal += 1;
    console.log(`gagal: ${nama}`);
    console.log(`  harapan: ${JSON.stringify(harapan)}`);
    console.log(`  hasil:   ${JSON.stringify(hasil)}`);
  }
}

function periksaMelempar(nama: string, jalankan: () => void): void {
  try {
    jalankan();
    gagal += 1;
    console.log(`gagal: ${nama}`);
  } catch (error) {
    if (error instanceof RangeError) {
      lulus += 1;
      console.log(`lulus: ${nama}`);
    } else {
      gagal += 1;
      console.log(`gagal: ${nama} karena error yang salah`);
    }
  }
}

periksaSama("lima elemen berukuran dua", bagiDaftar([1, 2, 3, 4, 5], 2), [[1, 2], [3, 4], [5]]);
periksaSama("daftar kosong menghasilkan potongan kosong", bagiDaftar([], 3), []);
periksaMelempar("ukuran nol melempar RangeError", () => bagiDaftar([1, 2, 3], 0));

console.log(`total: ${lulus} lulus, ${gagal} gagal`);
bash
# menjalankan pemeriksa mandiri langsung dengan node
node --no-warnings uji-mandiri.ts
text Keluaran
lulus: lima elemen berukuran dua
lulus: daftar kosong menghasilkan potongan kosong
lulus: ukuran nol melempar RangeError
total: 3 lulus, 0 gagal

Prinsipnya sama dengan pengujian Vitest, yaitu membandingkan hasil dengan harapan lalu melaporkan selisihnya, tetapi pemeriksa ini berdiri sendiri tanpa pustaka apa pun. Bila satu pemeriksaan gagal, baris harapan dan baris hasil dicetak berurutan agar selisihnya terbaca. Keluaran di atas membuktikan tiga perilaku yang sama dengan yang dinyatakan pada berkas pengujian Vitest.

Pemeriksa mandiri berguna ketika pustaka pengujian belum terpasang, misalnya pada latihan atau berkas contoh yang harus berjalan dengan node saja. Pemeriksa ini tetap menegakkan disiplin yang sama, yaitu harapan yang ditulis lebih dahulu dan hasil yang diperiksa sesudahnya. Keterbatasannya juga nyata, karena tidak ada pemilihan berkas, laporan berlapis, maupun matcher siap pakai seperti pada Vitest.

Mengetik utilitas pengujian dengan Expect dan Equal#

Utilitas pengujian yang baik ikut diperiksa tipenya. Pasangan tipe Expect dan Equal menjadi pola baku untuk menguji asumsi tipe pada waktu kompilasi. Tipe Expect<T extends true> = T hanya menerima true, sedangkan Equal<X, Y> membandingkan dua tipe secara simetris melalui dua bentuk fungsi generik yang identik. Berkas berikut memastikan inferensi T pada bagiDaftar benar-benar menghasilkan string[][].

ts pemeriksaan-tipe.ts
// tidak-dijalankan
import { bagiDaftar } from "./bagi-daftar.ts";

type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;

const potongan = bagiDaftar(["a", "b", "c", "d"], 2);

type Kasus1 = Expect<Equal<typeof potongan, string[][]>>;
type Kasus2 = Expect<Equal<ReturnType<typeof bagiDaftar<string>>, string[][]>>;
bash
# memeriksa asumsi tipe dengan tsc
tsc --noEmit --pretty false --allowImportingTsExtensions pemeriksaan-tipe.ts

Pemeriksaan berakhir tanpa satu pun pesan, yang berarti kedua asumsi benar. Hasil pemanggilan bagiDaftar(["a", "b", "c", "d"], 2) bertipe string[][], dan kembalian bagiDaftar<string> juga bertipe string[][]. Asumsi yang meleset ditolak compiler dengan pesan yang menunjuk baris deklarasinya, sebagaimana berkas berikut yang mengira hasilnya number[][].

ts pemeriksaan-tipe-salah.ts
// tidak-dijalankan
import { bagiDaftar } from "./bagi-daftar.ts";

type Expect<T extends true> = T;
type Equal<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends (<T>() => T extends Y ? 1 : 2) ? true : false;

const potongan = bagiDaftar(["a", "b", "c", "d"], 2);

type Kasus1 = Expect<Equal<typeof potongan, number[][]>>;
bash
# memeriksa asumsi tipe yang keliru dengan tsc
tsc --noEmit --pretty false --allowImportingTsExtensions pemeriksaan-tipe-salah.ts
text Keluaran
pemeriksaan-tipe-salah.ts(10,22): error TS2344: Type 'false' does not satisfy the constraint 'true'.

Pesan TS2344 muncul karena Equal<typeof potongan, number[][]> bernilai false, sehingga Expect menerima tipe false yang melanggar batasan true-nya. Dengan pola ini, kontrak generik sebuah pustaka dijaga pada level tipe dan setiap perubahan bentuk yang melanggarnya ditolak sebelum kode dijalankan. Seluruh deklarasi tipe di atas tidak ikut tereksekusi, sehingga biayanya nol bagi program yang berjalan. Pemeriksaan semacam ini lazim diletakkan di dalam berkas pengujian yang sama dengan pengujian perilakunya.

Fixture dengan as const satisfies#

Fixture adalah data contoh yang dipakai pengujian, dan pengetikannya menentukan seberapa awal kesalahan data tertangkap. Kombinasi as const satisfies menjaga presisi literal dari as const sekaligus memeriksa bentuk data terhadap tipe acuan lewat satisfies. Anotasi biasa seperti : Siswa[] memang memeriksa bentuknya, tetapi melebarkan tipe menjadi Siswa sehingga presisi literalnya hilang, sedangkan satisfies memeriksa tanpa melebarkan tipe. Berkas berikut menyusun daftar siswa sebagai fixture, lalu membaginya dengan bagiDaftar.

ts fixture.ts
// tidak-dijalankan
import { bagiDaftar } from "./bagi-daftar.ts";

interface Siswa {
  nama: string;
  nilai: number;
}

const daftarSiswa = [
  { nama: "Ali", nilai: 80 },
  { nama: "Budi", nilai: 90 },
  { nama: "Citra", nilai: 100 },
  { nama: "Dewi", nilai: 90 },
  { nama: "Eka", nilai: 80 },
] as const satisfies readonly Siswa[];

console.log(bagiDaftar(daftarSiswa, 2));
bash
# menjalankan berkas fixture dengan node
node --no-warnings fixture.ts
text Keluaran
[
  [ { nama: 'Ali', nilai: 80 }, { nama: 'Budi', nilai: 90 } ],
  [ { nama: 'Citra', nilai: 100 }, { nama: 'Dewi', nilai: 90 } ],
  [ { nama: 'Eka', nilai: 80 } ]
]

Parameter readonly T[] menerima tuple readonly hasil as const tanpa penyalinan, dan daftar lima siswa terbagi menjadi potongan berukuran dua. Kesalahan bentuk pada fixture ditolak oleh compiler pada waktu kompilasi. Berkas berikut menuliskan nilai sebagai string untuk field nilai yang tipe acuannya number.

ts fixture-salah.ts
// tidak-dijalankan
import { bagiDaftar } from "./bagi-daftar.ts";

interface Siswa {
  nama: string;
  nilai: number;
}

const daftarSiswa = [
  { nama: "Ali", nilai: 80 },
  { nama: "Budi", nilai: "sembilan puluh" },
] as const satisfies readonly Siswa[];

console.log(bagiDaftar(daftarSiswa, 2));
bash
# memeriksa fixture dengan bentuk data yang keliru
tsc --noEmit --pretty false --allowImportingTsExtensions fixture-salah.ts
text Keluaran
fixture-salah.ts(11,19): error TS2322: Type 'string' is not assignable to type 'number'.

Pesan TS2322 menunjuk nilai "sembilan puluh" pada baris 11 karena bertentangan dengan field nilai: number pada tipe acuannya, sehingga fixture yang keliru tidak pernah sampai ke pengujian. Tanpa satisfies, objek literal tanpa anotasi membentuk tipenya sendiri dan kesalahan semacam ini lolos tanpa pesan. Kombinasi as const satisfies menjadi cara ringkas untuk mengetik fixture secara ketat sekaligus tetap presisi.

Kesalahan umum#

Kesalahan berikut sering muncul saat menulis pengujian TypeScript.

  • Mengimpor expect dari "vitest" pada berkas yang dijalankan node secara langsung. Modul itu hanya diselesaikan runner-nya, sehingga pemeriksa mandiri perlu menuliskan pemeriksaannya sendiri.
  • Memakai toBe untuk membandingkan objek atau array. Matcher toBe membandingkan identitas referensi, sedangkan toEqual membandingkan strukturnya.
  • Meneruskan tuple as const ke parameter T[] yang dapat diubah. Array readonly ditolak oleh parameter tersebut, dan perbaikannya berupa parameter readonly T[] atau penyalinan masukan.
  • Mengharapkan vitest run sekaligus memeriksa tipe. Vitest menjalankan pengujian melalui transformasi tanpa memeriksa tipe, sehingga tsc --noEmit tetap dijalankan sebagai gerbang terpisah.
  • Mengandalkan JSON.stringify sebagai pembanding untuk data yang urutan propertinya tidak terkendali. Pembanding berbasis teks itu peka terhadap urutan penulisan properti, sedangkan toEqual membandingkan struktur tanpa bergantung pada urutannya.

Ringkasan#

  • Vitest menjalankan berkas .test.ts dengan describe, it, dan expect, sedangkan vitest run menjalankan seluruh pengujian sekali lalu berhenti.
  • Perilaku fungsi murni dapat dibuktikan tanpa pustaka melalui pemeriksa mandiri yang membandingkan hasil dengan harapan dan mencetak statusnya.
  • Pasangan tipe Expect dan Equal menguji asumsi tipe pada waktu kompilasi, dan asumsi yang keliru ditolak dengan pesan TS2344.
  • Fixture diketik dengan as const satisfies agar presisi literal terjaga sekaligus bentuk datanya diperiksa terhadap tipe acuan.

Referensi#