آزمون خودکار API؛ موفقیت، شکست و حفظ داده
در این درس ۶ بخش
آزمایش دستی درس قبل برای فهم جریان لازم بود؛ اکنون همان انتظارها را به آزمون قابل تکرار تبدیل میکنیم. قرار نیست فقط نتیجهٔ سبز بگیریم. باید ثابت کنیم ورودی خراب رد میشود، دادهٔ قبلی پس از ردشدن حفظ میشود و هر اجرای تازه از وضعیت مشخص شروع میکند. پروژه از node:test و assert داخلی Node.js استفاده میکند؛ نیازی به Jest یا کتابخانهٔ آزمون بیرونی نداریم. ابتدا فایل کامل زیر را کنار app.mjs ذخیره کنید.
فایل app.test.mjs؛ مجموعهٔ کامل قابل اجرا
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { request } from 'node:http';
import { makeServer } from './app.mjs';
import { makeStore } from './model.mjs';
async function withServer(run) {
const server = makeServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
try { await run(base); }
finally { await new Promise(resolve => server.close(resolve)); }
}
const post = (base, body, headers = { 'Content-Type': 'application/json' }) =>
fetch(`${base}/tasks`, { method: 'POST', headers, body });
test('health, JSON type and no-store', () => withServer(async base => {
const response = await fetch(`${base}/health`);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /application\/json/);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.deepEqual(await response.json(), { ok: true });
}));
test('new server starts with empty list', () => withServer(async base => {
assert.deepEqual(await (await fetch(`${base}/tasks`)).json(), { tasks: [] });
}));
test('create, list, read and update a Persian task', () => withServer(async base => {
const created = await post(base, JSON.stringify({ title: ' یادگیری سرور ' }));
assert.equal(created.status, 201);
const { task } = await created.json();
assert.equal(task.title, 'یادگیری سرور');
assert.equal(task.done, false);
assert.match(task.id, /^[0-9a-f-]{36}$/);
assert.equal(created.headers.get('location'), `/tasks/${task.id}`);
assert.deepEqual((await (await fetch(base + created.headers.get('location'))).json()).task, task);
const changed = await fetch(`${base}/tasks/${task.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: '{"done":true}'
});
assert.equal(changed.status, 200);
assert.equal((await changed.json()).task.done, true);
assert.equal((await (await fetch(`${base}/tasks`)).json()).tasks[0].done, true);
}));
test('malformed JSON is 400 and does not create data', () => withServer(async base => {
assert.equal((await post(base, '{')).status, 400);
assert.deepEqual(await (await fetch(`${base}/tasks`)).json(), { tasks: [] });
}));
test('empty JSON body is 400', () => withServer(async base => {
assert.equal((await post(base, '')).status, 400);
}));
test('invalid UTF-8 is 400', () => withServer(async base => {
assert.equal((await post(base, Buffer.from([0xff]))).status, 400);
}));
test('wrong content type is 415', () => withServer(async base => {
assert.equal((await post(base, '{"title":"hello"}', { 'Content-Type': 'text/plain' })).status, 415);
}));
test('compressed body is outside this contract', () => withServer(async base => {
assert.equal((await post(base, '{}', {
'Content-Type': 'application/json', 'Content-Encoding': 'gzip'
})).status, 415);
}));
test('body limit is bytes, including multibyte Persian', () => withServer(async base => {
const body = JSON.stringify({ title: 'س'.repeat(5000) });
assert.ok(Buffer.byteLength(body) > 8192);
assert.equal((await post(base, body)).status, 413);
}));
test('chunked oversized body receives 413 JSON', () => withServer(base => new Promise((resolve, reject) => {
const req = request(`${base}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }
}, response => {
let body = '';
response.setEncoding('utf8');
response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try {
assert.equal(response.statusCode, 413);
assert.equal(JSON.parse(body).error, 'BODY_TOO_LARGE');
resolve();
} catch (error) { reject(error); }
});
});
req.on('error', reject);
req.write('x'.repeat(5000));
req.end('x'.repeat(5000));
})));
for (const [name, input] of [
['array', []], ['null', null], ['number title', { title: 42 }],
['blank title', { title: ' ' }], ['long title', { title: 'x'.repeat(101) }],
['extra field', { title: 'hello', admin: true }]
]) {
test(`reject ${name} with 422`, () => withServer(async base => {
assert.equal((await post(base, JSON.stringify(input))).status, 422);
}));
}
test('unknown route is 404', () => withServer(async base => {
assert.equal((await fetch(`${base}/unknown`)).status, 404);
}));
test('unsupported method has 405 and Allow header', () => withServer(async base => {
const response = await fetch(`${base}/tasks`, { method: 'DELETE' });
assert.equal(response.status, 405);
assert.equal(response.headers.get('allow'), 'GET, POST');
}));
test('missing task is 404', () => withServer(async base => {
assert.equal((await fetch(`${base}/tasks/00000000-0000-0000-0000-000000000000`)).status, 404);
}));
test('done must be boolean, rejected update preserves task', () => withServer(async base => {
const { task } = await (await post(base, '{"title":"hello"}')).json();
const response = await fetch(`${base}/tasks/${task.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: '{"done":"true"}'
});
assert.equal(response.status, 422);
assert.equal((await (await fetch(`${base}/tasks/${task.id}`)).json()).task.done, false);
}));
test('each server owns its own in-memory state', async () => {
await withServer(async base => { assert.equal((await post(base, '{"title":"hello"}')).status, 201); });
await withServer(async base => { assert.deepEqual(await (await fetch(`${base}/tasks`)).json(), { tasks: [] }); });
});
test('store returns copies and enforces maximum count', () => {
const store = makeStore();
const first = store.create({ title: 'hello' });
first.title = 'changed outside';
assert.equal(store.get(first.id).title, 'hello');
const list = store.list(); list[0].done = true;
assert.equal(store.get(first.id).done, false);
for (let i = 1; i < 1000; i++) store.create({ title: `task ${i}` });
assert.throws(() => store.create({ title: 'overflow' }), error => error.status === 409);
});
withServer برای هر آزمون سرور تازه میسازد، روی 127.0.0.1 و پورت صفر گوش میدهد و پورت واقعی انتخابشده را میخواند. بنابراین لازم نیست پورت برنامهٔ اصلی را آزاد کنید یا آزمون را به یک سرور دستی روشن وابسته کنید. finally سرور را حتی پس از شکست assertion میبندد. اگر این پاکسازی حذف شود، فرایند آزمون میتواند پس از نمایش نتیجه باز بماند؛ بازبودن ترمینال در آزمون برخلاف اجرای سرور، نیازمند بررسی منبع باز است.
از HTTP واقعی تا خطای نوع داده
بیشتر آزمونها با fetch به یک سرور واقعی محلی وصل میشوند؛ بنابراین فقط منطق یک تابع جدا بررسی نشده است. جریان ساخت، خواندن و تغییر یک عنوان فارسی آزمایش میشود و Location پاسخ نیز با شناسهٔ ساختهشده تطبیق دارد. درخواست JSON خراب، بدنهٔ خالی، UTF-8 نامعتبر، نوع محتوای غلط و بدنهٔ فشرده همگی مسیرهای متفاوتیاند. یکیکردن همهٔ آنها در آزمونی با نام «خطاها کار میکنند» پیداکردن علت شکست را دشوار میکند.
آزمون بدنهٔ بزرگ دو نکته دارد: شمارش بایت با شمارش نویسه تفاوت دارد و بدنه ممکن است chunked باشد. برای مورد دوم از node:http و دو بار نوشتن بخشهای بدنه استفاده شده است؛ نتیجه باید پاسخ JSON با ۴۱۳ باشد. این آزمون ثابت میکند همین نمونهٔ محدود با این ورودی کار میکند؛ آزمون فشار، بررسی جامع امنیت یا تضمین رفتار در حملهٔ شبکهای نیست. آن ادعاها به روش اندازهگیری و محیط جداگانه نیاز دارند.
چرا فقط بررسی status کافی نیست؟
اگر PATCH نامعتبر ۴۲۲ بدهد ولی پیش از پاسخ مقدار داده را عوض کرده باشد، مشتری ظاهراً خطا دیده و برنامه واقعاً خراب شده است. به همین دلیل آزمون مقدار done را دوباره میخواند و حفظ false را بررسی میکند. آزمون مدل نیز نشان میدهد تغییر شیء خروجی create یا list به حافظهٔ داخلی نشت نمیکند. در هر مسئله، اثر جانبی مهم را بنویسید؛ بررسی وجود یک کلمه در پیام خطا بهتنهایی جای این مشاهده را نمیگیرد.
اجرا و خواندن نتیجه
npm.cmd run check
npm.cmd testدر نسخهٔ همراه ۲۲ آزمون تعریف شده و روی Node.js نسخهٔ 24.22.1 در محیط Linux اجرا و همگی موفق شدهاند. زمان اجرا روی دستگاهها متفاوت است؛ مدت یا قالب دقیق نمایش را معیار قبول قرار ندهید. نصب ویندوز، محیط PowerShell و رفتار رابط VS Code روی این میزبان اجرا نشدهاند؛ دستورهای ویندوز از نظر مستندات و قالب بازبینی شدهاند. هنگام اجرای محلی شما، نام آزمون شکستخورده و نخستین expected/actual مرتبط، نقطهٔ شروع عیبیابی است.
check فقط نحو فایلها را بررسی میکند و importهای اجرایی یا قرارداد HTTP را بهتنهایی تضمین نمیکند؛ test آنها را واقعاً به کار میگیرد. برعکس، عبور آزمونها هم دربارهٔ رفتاری که ننوشتهایم چیزی ثابت نمیکند. این مجموعه پایگاه داده، احراز هویت، CORS، TLS و استقرار عمومی ندارد و آنها را آزمایش نمیکند. اگر ویژگی تازهای اضافه کنید، باید انتظار موفق و ناموفق آن را نیز به مجموعه اضافه کنید؛ سبزبودن آزمونهای قدیمی اجازهٔ حدسزدن دربارهٔ ویژگی جدید نیست.
تمرین و پاسخ راهنما
برای PATCH دارای done:true و یک فیلد اضافی آزمون تازه بنویسید. ابتدا کاری بسازید، درخواست را بفرستید، انتظار ۴۲۲ را بررسی کنید و با GET ثابت کنید done هنوز false است. پاسخ راهنما استفاده از همان الگوی آزمون نوع نامعتبر است، با بدنهٔ {"done":true,"title":"changed"}. معیار تحویل یک آزمون است که اگر کنترل exactObject را عمداً حذف کنید شکست بخورد. پس از این آزمایش موقت، کد اصلی را برگردانید و کل مجموعه را دوباره اجرا کنید.
مرجع رسمی
آزمون داخلی Node.js: https://nodejs.org/docs/latest-v24.x/api/test.html
assert برای مقایسهٔ انتظار و نتیجه: https://nodejs.org/docs/latest-v24.x/api/assert.html
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
متن درسهای عمومی را بدون ورود بخوانید.