Strategi test CLI TypeScript untuk downloader magnet yang stabil tidak dimulai dari menambah lebih banyak end-to-end test, melainkan dari memisahkan logika inti, I/O, dan integrasi proses terminal. Untuk aplikasi seperti pencari dan downloader magnet berbasis CLI, sumber kegagalan test biasanya bukan logika bisnis, tetapi jaringan publik, timeout nyata, output terminal yang rapuh, dan state file yang tertinggal.
Jika tujuan Anda adalah test suite yang cepat, stabil, dan mampu menangkap regression, gunakan test pyramid: banyak unit test untuk parser argumen dan formatter, integration test untuk wiring antarkomponen melalui dependency injection, lalu end-to-end test terbatas untuk memverifikasi perilaku CLI secara realistis tanpa bergantung pada internet atau proses eksternal yang tidak terkontrol.
Mengapa CLI downloader magnet sering punya test yang flaky
Aplikasi CLI cenderung terlihat sederhana dari luar, tetapi sering menyentuh banyak boundary sekaligus: stdin/stdout, filesystem, HTTP, child process, environment variable, dan exit code. Pada kasus downloader magnet, test menjadi rapuh ketika semua boundary itu disentuh langsung dalam satu jalur eksekusi.
Beberapa penyebab paling umum:
- Jaringan publik: hasil pencarian berubah, domain tidak stabil, rate limit, DNS lambat, atau struktur HTML/API bergeser.
- Timeout real: test menunggu durasi nyata sehingga lambat dan kadang gagal di CI yang lebih sibuk.
- Race condition: proses menulis file, update progress, atau spawn child process belum selesai saat assertion dijalankan.
- Snapshot terminal rapuh: warna ANSI, lebar terminal, urutan whitespace, dan karakter progress bar membuat snapshot sering berubah walau perilaku benar.
- State file sisa: cache, file unduhan sementara, atau direktori kerja yang tidak dibersihkan menyebabkan test saling memengaruhi.
Masalah ini bukan alasan untuk mengurangi testing, tetapi sinyal bahwa arsitektur test perlu dipisah dengan lebih tegas.
Rancang test pyramid untuk CLI TypeScript
Pendekatan yang paling aman adalah menjaga lapisan test tetap jelas. Unit test harus menguji logika murni tanpa I/O nyata. Integration test menguji kolaborasi antarlapisan dengan dependency yang dapat diganti. End-to-end test hanya memverifikasi jalur kritis dan dibuat sekecil mungkin.
Lapisan 1: unit test untuk logika murni
Di level ini, fokus pada fungsi yang deterministik dan tidak menyentuh jaringan atau file sungguhan.
- Parser argumen CLI
- Validasi input pengguna
- Formatter output tabel/teks
- Normalisasi magnet link
- Mapper error ke exit code
Contohnya, parser argumen sebaiknya tidak langsung membaca process.argv di dalam logika inti. Ambil array string sebagai input agar mudah diuji.
type CliOptions = {
query: string;
limit: number;
output?: string;
json: boolean;
};
export function parseArgs(argv: string[]): CliOptions {
let query = "";
let limit = 10;
let output: string | undefined;
let json = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--limit") limit = Number(argv[++i]);
else if (arg === "--output") output = argv[++i];
else if (arg === "--json") json = true;
else if (!arg.startsWith("-")) query = arg;
}
if (!query) throw new Error("Query wajib diisi");
if (!Number.isInteger(limit) || limit <= 0) {
throw new Error("--limit harus bilangan bulat positif");
}
return { query, limit, output, json };
}Unit test untuk fungsi seperti ini cepat, presisi, dan hampir tidak pernah flaky.
import { describe, it, expect } from "vitest";
import { parseArgs } from "../src/cli/parseArgs";
describe("parseArgs", () => {
it("mem-parse query dan limit", () => {
expect(parseArgs(["ubuntu", "--limit", "5"]))
.toEqual({ query: "ubuntu", limit: 5, output: undefined, json: false });
});
it("melempar error jika query kosong", () => {
expect(() => parseArgs(["--limit", "5"]))
.toThrow("Query wajib diisi");
});
});Lapisan 2: unit test untuk formatter output
Formatter terminal sebaiknya diuji sebagai string biasa, bukan snapshot penuh seluruh layar terminal. Yang diuji adalah kontrak output penting: kolom yang muncul, urutan data, fallback nilai kosong, dan perilaku mode JSON.
Lebih baik menguji bagian penting seperti ini:
- judul kolom ada atau tidak
- jumlah baris hasil benar
- magnet link disembunyikan atau ditampilkan sesuai mode
- warna ANSI dimatikan saat output non-interaktif
Daripada snapshot besar yang rapuh, gunakan assertion yang lebih semantik. Snapshot tetap bisa dipakai untuk output kecil yang stabil, tetapi hindari menjadikannya mekanisme utama untuk seluruh UI terminal.
Lapisan 3: unit test adapter boundary
Adapter adalah pembungkus tipis di sekitar dependency nyata seperti filesystem, HTTP client, atau process spawn. Tujuannya bukan menguji library pihak ketiga, melainkan memastikan adapter Anda menerjemahkan input-output dengan benar.
Contoh adapter yang layak diuji:
FileStoreuntuk menyimpan hasil ke fileHttpSearchClientuntuk mengambil hasil pencarianDownloaderProcessuntuk membungkusspawn
Jaga adapter tetap tipis. Jika adapter terlalu kompleks, itu tanda ada logika domain yang harus dipindahkan ke layer lain.
Gunakan dependency injection agar integration test tetap terkendali
CLI yang langsung memanggil fetch, fs, dan child_process.spawn dari dalam command handler akan sulit diuji tanpa efek samping. Solusinya adalah dependency injection: command menerima antarmuka dependency, bukan membuat sendiri dependency konkret.
export interface SearchClient {
search(query: string, limit: number): Promise<SearchResult[]>;
}
export interface FileWriter {
write(path: string, content: string): Promise<void>;
}
export interface Terminal {
log(message: string): void;
error(message: string): void;
}
export async function runSearchCommand(
opts: CliOptions,
deps: {
searchClient: SearchClient;
fileWriter: FileWriter;
terminal: Terminal;
}
): Promise<number> {
try {
const results = await deps.searchClient.search(opts.query, opts.limit);
const output = JSON.stringify(results, null, 2);
if (opts.output) {
await deps.fileWriter.write(opts.output, output);
deps.terminal.log(`Disimpan ke ${opts.output}`);
} else {
deps.terminal.log(output);
}
return 0;
} catch (err) {
deps.terminal.error(err instanceof Error ? err.message : "Unknown error");
return 1;
}
}Dengan pola ini, integration test dapat menjalankan command secara utuh tanpa jaringan sungguhan atau file sungguhan.
import { describe, it, expect, vi } from "vitest";
import { runSearchCommand } from "../src/app/runSearchCommand";
describe("runSearchCommand", () => {
it("menulis hasil ke file output", async () => {
const searchClient = {
search: vi.fn().mockResolvedValue([{ title: "Ubuntu ISO", magnet: "magnet:?xt=..." }])
};
const fileWriter = { write: vi.fn().mockResolvedValue(undefined) };
const terminal = { log: vi.fn(), error: vi.fn() };
const exitCode = await runSearchCommand(
{ query: "ubuntu", limit: 5, output: "result.json", json: true },
{ searchClient, fileWriter, terminal }
);
expect(exitCode).toBe(0);
expect(searchClient.search).toHaveBeenCalledWith("ubuntu", 5);
expect(fileWriter.write).toHaveBeenCalledTimes(1);
expect(terminal.error).not.toHaveBeenCalled();
});
});Kenapa pendekatan ini bekerja? Karena test tidak lagi memverifikasi implementasi internal secara rapuh, melainkan kontrak antarmuka dan hasil observabel. Ini menurunkan coupling dan membuat refactor lebih aman.
End-to-end test terbatas: realistis, tetapi tidak bergantung pada dunia luar
End-to-end tetap penting untuk CLI karena ada hal-hal yang baru terlihat saat proses dieksekusi sebagai program nyata: parsing argumen, penulisan ke stdout/stderr, exit code, dan direktori kerja. Namun jumlahnya harus sedikit dan terfokus pada jalur kritis.
Apa yang layak diuji secara end-to-end
- CLI menerima argumen valid dan menghasilkan exit code 0
- CLI menolak input tidak valid dan menghasilkan error yang terbaca
- File output benar-benar tercipta di temporary directory
- Mode JSON menghasilkan output yang dapat diparse
Yang sebaiknya tidak diuji E2E secara langsung:
- request ke situs publik
- spawn downloader sungguhan ke jaringan nyata
- progress bar penuh dengan delay waktu nyata
Mock HTTP untuk E2E terbatas
Jika CLI Anda memanggil HTTP client, arahkan dependency ke server mock lokal atau gunakan interception pada layer HTTP. Tujuannya adalah memastikan proses CLI tetap utuh, tetapi sumber data stabil dan dikendalikan test.
Prinsip pentingnya:
- response harus statis dan tersimpan sebagai fixture
- jangan bergantung pada internet
- uji skenario sukses, kosong, dan error HTTP
Fake process spawn untuk downloader
Jika aplikasi memanggil binary eksternal, bungkus pemanggilan itu dalam adapter. Di test, gantikan adapter dengan implementasi palsu yang mengemulasikan event selesai, error, atau timeout tanpa menjalankan proses nyata.
export interface ProcessRunner {
run(command: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }>;
}
export class FakeProcessRunner implements ProcessRunner {
async run(): Promise<{ code: number; stdout: string; stderr: string }> {
return { code: 0, stdout: "download complete", stderr: "" };
}
}Pola ini jauh lebih stabil daripada mem-spawn binary sungguhan yang belum tentu tersedia di semua mesin CI.
Temporary directory untuk isolasi filesystem
Setiap test yang menulis file harus memakai direktori temporer unik. Jangan menulis ke folder proyek atau path tetap seperti ./tmp yang dibagi bersama semua test. Setelah test selesai, hapus direktori itu secara eksplisit.
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const dir = await mkdtemp(join(tmpdir(), "torlink-test-"));
const outFile = join(dir, "result.json");
// jalankan test yang menulis file
const content = await readFile(outFile, "utf8");
await rm(dir, { recursive: true, force: true });Ini menghilangkan kebocoran state antar test dan mempermudah debugging karena setiap skenario punya workspace sendiri.
Cara menghindari sumber flaky test yang paling umum
1. Hindari jaringan publik sepenuhnya
Untuk aplikasi seperti torlink, hasil pencarian magnet dari sumber publik sangat tidak stabil untuk dijadikan oracle test. Bahkan jika endpoint hari ini konsisten, perubahan kecil pada HTML, rate limit, atau urutan hasil akan merusak test.
Gunakan salah satu dari dua pendekatan:
- Mock response di level HTTP client untuk test aplikasi
- Fixture HTML/JSON untuk menguji parser extractor secara lokal
Ini juga membantu membedakan regression internal dari gangguan eksternal.
2. Jangan pakai timeout real jika bisa pakai clock virtual
Jika ada retry, backoff, debounce, atau progress update berkala, gunakan fake timer dari test runner. Test menjadi jauh lebih cepat dan tidak sensitif terhadap performa mesin CI.
Yang penting, setelah menggunakan fake timer, flush semua microtask dan timer sebelum assertion. Banyak race condition tersembunyi terjadi karena test mengasumsikan promise chain sudah selesai padahal belum.
3. Kurangi race condition dengan kontrak async yang jelas
Race condition sering muncul saat fungsi memulai pekerjaan async tetapi tidak mengembalikan promise yang menandakan pekerjaan selesai. Untuk CLI, pastikan fungsi utama mengembalikan Promise<number> atau mekanisme serupa, lalu test await hasilnya sebelum memeriksa file atau output.
Kesalahan umum:
- menulis file tanpa di-
await - mengandalkan event emitter tanpa sinkronisasi penyelesaian
- mengassert stdout sebelum proses benar-benar selesai
4. Hindari snapshot terminal yang terlalu besar
Snapshot output terminal sering tampak praktis, tetapi mudah patah oleh:
- warna ANSI
- lebar terminal berbeda antar OS
- timestamp atau path absolut
- perubahan formatting kecil yang tidak penting
Jika ingin memakai snapshot, normalisasikan output lebih dulu:
- hapus ANSI escape code
- ganti path absolut menjadi placeholder
- hapus timestamp
- bekukan urutan data bila memang tidak relevan
Alternatif yang lebih stabil adalah assertion selektif terhadap bagian penting output.
5. Bersihkan state file dan environment
Test CLI sering dipengaruhi oleh process.env, current working directory, cache lokal, atau file konfigurasi user. Simpan dan pulihkan state tersebut di setiap test. Jangan biarkan test bergantung pada home directory developer atau konfigurasi mesin CI.
Catatan: jika aplikasi mendukung file konfigurasi default di direktori pengguna, tambahkan jalur injeksi untuk lokasi config agar test dapat menunjuk ke fixture lokal, bukan ke environment asli.
Struktur fixture yang praktis untuk proyek CLI
Fixture yang baik membuat skenario mudah dipahami dan tidak mengulang setup yang sama.
test/
fixtures/
http/
search-success.json
search-empty.json
search-error.json
html/
provider-page-sample.html
files/
expected-result.json
helpers/
makeTempDir.ts
stripAnsi.ts
fakeTerminal.ts
fakeProcessRunner.ts
unit/
integration/
e2e/Prinsip penyusunan fixture:
- pisahkan fixture berdasarkan jenis dependency
- beri nama file sesuai skenario, bukan nomor acak
- usahakan fixture kecil tetapi representatif
- hindari fixture yang bergantung pada data bergerak dari internet
Workflow CI yang cocok untuk CLI TypeScript
CI untuk aplikasi CLI sebaiknya tidak hanya menjalankan happy path. Tujuannya adalah mendeteksi regression lintas environment tanpa membuat pipeline rapuh.
Langkah minimum di CI
- Install dependencies secara reproducible dengan lockfile.
- Type check untuk menangkap error kontrak lebih awal.
- Lint agar code style dan bug sederhana terdeteksi sebelum test.
- Run unit dan integration test sebagai lapisan utama.
- Run E2E terbatas hanya pada skenario penting.
- Collect coverage secukupnya sebagai sinyal area yang belum teruji, bukan target buta.
Matriks OS minimal
Untuk CLI yang menyentuh path, newline, atau spawn process, uji minimal di:
- Linux: environment CI paling umum dan biasanya baseline utama
- Windows: penting untuk perbedaan separator path, quoting argumen, dan perilaku shell
- macOS: opsional tetapi bernilai jika pengguna CLI Anda banyak di ekosistem ini
Jika sumber daya terbatas, kombinasi Linux dan Windows sudah memberi sinyal lintas platform yang cukup kuat untuk banyak proyek CLI.
Praktik CI yang membantu stabilitas
- jalankan test dengan environment yang eksplisit, bukan mewarisi konfigurasi mesin runner
- matikan warna terminal di CI jika assertion sensitif terhadap ANSI
- hindari parallelism agresif untuk test yang masih menyentuh resource bersama
- simpan artifact log saat E2E gagal agar mudah dianalisis
Pola implementasi yang memudahkan test jangka panjang
Pisahkan entrypoint CLI dari application service
File entrypoint sebaiknya tipis: membaca argumen, membuat dependency nyata, memanggil service aplikasi, lalu menetapkan exit code. Semua logika utama berada di service yang bisa dipanggil dari test tanpa mem-boot proses terminal penuh.
Gunakan antarmuka kecil di boundary
Dependency injection akan terasa berat jika semua dependency dibungkus terlalu banyak. Solusinya adalah membungkus hanya boundary yang memang sulit diuji: HTTP, filesystem, process, waktu, dan terminal. Jangan membungkus fungsi murni yang tidak membutuhkan abstraksi.
Normalisasikan output yang nondeterministik
Jika aplikasi menghasilkan timestamp, path absolut, ID acak, atau urutan hasil yang tidak dijamin, normalisasikan sebelum assertion. Test yang baik memeriksa perilaku penting, bukan detail kebetulan.
Kesalahan yang sering terjadi saat menguji CLI TypeScript
- Terlalu banyak E2E: suite lambat, mahal, dan rapuh.
- Mock terlalu dalam: test lolos tetapi integrasi nyata tidak pernah diverifikasi.
- Tidak menguji exit code: padahal itu kontrak penting CLI.
- Mengassert string penuh stdout untuk output yang dinamis.
- Menggunakan direktori kerja bersama antar test.
- Tidak membedakan stdout dan stderr saat menangani error.
Trade-off yang perlu diterima: semakin realistis test, biasanya semakin lambat dan makin rentan flaky. Karena itu, realism perlu ditempatkan di lapisan yang tepat, bukan diterapkan ke semua test.
Checklist verifikasi sebelum rilis
Sebelum merilis CLI downloader magnet berbasis TypeScript, gunakan checklist berikut untuk mengurangi regression:
- Semua unit test parser argumen, formatter, dan mapper error lulus.
- Integration test command utama lulus dengan dependency injection.
- E2E terbatas lulus untuk jalur sukses dan gagal.
- Tidak ada test yang mengakses jaringan publik.
- Tidak ada test yang menunggu timeout real tanpa alasan kuat.
- Semua test yang menulis file memakai temporary directory.
- Output terminal yang diuji sudah dinormalisasi dari ANSI/path/timestamp bila perlu.
- Exit code, stdout, dan stderr diverifikasi pada skenario penting.
- CI lulus minimal di Linux dan Windows.
- Fixture dan helper test mudah dibaca, tidak menyimpan state tersembunyi.
Penutup
Strategi test CLI TypeScript untuk downloader magnet yang stabil pada dasarnya adalah soal mengendalikan boundary. Uji logika murni sebanyak mungkin di level unit, sambungkan komponen lewat dependency injection di level integrasi, lalu pertahankan end-to-end test dalam jumlah kecil tetapi representatif.
Dengan menghindari jaringan publik, timeout nyata, snapshot terminal rapuh, race condition, dan state file sisa, Anda akan mendapatkan test suite yang lebih cepat, lebih dapat dipercaya, dan lebih efektif menangkap regression pada aplikasi CLI seperti torlink.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!