รีวิวจริงที่ 4: package พร้อม test suite
รีวิวสุดท้ายจากทั้งสี่บทนี้เป็น package ไม่ใช่สคริปต์เดียว: package.json, source ไฟล์เดียว, และ node:test suite หนึ่งชุด — สามไฟล์แทนที่จะเป็นโปรแกรมเดียวที่รันได้ ซึ่งเป็นเหตุผลที่ transcript ทุกอันในบทนี้เป็นคำสั่งจริงที่รันแยกกัน แทนที่จะเป็น <NodeRunner> เดียว bug สี่ตัวจาก AI bug catalog ถูกปลูกไว้ทั่วทั้ง package: ลำดับ exports ผิด, dependency แบบ ^0.x, test ที่ไม่มี assertion, และ test ที่รอ timer จริงแทนที่จะใช้ mock.timers
Package
หัวข้อที่มีชื่อว่า “Package”package.json:
{ "name": "wr4-demo", "version": "1.0.0", "type": "module", "exports": { ".": { "default": "./index.js", "types": "./index.d.ts" } }, "dependencies": { "left-pad": "^0.0.3" }, "scripts": { "test": "node --test" }}index.js:
// @skip-verify multi-file package -- imported by index.test.js below, not run standaloneexport function double(n) { return n * 2 - 1; // BUG 3: off by one -- should just be n * 2}index.test.js:
// @skip-verify multi-file package -- imports ./index.js as a sibling fileimport { test } from 'node:test';import { double } from './index.js';
test('doubles a number', function () { double(4); // BUG 3 (continued): no assertion at all -- this can never fail});
test('resolves eventually', function (t, done) { setTimeout(function () { done(); }, 300); // BUG 4: a real timer, not mock.timers -- slows the suite for no real reason});ลองหา bug ก่อนอ่านต่อ
หัวข้อที่มีชื่อว่า “ลองหา bug ก่อนอ่านต่อ”bug ที่ปลูกไว้สี่ตัว — หนึ่งข้อตกลงการแพ็กเกจ, หนึ่งกับดักช่วงเวอร์ชัน, หนึ่งการเช็กที่หายไป, หนึ่ง test ที่ช้า ลองเดาทั้งสี่ตัวก่อนเปิดคำตอบด้านล่าง
Bug 1 — "default" อยู่ก่อน "types" ใน exports map
อาการ: Node resolve เงื่อนไขแรกที่ match ใน exports map — เมื่อ "default" มาก่อน มันจะชนะเสมอ และ "types" ข้างล่างเป็น configuration ที่ตายแล้วซึ่ง editor ของ TypeScript และ tsc จะไม่มีวันได้ใช้จริง
หลักฐาน: สิ่งนี้ไม่สร้าง runtime error เลยใน Node.js ธรรมดา — require/import ใน JavaScript ไม่เคยดูเงื่อนไข "types" ตั้งแต่แรก วิธีเดียวที่จะสังเกตเห็น bug นี้คือลองใช้ type ของ package นี้จากโปรเจกต์ TypeScript จริง ๆ แล้วสังเกตว่า editor กลับไปใช้ any โดยปริยายแทนที่จะเป็น declaration จริง
วิธีแก้: เรียงเงื่อนไขที่เจาะจงกว่าไว้ก่อน: { "types": "./index.d.ts", "default": "./index.js" }
Bug 2 — "left-pad": "^0.0.3"
อาการ: สำหรับเวอร์ชัน 0.0.x เครื่องหมาย caret (^) ของ npm ไม่อนุญาตให้อัปเดตเลยแม้แต่นิดเดียว — ไม่แม้แต่ patch release — ซึ่งเข้มงวดกว่า ^ ตัวเดียวกันบน dependency แบบ 1.x หรือแม้แต่ 0.1.x มาก
หลักฐาน: ไม่ใช่การ crash — นี่เป็นทางเลือกเรื่องช่วงเวอร์ชัน ไม่ใช่ bug ที่ทำให้ build ล้มเหลว มันคุ้มค่าที่จะ flag ไว้ตอนรีวิวเพราะง่ายที่จะสมมติว่า ^ แปลว่า “อัปเดต patch และ minor ได้เสมอ” ทั้งที่สำหรับอะไรก็ตามที่ต่ำกว่า 0.0.x มันแปลว่า “pin ไว้แน่นอน” จริง ๆ
วิธีแก้: ไม่มีอะไรพังจริง ๆ ที่นี่ แต่ reviewer ควรยืนยันว่าผู้เขียนตั้งใจจริง ๆ ว่า “ไม่อัปเดต dependency นี้เลยเด็ดขาด” — ถ้าไม่ใช่ การรอให้ dependency ถึงอย่างน้อย 0.1.0 (หรือ pin แบบไม่มี caret เลยเพื่อให้เจตนาชัดเจน) จะหลีกเลี่ยงความประหลาดใจนี้ได้
Bug 3 — double(4); ไม่มี assertion บวกกับ double ที่ผิดจริง
อาการ: double ใน index.js คืนค่า n * 2 - 1 — double(4) คือ 7 ไม่ใช่ 8 — และ test ที่เรียกมันไม่เคยเช็กค่าที่คืนกลับมาเลย
หลักฐาน: transcript จริงของ node --test ด้านล่าง: ทั้งสอง test รายงานว่าผ่าน รวมถึงตัวนี้ด้วย ทั้งที่ฟังก์ชันข้างใต้ผิดจริง
วิธีแก้: assert.equal(double(4), 8); — และเมื่อมี assertion แล้ว การแก้ bug จริงใน index.js (n * 2) คือสิ่งที่ทำให้ suite เขียวอีกครั้ง
Bug 4 — setTimeout(function () { done(); }, 300); ที่ไม่มี mock.timers
อาการ: test นี้รอจริง 300 มิลลิวินาทีเพื่อพิสูจน์แค่ว่า “callback ทำงานในที่สุด” — ข้อเท็จจริงที่ไม่ต้องรอนาฬิกาจริงเพื่อยืนยันเลย
หลักฐาน: เวลาต่อ test ของ transcript จริงเอง ตามที่ยกมาด้านล่าง: test ตัวนี้ตัวเดียวใช้เวลาประมาณ 300ms ในขณะที่ test อื่นทุกตัวใน suite ใช้เวลาต่ำกว่าหนึ่งมิลลิวินาที คูณด้วยจำนวน test แบบนี้ที่ suite จริงสะสมไว้ test suite ที่ “เร็ว” จะกลายเป็นยาวหลายนาทีโดยไม่มีเหตุผลเชิง functional เลย
วิธีแก้: mock.timers ที่มากับ node:test เอง: เปิดใช้งานมัน, ลงทะเบียน setTimeout ตัวเดิม, แล้วเรียก t.mock.timers.tick(300) เพื่อเลื่อนนาฬิกาจำลองไปทันทีแทนที่จะรอนาฬิกาจริง
เวอร์ชันที่แก้แล้ว
หัวข้อที่มีชื่อว่า “เวอร์ชันที่แก้แล้ว”package.json:
{ "name": "wr4-demo", "version": "1.0.0", "type": "module", "exports": { ".": { "types": "./index.d.ts", "default": "./index.js" } }, "dependencies": { "left-pad": "^0.0.3" }, "scripts": { "test": "node --test" }}index.js:
// @skip-verify multi-file package -- imported by index.test.js below, not run standaloneexport function double(n) { return n * 2; // FIX: was n * 2 - 1}index.test.js:
// @skip-verify multi-file package -- imports ./index.js as a sibling fileimport { test } from 'node:test';import assert from 'node:assert/strict';import { double } from './index.js';
test('doubles a number', function () { assert.equal(double(4), 8); // FIX: a real, reachable assertion});
test('resolves eventually', function (t) { t.mock.timers.enable({ apis: ['setTimeout'] }); // FIX: mock the timer instead of waiting for real return new Promise(function (resolve) { setTimeout(resolve, 300); t.mock.timers.tick(300); // advances the mocked clock instantly });});เครื่องมือจับอะไรได้บ้าง
หัวข้อที่มีชื่อว่า “เครื่องมือจับอะไรได้บ้าง”npm ci ตอนที่ยังไม่มี lockfile (สภาพของ package ที่มี bug ตอน commit):
npm error code EUSAGEnpm errornpm error The `npm ci` command can only install with an existing package-lock.json ornpm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 ornpm error later to generate a package-lock.json file, then try again.npm ci ปฏิเสธที่จะรันเลยถ้าไม่มี lockfile ที่ commit ไว้ — ความล้มเหลวจริงและทันทีที่ไม่เกี่ยวกับ bug ทั้งสี่ตัวที่ปลูกไว้เลย แต่เป็นสิ่งที่ Dockerfile ที่ใช้ npm ci (ตามที่บท Dependencies แนะนำ) จะจับได้ตั้งแต่ build แรกพอดี
npm install (สร้าง lockfile) แล้ว npm ci อีกครั้ง:
npm warn deprecated [email protected]: use String.prototype.padStart()
added 1 package, and audited 2 packages in 522ms
found 0 vulnerabilitiesคำเตือน deprecated ของ npm เองเรียก left-pad ออกมาตรง ๆ — สัญญาณจริงที่เป็นอิสระ (ไม่เกี่ยวกับ bug ของช่วงเวอร์ชัน ^0.0.3) ว่า dependency ตัวนี้ไม่ควรเป็น dependency ของ package ใหม่เลยในปี 2026 เพราะ standard library ทำหน้าที่นี้แทนได้ตั้งแต่ ES2017 แล้ว
node --test บนเวอร์ชันที่มี bug:
✔ doubles a number (0.4ms)✔ resolves eventually (302.7ms)ℹ tests 2ℹ suites 0ℹ pass 2ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 367.2ทั้งสอง test ผ่าน — bug จริงของ double (คืน 7 แทนที่จะเป็น 8) มองไม่เห็นเลยสำหรับการรันนี้ และ resolves eventually เสียเวลาจริง 300+ มิลลิวินาทีเพื่อยืนยันสิ่งที่ไม่ต้องรอจริงเลย
node --test บนเวอร์ชันที่แก้แล้ว:
✔ doubles a number (0.4ms)✔ resolves eventually (0.4ms)ℹ tests 2ℹ suites 0ℹ pass 2ℹ fail 0ℹ cancelled 0ℹ skipped 0ℹ todo 0ℹ duration_ms 53.6test สองตัวเดิม หัวข้อ “2 passed” เดิม — แต่ตอนนี้ doubles a number เป็นการเช็กจริง (มันจะ fail ดัง ๆ ถ้า double กลับไปเป็นเวอร์ชันที่มี bug) และ resolves eventually เสร็จในเวลาต่ำกว่าครึ่งมิลลิวินาทีแทนที่จะเป็น 300+ เพราะ mock.timers.tick(300) เลื่อนนาฬิกาจำลองแทนที่จะรอนาฬิกาจริง