AI viết unit test: cách kiểm tra test do AI sinh ra

AI viết unit test rất nhanh, nhưng test xanh chưa chắc bảo vệ được gì. Làm hỏng code có chủ đích để thử test: bản yếu bắt 1/5 chỗ hỏng, bản chặt bắt 5/5.

Để AI viết unit test thì kiểm tra test đó thế nào? Bạn làm hỏng code có chủ đích, từng chỗ nhỏ một, rồi xem test có đỏ không. Test xanh với code đúng chưa chứng minh gì, vì test chỉ có giá trị khi nó đỏ lúc code sai. Bạn đã bao giờ tin một test chỉ vì nó xanh chưa? Cùng mình thử nhé!

Test nào cho lớp nối Telegram?

Ở bài trước, mình cùng AI sửa lỗi hai người dùng chung một cuốn sổ trong bot Telegram của app sổ chi tiêu. Series này làm app đó từ đầu tới cuối cùng AI: bạn nhắn "cà phê 35k" thì app ghi một khoản chi, nhắn /tong thì app trả tổng tháng này. Cuối bài, mình phát hiện đổi chữ ký hàm taoBot làm hỏng một file thử mà cả 23 test vẫn xanh, vì phần nối với Telegram, tức bot.ts, không có test nào.

Mình muốn bịt lỗ hổng đó, và đây là việc rất hợp để nhờ AI. Test cho một file 28 dòng là việc lặp lại và dễ viết, đúng loại việc AI làm nhanh. Nhưng mình đã học được ở bài trước rằng AI chỉ giỏi như thông tin mình đưa. Câu "viết test cho bot.ts" không nói test cần bảo vệ điều gì, và AI sẽ chọn điều dễ viết nhất.

Trước hết, đây là file cần test. Bot dùng thư viện grammY 1.46.0, và mỗi tin nhắn chữ đi qua handleMessage của các bài trước:

src/bot.ts
// Nối app sổ chi tiêu với Telegram qua grammY. Mọi logic vẫn nằm trong handleMessage.
import { Bot, type BotConfig, type Context } from "grammy";

import { handleMessage, type Entry } from "./so-chi-tieu.ts";

// donVi chỉ để minh họa lỗi đơn vị thời gian ở bài trước; code thật luôn dùng "giay".
export function taoBot(token: string, config?: BotConfig<Context>, donVi: "giay" | "mili-giay" = "giay"): Bot {
  const bot = new Bot(token, config);
  // Mỗi người dùng Telegram một cuốn sổ riêng, theo id của họ. Trước đây cả bot dùng chung một mảng.
  const soTheoNguoi = new Map<number, Entry[]>();

  // "message:text" chỉ khớp tin nhắn MỚI có chữ: không khớp ảnh, sticker hay tin nhắn được sửa.
  bot.on("message:text", async (ctx) => {
    const nguoi = ctx.from.id;
    let so = soTheoNguoi.get(nguoi);
    if (!so) {
      so = [];
      soTheoNguoi.set(nguoi, so);
    }
    // Telegram gửi thời điểm theo GIÂY (Unix time), còn Date của JavaScript cần mili giây.
    const luc = new Date(donVi === "giay" ? ctx.message.date * 1000 : ctx.message.date);
    await ctx.reply(handleMessage(ctx.message.text, luc, so));
  });

  // catch là một HÀM nhận error handler. Không có nó, lỗi trong handler sẽ làm bot dừng nhận tin.
  bot.catch((err) => console.error(`Lỗi khi xử lý update ${err.ctx.update.update_id}:`, err.error));
  return bot;
}

Nó gọi hai file từ các bài trước. Bạn cần cả hai để chạy được ví dụ, nên mình đặt chúng ở đây. File đầu đọc số tiền từ tin nhắn, như "35k" hay "1tr2":

src/parse-expense.ts
// Đọc một tin nhắn chi tiêu theo SPEC.md (v1.1), mục "Quy tắc đọc số tiền".
export type Expense = { note: string; amount: number };

const MAX_AMOUNT = 1_000_000_000;
// 35k · 32K · 3tr · 1tr2 · 45.000 · 50000 · 35 nghìn · 20 ngàn · 2 triệu (một "token" số tiền, đứng riêng)
const AMOUNT = /(?<![\p{L}\d])(\d{1,3}(?:[.,]\d{3})+|\d+)(?:(k|tr)(\d)?|\s+(nghìn|ngàn|triệu))?(?![\p{L}\d])/giu;

const UNIT: Record<string, number> = { k: 1_000, nghìn: 1_000, ngàn: 1_000, tr: 1_000_000, triệu: 1_000_000 };

function toAmount(digits: string, unit: string | undefined, decimal: string | undefined): number {
  const base = Number(digits.replace(/[.,]/g, ""));
  if (!unit) return base;
  const multiplier = UNIT[unit.toLowerCase()];
  // "1tr2": chữ số sau "tr" là hàng trăm nghìn. Regex chỉ cho phép chữ số này ngay sau "k" hoặc "tr".
  if (decimal && multiplier === 1_000_000) return base * multiplier + Number(decimal) * 100_000;
  if (decimal) return Number.NaN; // "35k2" không có trong spec
  return base * multiplier;
}

export function parseExpense(input: string): Expense | null {
  // Tin nhắn gõ trên điện thoại có thể ở dạng dấu tổ hợp (NFD); đưa về một dạng để so khớp "nghìn".
  const message = input.normalize("NFC");
  const matches = [...message.matchAll(AMOUNT)];
  if (matches.length !== 1) return null; // thiếu số tiền, hoặc có nhiều hơn một

  const [token, digits, shortUnit, decimal, wordUnit] = matches[0];
  const amount = toAmount(digits, shortUnit ?? wordUnit, decimal);
  if (!Number.isInteger(amount) || amount <= 0 || amount > MAX_AMOUNT) return null;

  const note = message.replace(token, " ").replace(/\s+/g, " ").trim();
  if (!note) return null;
  return { note, amount };
}

File thứ hai ghi khoản chi và trả lời lệnh /tong. Nó tính tháng theo giờ Việt Nam, kể cả khi máy chủ chạy giờ UTC:

src/so-chi-tieu.ts
// Xử lý một tin nhắn theo SPEC.md (v1.2): ghi khoản chi, hoặc trả lời lệnh.
// Dữ liệu tạm giữ trong bộ nhớ; các tập sau mới lưu vào database.
import { parseExpense } from "./parse-expense.ts";

export type Entry = { note: string; amount: number; at: Date };

const HUONG_DAN = 'Mình chưa hiểu. Nhắn kiểu "cà phê 35k" để ghi một khoản chi, hoặc /tong để xem tổng tháng này.';

// Tạo MỘT lần rồi dùng lại: tạo Intl.DateTimeFormat mới ở mỗi lần gọi chậm hơn hàng chục lần.
// "en-GB" vì nó trả về đúng "12/2026"; "vi-VN" trả về "tháng 12, 2026".
const THANG_VN = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" });

// Tháng/năm theo giờ Việt Nam, kể cả khi máy chủ chạy theo giờ UTC.
function thangVN(date: Date): string {
  const parts = THANG_VN.formatToParts(date);
  const month = parts.find((p) => p.type === "month")?.value;
  const year = parts.find((p) => p.type === "year")?.value;
  if (!month || !year) throw new Error(`Không tách được tháng/năm từ ${date.toISOString()}`);
  return `${month}/${year}`;
}

const vnd = (n: number) => `${n.toLocaleString("vi-VN")}đ`;

export function tongThang(entries: Entry[], now: Date): string {
  const thang = thangVN(now);
  const trongThang = entries.filter((entry) => thangVN(entry.at) === thang);
  if (trongThang.length === 0) return `Tháng ${thang} chưa có khoản chi nào.`;
  const tong = trongThang.reduce((sum, entry) => sum + entry.amount, 0);
  return `Tháng ${thang}: ${trongThang.length} khoản, tổng ${vnd(tong)}`;
}

export function handleMessage(text: string, now: Date, entries: Entry[]): string {
  const message = text.trim().normalize("NFC");
  // Tin nhắn bắt đầu bằng "/" là lệnh, không bao giờ là khoản chi (spec v1.2).
  if (message.startsWith("/")) {
    const lenh = message.toLowerCase();
    if (lenh === "/tong" || lenh === "/tổng") return tongThang(entries, now);
    return `Mình chưa có lệnh ${message.split(/\s/)[0]}. Nhắn /tong để xem tổng tháng này.`;
  }

  const expense = parseExpense(message);
  if (!expense) return HUONG_DAN;
  entries.push({ ...expense, at: now });
  return `Đã ghi: ${expense.note}, ${vnd(expense.amount)}`;
}

Để thử bot mà không cần token Telegram hay mạng, mình dùng cách của bài trước và gói nó vào một file dùng chung. Nó dựng bot với thông tin bot giả để grammY khỏi hỏi Telegram, chặn mọi lời gọi ra ngoài bằng một transformer và ghi lại câu bot định gửi, rồi đưa các update giả vào bot.handleUpdate:

src/thu-bot.ts
// Đồ nghề để thử bot không cần token hay mạng: dựng bot với botInfo giả, chặn mọi lời gọi tới Telegram
// bằng một transformer và ghi lại, rồi đưa update giả vào bot.handleUpdate. Chung cho các file test.
import type { Update, UserFromGetMe } from "grammy/types";

import { taoBot } from "./bot.ts";

const botInfo = {
  id: 1, is_bot: true, first_name: "Sổ chi tiêu", username: "so_chi_tieu_bot",
  can_join_groups: false, can_read_all_group_messages: false, supports_inline_queries: false,
  can_connect_to_business: false, has_main_web_app: false, has_topics_enabled: false,
  allows_users_to_create_topics: false, can_manage_bots: false, supports_join_request_queries: false,
} satisfies UserFromGetMe;

const giay = (isoGioVN: string) => Math.floor(new Date(`${isoGioVN}+07:00`).getTime() / 1000);

export function taoBotThu() {
  const bot = taoBot("token-gia", { botInfo });
  const daGui: string[] = [];
  bot.api.config.use(async (_prev, _method, payload) => {
    daGui.push((payload as { text?: string }).text ?? "");
    return { ok: true, result: { message_id: 0, date: 0, chat: { id: 0, type: "private", first_name: "" }, text: "" } } as never;
  });

  let updateId = 0;
  const chatCua = (id: number) => ({ id, type: "private" as const, first_name: `Người ${id}` });
  const nguoi = (id: number) => ({ id, is_bot: false, first_name: `Người ${id}` });

  return {
    bot, // để test cần gọi thẳng vào bot khi có lý do
    daGui,
    /** Người dùng `id` nhắn `text` lúc `luc` (giờ Việt Nam, dạng ISO). Trả về câu bot trả lời, hoặc null nếu bot im lặng. */
    async nhan(id: number, text: string, luc = "2026-12-01T06:30"): Promise<string | null> {
      daGui.length = 0;
      const update: Update = { update_id: ++updateId, message: { message_id: updateId, date: giay(luc), chat: chatCua(id), from: nguoi(id), text } };
      await bot.handleUpdate(update);
      return daGui.at(-1) ?? null;
    },
    /** Người dùng `id` gửi một tấm ảnh, không có chữ. */
    async guiAnh(id: number, luc = "2026-12-01T06:30"): Promise<string | null> {
      daGui.length = 0;
      const update: Update = { update_id: ++updateId, message: { message_id: updateId, date: giay(luc), chat: chatCua(id), from: nguoi(id), photo: [{ file_id: "f", file_unique_id: "u", width: 90, height: 90 }] } };
      await bot.handleUpdate(update);
      return daGui.at(-1) ?? null;
    },
    /** Người dùng `id` SỬA một tin nhắn cũ thành `text`. */
    async sua(id: number, text: string, luc = "2026-12-01T06:30"): Promise<string | null> {
      daGui.length = 0;
      const update: Update = { update_id: ++updateId, edited_message: { message_id: 1, date: giay(luc), edit_date: giay(luc), chat: chatCua(id), from: nguoi(id), text } };
      await bot.handleUpdate(update);
      return daGui.at(-1) ?? null;
    },
  };
}

Bạn đặt bốn file này trong thư mục src/, tạo package.json có "type": "module" rồi chạy npm install grammy@1.46.0. Cần Node.js 22.18 trở lên để chạy thẳng file .ts. Từ đây trở xuống là phần chính của bài.

Một bản test "trông đủ" thì như thế nào?

Một lưu ý trước khi tiếp tục. Mình không trích hai bản test dưới đây từ một lần chạy AI cụ thể nào. Cả hai là bản mình tự viết để minh họa. Bản đầu viết theo kiểu hay gặp khi bạn chỉ nhờ "viết test cho bot.ts", không nói gì thêm: mỗi tính năng một test, ngắn, nhìn rất đủ.

src/bot-yeu.test.ts
// Bản test "trông đủ": mỗi tính năng một test, và tất cả đều xanh. Kiểu test hay gặp khi nhờ AI viết test
// mà không nói rõ cần bảo vệ điều gì.
import assert from "node:assert/strict";
import { test } from "node:test";

import { taoBotThu } from "./thu-bot.ts";

test("bot trả lời khi nhận tin nhắn ghi chi tiêu", async () => {
  const bot = taoBotThu();
  const traLoi = await bot.nhan(42, "cà phê 35k");
  assert.ok(traLoi);
  assert.ok(traLoi.includes("Đã ghi"));
});

test("/tong có trả lời", async () => {
  const bot = taoBotThu();
  await bot.nhan(42, "cà phê 35k");
  const traLoi = await bot.nhan(42, "/tong");
  assert.ok(traLoi);
  assert.ok(traLoi.includes("tổng"));
});

test("tin nhắn không hiểu được thì bot hướng dẫn", async () => {
  const bot = taoBotThu();
  const traLoi = await bot.nhan(42, "xin chào");
  assert.ok(traLoi?.includes("Mình chưa hiểu"));
});

Chạy bằng trình chạy test có sẵn của Node.js, node:test, mà không cần cài thêm gì:

node --test src/bot-yeu.test.ts
# tests 3
# pass 3
# fail 0

Ba test xanh, và nhìn code thì chúng rất hợp lý. Mỗi test có một câu mô tả rõ ràng, và mỗi test kiểm tra một tính năng của bot. Nếu bạn là người review pull request này, bạn có duyệt không? Mình nghĩ nhiều người sẽ duyệt.

Độ phủ 100% nói lên điều gì?

Một cách phổ biến để đánh giá test là đo độ phủ (code coverage), tức bao nhiêu dòng code của bot.ts đã được chạy qua trong lúc test. Node.js đo được việc này bằng cờ --experimental-test-coverage, theo tài liệu về thu thập độ phủ:

node --test --experimental-test-coverage --test-coverage-include=src/bot.ts src/bot-yeu.test.ts
# file      | line % | branch % | funcs % | uncovered lines
#  bot.ts   | 100.00 |    80.00 |   66.67 |

100% số dòng của bot.ts đã chạy. Một người nhìn con số này sẽ yên tâm, và đây là chỗ mình nghĩ nhiều người nhờ AI viết test bị lừa. Độ phủ chỉ nói dòng code đã chạy qua, không nói test có kiểm tra kết quả của dòng đó hay không. Test thứ nhất chạy qua cả đoạn tạo cuốn sổ riêng, nhưng nó chỉ kiểm tra một người dùng, nên không phân biệt được "mỗi người một sổ" với "cả bot một sổ".

Nghiên cứu về chủ đề này nói cùng ý. Bài báo của Meta, Mutation-Guided LLM-based Test Generation at Meta, mô tả cách dùng chính những lỗi cài vào code để hướng dẫn việc sinh test, thay vì chỉ nhìn độ phủ. Mình chỉ lấy ý chính từ bài đó, không dùng con số nào của nó. Còn trong bài này, mình sẽ cho bạn thấy ý đó bằng một thí nghiệm nhỏ.

Làm hỏng code có chủ đích để thử test

Ý tưởng khá đơn giản. Nếu một test có tác dụng, nó phải đỏ khi code sai. Vậy mình cố tình làm code sai, từng chỗ một, và đếm xem test bắt được mấy lần. Kỹ thuật này có tên là mutation testing, và có công cụ tự động làm việc đó. Ở đây mình làm tay để bạn thấy từng bước.

Năm chỗ hỏng mình chọn đều có lý do. Hai chỗ là những lỗi thật của dự án ở các bài trước, gồm cả bot dùng chung một cuốn sổ và lẫn giây với mili giây. Hai chỗ nằm trong spec: ảnh không được vào handler, và tin nhắn được sửa không được ghi thêm khoản chi. Chỗ còn lại là một lỗi rõ ràng nhất, bot không còn gọi handleMessage.

dot-bien.ts
// "Làm hỏng code có chủ đích" (mutation testing làm tay): mỗi lần sửa một chỗ nhỏ trong bot.ts, rồi chạy một file test.
// Test tốt phải ĐỎ khi code hỏng. Nếu test vẫn xanh, test đó chưa bảo vệ được chỗ vừa bị sửa.
// Dùng: node dot-bien.ts src/bot-yeu.test.ts
import { spawnSync } from "node:child_process";
import { cpSync, readFileSync, rmSync, writeFileSync } from "node:fs";

const fileTest = process.argv[2];
if (!fileTest) throw new Error("Cần tên file test, ví dụ: node dot-bien.ts src/bot.test.ts");

// Mỗi mục: tên, đoạn code gốc trong bot.ts, đoạn code hỏng thay vào.
const DOT_BIEN: [string, string, string][] = [
  ["cả bot dùng chung một cuốn sổ (lỗi bài 5)", "const nguoi = ctx.from.id;", "const nguoi = 0;"],
  ["lẫn giây với mili giây (lỗi bài 4)", 'donVi === "giay" ? ctx.message.date * 1000 : ctx.message.date', "ctx.message.date"],
  ['"message:text" thành "message": ảnh cũng vào handler', 'bot.on("message:text"', 'bot.on("message"'],
  ["tin nhắn được sửa cũng bị ghi thêm", 'bot.on("message:text"', 'bot.on(["message:text", "edited_message:text"]'],
  ["bot không còn gọi handleMessage", "await ctx.reply(handleMessage(ctx.message.text, luc, so));", 'await ctx.reply("Đã ghi");'],
];

const THU_MUC = ".dot-bien"; // bản sao tạm của src/, xóa khi xong; bot.ts thật không bị đụng tới
const goc = readFileSync("src/bot.ts", "utf8");
const chay = () => spawnSync(process.execPath, ["--test", `${THU_MUC}/${fileTest.replace(/^src\//, "")}`], { encoding: "utf8" }).status === 0;

console.log(`Kiểm tra ${fileTest}:`);
cpSync("src", THU_MUC, { recursive: true });
console.log(`  code nguyên vẹn: ${chay() ? "xanh (đúng như mong đợi)" : "ĐỎ, sửa test trước đã"}`);
let sot = 0;
for (const [ten, tu, thanh] of DOT_BIEN) {
  if (!goc.includes(tu)) throw new Error(`Không thấy đoạn cần sửa trong bot.ts: ${tu}`);
  writeFileSync(`${THU_MUC}/bot.ts`, goc.replace(tu, thanh));
  const bat = !chay();
  if (bat) sot += 1;
  console.log(`  ${bat ? "bắt được " : "BỎ LỌT   "} ${ten}`);
}
rmSync(THU_MUC, { recursive: true, force: true });
console.log(`  => bắt ${sot}/${DOT_BIEN.length} chỗ hỏng`);

Script chép thư mục src sang một thư mục tạm, sửa bot.ts trong bản sao rồi chạy test trên đó. Nhờ vậy bot.ts thật không bị đụng tới, kể cả khi script lỗi giữa chừng. Dòng kiểm tra goc.includes(tu) để script báo lỗi ngay nếu ai đó sửa bot.ts làm đoạn cần hỏng không còn nữa, thay vì âm thầm báo "bắt được" cho một sự hỏng không xảy ra. Mình chạy nó với bản test trông đủ:

node dot-bien.ts src/bot-yeu.test.ts
Kiểm tra src/bot-yeu.test.ts:
  code nguyên vẹn: xanh (đúng như mong đợi)
  BỎ LỌT    cả bot dùng chung một cuốn sổ (lỗi bài 5)
  BỎ LỌT    lẫn giây với mili giây (lỗi bài 4)
  BỎ LỌT    "message:text" thành "message": ảnh cũng vào handler
  BỎ LỌT    tin nhắn được sửa cũng bị ghi thêm
  bắt được  bot không còn gọi handleMessage
  => bắt 1/5 chỗ hỏng

Ba test xanh, độ phủ 100%, và chỉ bắt được 1 trong 5 chỗ hỏng. Hai lỗi thật mà dự án đã mất cả một bài để tìm ra, test này đều bỏ lọt. Đưa lỗi "cả bot dùng chung một cuốn sổ" quay lại, và test vẫn xanh, vì mọi test đều chỉ có một người dùng. Lỗi lẫn giây với mili giây cũng lọt, vì không test nào kiểm tra tháng của khoản chi.

Điều làm mình thấy đáng chú ý là test duy nhất bắt được là test với lỗi "dễ" nhất. Khi bot.ts không còn gọi handleMessage, câu trả lời đổi hẳn nên bất kỳ test nào kiểm tra nội dung đều đỏ. Những lỗi tinh vi hơn thì không. Đây cũng là loại lỗi người ta thường gặp ở code thật.

Bản test chặt khác ở đâu?

Bản thứ hai khác ở một điểm cốt lõi: mỗi test bắt đầu từ một điều cần bảo vệ, rồi mới viết code. Điều đó có thể là một lỗi đã từng xảy ra, hoặc một điều khoản trong spec. Mình ghi luôn lý do đó vào tên test, để người đọc sau biết xóa test này đi thì mất bảo vệ gì:

src/bot.test.ts
// Test cho bot.ts: mỗi test bảo vệ MỘT điều đã từng hỏng hoặc đã nằm trong SPEC.md, và nói rõ điều đó.
import assert from "node:assert/strict";
import { test } from "node:test";

import { taoBotThu } from "./thu-bot.ts";

test("mỗi người một cuốn sổ: /tong của người này không cộng khoản của người kia (lỗi bài 5)", async () => {
  const bot = taoBotThu();
  await bot.nhan(42, "cà phê 35k");
  await bot.nhan(77, "trà sữa 32k");
  assert.equal(await bot.nhan(42, "/tong"), "Tháng 12/2026: 1 khoản, tổng 35.000đ");
  assert.equal(await bot.nhan(77, "/tong"), "Tháng 12/2026: 1 khoản, tổng 32.000đ");
});

test("thời điểm Telegram gửi tính bằng giây: 6:30 sáng 1/12 giờ VN thuộc tháng 12 (lỗi bài 4)", async () => {
  const bot = taoBotThu();
  await bot.nhan(42, "trà sữa 32k", "2026-12-01T06:30");
  assert.equal(await bot.nhan(42, "/tong", "2026-12-01T06:31"), "Tháng 12/2026: 1 khoản, tổng 32.000đ");
});

test("tin nhắn ảnh, không có chữ, thì bot im lặng", async () => {
  const bot = taoBotThu();
  assert.equal(await bot.guiAnh(42), null);
});

test("sửa một tin nhắn cũ không ghi thêm khoản chi", async () => {
  const bot = taoBotThu();
  await bot.nhan(42, "cà phê 35k");
  assert.equal(await bot.sua(42, "cà phê 45k"), null);
  assert.equal(await bot.nhan(42, "/tong"), "Tháng 12/2026: 1 khoản, tổng 35.000đ");
});

Có hai khác biệt nữa. Các kiểm tra dùng assert.equal với chuỗi đầy đủ thay cho includes, nên sai một con số hay một chữ cũng đỏ. Và test thứ nhất dùng hai người dùng thay vì một, vì chỉ khi có hai người thì lỗi sổ chung mới lộ ra. Mình chạy cùng script:

node dot-bien.ts src/bot.test.ts
Kiểm tra src/bot.test.ts:
  code nguyên vẹn: xanh (đúng như mong đợi)
  bắt được  cả bot dùng chung một cuốn sổ (lỗi bài 5)
  bắt được  lẫn giây với mili giây (lỗi bài 4)
  bắt được  "message:text" thành "message": ảnh cũng vào handler
  bắt được  tin nhắn được sửa cũng bị ghi thêm
  bắt được  bot không còn gọi handleMessage
  => bắt 5/5 chỗ hỏng

Độ phủ của bản này đo ra cũng 100% dòng, 80% nhánh và 66,67% hàm, giống hệt bản trước. Hai bản test có cùng độ phủ mà một bản bắt 1 trong 5 chỗ hỏng, một bản bắt cả 5. Đó là lý do mình không dùng độ phủ để quyết định có tin một test hay không.

Test "ảnh thì im lặng" bắt được lỗi message có một chi tiết nhỏ đáng biết. Khi handler nhận ảnh, ctx.message.text không có, và handleMessage ném lỗi. Hàm bot.handleUpdate đẩy lỗi đó ra ngoài cho nơi gọi, nên test đỏ vì có ngoại lệ chứ không phải vì câu trả lời sai. Trong lúc chạy thật với bot.start(), lỗi đó đi vào bot.catch và chỉ được ghi log. Dù đường đi khác nhau, test vẫn làm đúng việc của nó là báo có chuyện bất thường.

Cả bộ test của dự án giờ gồm 27 test:

node --test src/bot.test.ts src/so-chi-tieu.test.ts src/parse-expense.test.ts
# tests 27
# pass 27
# fail 0

Mình chạy lệnh trên với so-chi-tieu.test.ts và parse-expense.test.ts là hai file test của các bài trước, 23 test như đã nói. Nếu bạn không có hai file đó thì chỉ chạy bot.test.ts là đủ để theo dõi bài này.

Bốn bước kiểm tra một test do AI viết

Sau thí nghiệm này, mình rút ra quy trình ngắn gọn mà mình sẽ dùng mỗi khi nhờ AI viết test:

  1. Nói rõ cần bảo vệ điều gì khi nhờ. Thay vì "viết test cho bot.ts", mình sẽ liệt kê: hai người dùng không dùng chung sổ, thời gian tính bằng giây, ảnh không được ghi khoản chi. Một danh sách như vậy chính là danh sách chỗ hỏng cần thử ở bước 3.
  2. Chạy test với code đúng. Test phải xanh. Nhưng đây mới là bước đầu, chưa chứng minh gì.
  3. Làm hỏng code có chủ đích. Mỗi điều ở bước 1, sửa code cho nó sai, rồi chạy lại. Test phải đỏ. Nếu nó vẫn xanh, hãy bảo AI sửa test, và lần này nói luôn chỗ hỏng nào đã lọt.
  4. Đọc từng dòng `assert`. assert.ok(traLoi) hay includes("Đã ghi") nghe có vẻ kiểm tra nhiều nhưng chỉ cần bot trả lời một chuỗi bất kỳ chứa chữ đó. Mình ưu tiên so sánh bằng chuỗi hoàn chỉnh khi có thể.

Bước 3 làm tay như trong bài thì chậm khi dự án lớn, nhưng nó chạy được với vài chỗ hỏng quan trọng nhất mà bạn biết. Với dự án lớn hơn, các công cụ như Stryker tự tạo ra rất nhiều chỗ hỏng và chạy tự động. Mình chưa thử Stryker trong series này nên không bình luận thêm về nó.

Một lỗi khác không nằm trong bảng mutation

Còn một chuyện mình muốn bạn biết trước khi kết thúc. Khi nhờ AI viết test, có một loại lỗi mà thí nghiệm ở trên không bắt được. Đó là AI viết test gọi một hàm không có thật. Mình dựng một ví dụ ngắn, dùng đúng đồ nghề ở trên và một hàm tên processUpdate mà grammY 1.46.0 không có:

src/ham-khong-ton-tai.test.ts
// Một dòng nghe rất hợp lý, nhưng grammY 1.46.0 không có hàm processUpdate.
import assert from "node:assert/strict";
import { test } from "node:test";

import { taoBotThu } from "./thu-bot.ts";

test("bot nhận một update và trả lời", async () => {
  const thu = taoBotThu();
  const update = { update_id: 1, message: { message_id: 1, date: 1796000000, chat: { id: 42, type: "private" }, from: { id: 42, is_bot: false, first_name: "A" }, text: "cà phê 35k" } };
  await thu.bot.processUpdate(update);
  assert.ok(thu.daGui.length > 0);
});
node --test src/ham-khong-ton-tai.test.ts
  error: 'thu.bot.processUpdate is not a function'
  name: 'TypeError'
# pass 0
# fail 1

Lần này test đỏ ngay, vì hàm không tồn tại bị Node.js phát hiện khi chạy. Nhưng đó là vì dòng này nằm trong một test chạy được. Nếu một hàm bịa nằm trong nhánh code hiếm khi chạy, nó chỉ nổ khi người dùng đụng tới. Và AI có thể bịa cả tên hàm lẫn tham số, cả khi nó nói chuyện rất chắc chắn. Cách kiểm chứng từng dòng code AI viết, không chỉ test, là việc của các bài viết sau.

Bạn theo dõi cả series ở Làm side project cùng AI. Nếu đang ôn phỏng vấn, các câu hỏi về cách dùng AI khi lập trình có trong ngân hàng câu hỏi phỏng vấn.

Nhận bài tiếp theo qua email.

Một email mỗi tuần, chỉ khi có điều đáng đọc. Không spam.