اختبار كود Vercel AI SDK باستخدام MockLanguageModelV3 (2026)
٢٠ يونيو ٢٠٢٦
لاختبار كود Vercel AI SDK دون استدعاء نموذج حقيقي، قم بحقن النموذج كمعامل (parameter) ومرر MockLanguageModelV3 من ai/test في اختباراتك. إنه يعيد استجابات حتمية لـ generateText و streamText، لذا تعمل مجموعة اختباراتك في أجزاء من الثانية بدون مفتاح API وبدون تكلفة.
ملخص
يضيف هذا الدليل العملي مجموعة اختبارات وحدة (unit-test) حقيقية لتطبيق TypeScript مبني على Vercel AI SDK. ستقوم بإنشاء مشروع باستخدام Vitest 4.1.91، وجعل ثلاث دوال صغيرة قابلة للاختبار عن طريق حقن النموذج، ثم محاكاة المخرجات المهيكلة (structured output)، واستدعاء الأدوات متعدد الخطوات، والبث (streaming) باستخدام MockLanguageModelV3 من ai/test على ai 6.0.2082. تم تنفيذ كل اختبار هنا في 20 يونيو 2026 مقابل المكتبة الحقيقية: 3 ملفات، 6 اختبارات، وجميعها ناجحة، مع نظافة tsc --strict وعدم وجود مفتاح API. الميزانية حوالي 30 دقيقة.
ما ستتعلمه
- لماذا يصعب اختبار كود AI SDK، والنمط الوحيد الذي يحل ذلك
- كيفية إنشاء مشروع TypeScript + Vitest لـ AI SDK
- كيفية جعل الكود الخاص بك قابلاً للاختبار عن طريق حقن النموذج
- كيفية محاكاة
generateTextوالتحقق من المخرجات المهيكلة - كيفية اختبار مسار الخطأ عندما يعيد النموذج بيانات غير صالحة
- كيفية التحقق من "البرومبت" (prompt) الذي أرسله الكود فعليًا إلى النموذج
- كيفية اختبار استدعاء الأدوات متعدد الخطوات باستخدام
mockValues - كيفية اختبار استجابات البث باستخدام
simulateReadableStream - كيفية مشاركة المحاكيات (mocks) مع مجموعة أدوات اختبار صغيرة ومحددة الأنواع
لماذا يصعب اختبار كود AI SDK
نماذج اللغة غير حتمية، بطيئة، وتكلف مالاً في كل استدعاء، لذا فإن الغريزة المعتادة — استدعاء النموذج في اختبار والتحقق من الإجابة — تفشل في جميع الجوانب الثلاثة. سيكون اختبارك غير مستقر (تتغير الصياغة)، بطيئًا (رحلة عبر الشبكة)، ومكلفًا (رموز لكل تشغيل). يحل AI SDK هذا باستخدام مزودي محاكاة يمكنك تبديلهم، بحيث تختبر كودك أنت — تجميع البرومبت، تحليل المخطط (schema parsing)، ربط الأدوات، معالجة البث — دون الوصول أبدًا إلى المزود.3
هناك عقبة واحدة تعيق الكثير من الدروس التعليمية القديمة على الإنترنت: تم تغيير اسم فئة المحاكاة. في AI SDK v6، تم إزالة فئات المحاكاة V2 من ai/test؛ أنت الآن تستخدم MockLanguageModelV3.4 إذا قمت بنسخ مقتطف قديم يستورد MockLanguageModelV2، فلن يكون موجودًا في ai 6.x. يثبت هذا الدليل الإصدار v6 ويستخدم مساعدي V3 الحاليين طوال الوقت.
المتطلبات الأساسية
- Node.js 18+ (حقل
enginesفي حزمةaiيسمح بـ Node 18 أو أحدث؛ تم التحقق من هذا الدليل على Node 22.22.3) - npm 10+
- الإلمام بـ
async/awaitوأساسيات TypeScript - لا حاجة لمفتاح API ولا حساب مزود — هذا هو الهدف الأساسي
الخطوة 1: إنشاء هيكل المشروع
أنشئ مشروعًا فارغًا وقم بتثبيت AI SDK بالإضافة إلى مشغل اختبارات. قم بتثبيت الإصدارات حتى تتطابق نتائجك مع هذا الدليل.
mkdir ai-sdk-testing && cd ai-sdk-testing
npm init -y
npm pkg set type=module
npm install [email protected] @ai-sdk/[email protected] [email protected]
npm install -D [email protected] [email protected] @types/[email protected]
أضف ملف tsconfig.json صارمًا. علامات الصرامة مهمة هنا: فهي تكتشف الأخطاء في أشكال المحاكاة الخاصة بك قبل تشغيل الاختبارات.
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"],
"noEmit": true
},
"include": ["src"]
}
أضف تكوين Vitest بسيطًا واثنين من نصوص npm البرمجية:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'] } });
npm pkg set scripts.test="vitest run"
npm pkg set scripts.typecheck="tsc --noEmit"
لديك الآن مشروع يقوم بفحص الأنواع تحت الوضع الصارم ويشغل الاختبارات باستخدام npm test. لا شيء هنا يتصل بمزود نموذج.
الخطوة 2: اجعل كودك قابلاً للاختبار — احقن النموذج
العادة الأساسية التي يجب تبنيها هي عدم كتابة النموذج بشكل ثابت (hard-code) داخل الدالة التي تريد اختبارها. بدلاً من ذلك، اقبله كمعامل من نوع LanguageModel. يمرر الإنتاج نموذج مزود حقيقي؛ وتمرر الاختبارات محاكاة. إليك الدالة الأولى — مساعد فرز تذاكر الدعم الذي يطلب من النموذج كائنًا محدد النوع باستخدام generateText مع Output.object.5
// src/triage.ts
import { generateText, Output, type LanguageModel } from 'ai';
import { z } from 'zod';
export const ticketSchema = z.object({
category: z.enum(['billing', 'bug', 'feature', 'other']),
priority: z.enum(['low', 'medium', 'high']),
summary: z.string(),
});
export type Ticket = z.infer<typeof ticketSchema>;
export async function triageTicket(model: LanguageModel, message: string): Promise<Ticket> {
const { output } = await generateText({
model,
system: 'You are a support triage assistant. Classify the ticket.',
prompt: message,
output: Output.object({ schema: ticketSchema }),
});
return output;
}
في الإنتاج، تقوم بربط النموذج الحقيقي في مكان واحد فقط، بحيث يظل باقي الكود الخاص بك مستقلاً عن المزود وقابلاً للاختبار:
// src/models.ts
import { openai } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
// The ONLY place a real provider is referenced. Swap in whatever chat model
// your provider currently exposes — this line is never reached in tests.
export const productionModel: LanguageModel = openai('gpt-4o-mini');
هذه هي الحيلة كلها. لأن triageTicket تأخذ model، يمكن للاختبار أن يسلمها نموذجًا وهميًا.
الخطوة 3: محاكاة generateText واختبار مخرجات مهيكلة
أنشئ src/triage.test.ts. المحاكاة هي MockLanguageModelV3 الذي تعيد دالته doGenerate الاستجابة التي تريد أن "ينتجها" النموذج. لاحظ شكل الاستجابة: content عبارة عن مصفوفة من الأجزاء، finishReason هو كائن { unified, raw }، و usage يحمل كائنات رموز متداخلة. تحت وضع TypeScript الصارم، يجب عليك تضمين كل حقل رموز — cacheRead، cacheWrite، و reasoning — حتى عندما تكون undefined، وإلا سيرفض المترجم المحاكاة.
// src/triage.test.ts
import { describe, it, expect } from 'vitest';
import { NoObjectGeneratedError } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import type { LanguageModelV3Prompt } from '@ai-sdk/provider';
import { triageTicket } from './triage.js';
describe('triageTicket', () => {
it('parses a structured ticket from the model output', async () => {
const model = new MockLanguageModelV3({
doGenerate: async () => ({
content: [{ type: 'text', text: '{"category":"billing","priority":"high","summary":"Double charged"}' ],
finishReason: { unified: 'stop', raw: undefined ,
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined ,
outputTokens: { total: 20, text: 20, reasoning: undefined ,
},
warnings: [],
}),
});
const ticket = await triageTicket(model, 'I was charged twice this month!');
expect(ticket).toEqual({ category: 'billing', priority: 'high', summary: 'Double charged' );
});
});
قم بتشغيل npm test وسينجح هذا الاختبار. "أعاد" النموذج سلسلة JSON، وقام SDK بتحليلها مقابل مخطط Zod الخاص بك، وأعادت دالتك Ticket مكتمل الأنواع — كل ذلك دون اتصال بالإنترنت.
اختبر مسار الخطأ
تثبت مجموعة الاختبارات الجيدة المسار غير السعيد أيضًا. عندما يعيد النموذج نصًا ليس بتنسيق JSON صالح، فإن generateText مع Output.object يرمي NoObjectGeneratedError (اسم الخطأ هو AI_NoObjectGeneratedError). أضف اختبارًا يحاكي ردًا غير JSON ويتحقق من الرفض. ألحقه داخل نفس كتلة describe:
it('rejects when the model returns text that is not valid JSON', async () => {
const model = new MockLanguageModelV3({
doGenerate: async () => ({
content: [{ type: 'text', text: 'sorry, I cannot help with that' ],
finishReason: { unified: 'stop', raw: undefined ,
usage: {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined ,
outputTokens: { total: 1, text: 1, reasoning: undefined ,
},
warnings: [],
),
});
await expect(triageTicket(model, 'gibberish')).rejects.toBeInstanceOf(NoObjectGeneratedError);
});
الآن لديك دليل على أن كودك يظهر خطأً محدد النوع بدلاً من إرجاع كائن معطل — وقد تحققت من ذلك دون إنفاق رمز واحد لاستفزاز نموذج حقيقي ليتصرف بشكل خاطئ.
الخطوة 4: التحقق مما أرسله كودك إلى النموذج
يجب أن تتحقق الاختبارات أيضًا من جانب المدخلات: هل أرسلت دالتك بالفعل برومبت النظام ورسالة المستخدم؟ يتلقى رد النداء doGenerate خيارات الاستدعاء، بما في ذلك prompt المجمع. التقطه وتحقق منه. أضف هذا الاختبار إلى كتلة describe:
it('sends the system prompt and user message to the model', async () => {
let captured: LanguageModelV3Prompt | undefined;
const model = new MockLanguageModelV3({
doGenerate: async ({ prompt ) => {
captured = prompt;
return {
content: [{ type: 'text', text: '{"category":"bug","priority":"low","summary":"x"}' ],
finishReason: { unified: 'stop', raw: undefined ,
usage: {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined ,
outputTokens: { total: 1, text: 1, reasoning: undefined ,
},
warnings: [],
};
},
});
await triageTicket(model, 'The export button is broken');
expect(captured?.[0]).toMatchObject({ role: 'system' );
expect(JSON.stringify(captured)).toContain('The export button is broken');
});
هذا هو المكان الذي تثبت فيه المحاكيات قيمتها في التطبيقات الحقيقية: إذا قمت بحقن سياق مسترجع لـ RAG، أو قمت ببناء برومبت نظام من إعدادات المستخدم، فإن هذا النمط يثبت أن النص الصحيح وصل إلى النموذج — وهو أمر لا يمكنك التحقق منه بشكل موثوق عندما يكون النموذج نفسه في الحلقة.
الخطوة 5: اختبار استدعاء الأدوات والوكلاء متعددي الخطوات
يعد وكلاء استدعاء الأدوات من بين أصعب الأشياء التي يمكن اختبارها لأنهم يتضمنون دورات نموذج متعددة: يطلب النموذج استدعاء أداة، ويقوم كودك بتشغيل الأداة، ثم ينتج النموذج إجابة نهائية. إليك وكيل طقس صغير يدور مع stepCountIs.6
// src/agent.ts
import { generateText, tool, stepCountIs, type LanguageModel from 'ai';
import { z } from 'zod';
export const weatherTool = tool({
description: 'Get the current temperature for a city in Celsius.',
inputSchema: z.object({ city: z.string() ),
execute: async ({ city ) => ({ city, tempC: 21 ),
});
export interface AgentReply {
text: string;
toolsUsed: string[];
}
export async function askWeatherAgent(model: LanguageModel, question: string): Promise<AgentReply> {
const result = await generateText({
model,
tools: { weather: weatherTool ,
stopWhen: stepCountIs(5),
prompt: question,
});
return {
text: result.text,
toolsUsed: result.steps.flatMap((s) => s.toolCalls).map((c) => c.toolName),
};
}
لتشغيل دورتي نموذج من محاكاة واحدة، استخدم mockValues، التي تعيد كل قيمة بالترتيب. مرر لها كائنات النتائج الخاصة بك (يساعدك مساعد generateResult من الخطوة 7 في بنائها) — الدورة الأولى هي استدعاء أداة، والثانية هي النص النهائي:
result.toolCalls و result.toolResults يعكسان فقط الخطوة الأخيرة — وهي دور النص — لذا يكونان فارغين. استدعاءات الأدوات توجد في الخطوة السابقة. هذا هو بالضبط السبب في أن askWeatherAgent يقرأ result.steps.flatMap((s) => s.toolCalls) بدلاً من result.toolCalls. إذا استمر تأكيدك (assertion) على استخدام الأدوات في العودة فارغاً، فهذا هو السبب دائماً تقريباً.
الخطوة 6: اختبار استجابات البث (Streaming)
لا يمكن تزييف البث بقيمة بسيطة تم حلها (resolved value)، لأن streamText يعيد كائناً قابلاً للتكرار بشكل غير متزامن (async iterable). توفر SDK أداة simulateReadableStream لهذا الغرض: أنت تقدم أجزاء البث (stream chunks) وهي تقوم بإعادة تشغيلها. إليك ملخص بث يرسل التغييرات (deltas) إلى المستدعي.7
// src/summarize.ts
import { streamText, type LanguageModel } from 'ai';
export async function* streamSummary(model: LanguageModel, text: string): AsyncGenerator<string> {
const result = streamText({
model,
system: 'Summarize the text in one sentence.',
prompt: text,
});
for await (const delta of result.textStream) {
yield delta;
}
}
يقوم الاختبار بمحاكاة doStream بتسلسل من أجزاء text-start، و text-delta، و text-end، و finish، ثم يجمع الأجزاء المرسلة ويعيد تجميعها:
// src/summarize.test.ts
import { describe, it, expect } from 'vitest';
import { streamSummary } from './summarize.js';
import { mockStream } from './testkit.js';
describe('streamSummary', () => {
it('yields text deltas and reassembles the full summary', async () => {
const model = mockStream(['A ', 'short ', 'summary.']);
const chunks: string[] = [];
for await (const delta of streamSummary(model, 'long input text')) chunks.push(delta);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.join('')).toBe('A short summary.');
});
});
الخطوة 7: مشاركة المحاكاة (Mocks) مع مجموعة أدوات اختبار
استجابة المحاكاة الكاملة مطولة، لذا اجعلها مركزية. يحدد ملف testkit.ts كائناً كاملاً لـ usage (مما يرضي الأنواع الصارمة في مكان واحد)، وباني generateResult المستخدم بواسطة mockValues، و mockStream لحالة البث. جعل generateResult بصيغة async يسمح لك بتمريره مباشرة إلى mockValues مع الحفاظ على توافق الأنواع.
// src/testkit.ts
import { MockLanguageModelV3 } from 'ai/test';
import { simulateReadableStream } from 'ai';
import type {
LanguageModelV3CallOptions,
LanguageModelV3Content,
LanguageModelV3Usage,
} from '@ai-sdk/provider';
// كائن استخدام كامل. تتطلب الأنواع الصارمة في v6 كل حقل توكن،
// لذا نقوم بتعيين الحقول التي لا نهتم بها إلى undefined في مكان واحد.
const usage: LanguageModelV3Usage = {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 20, text: 20, reasoning: undefined },
};
// استجابة واحدة غير بثية (نص أو استدعاءات أدوات).
// async بحيث يمكن تمريرها مباشرة إلى mockValues() للتشغيل متعدد الخطوات.
export async function generateResult(content: LanguageModelV3Content[]) {
return {
content,
finishReason: { unified: 'stop' as const, raw: undefined },
usage,
warnings: [],
};
}
// محاكاة تعيد استجابة نصية واحدة وتلتقط الموجه (prompt) الذي رأته.
export function mockText(text: string, onCall?: (opts: LanguageModelV3CallOptions) => void) {
return new MockLanguageModelV3({
doGenerate: async (options) => {
onCall?.(options);
return generateResult([{ type: 'text', text }]);
},
});
}
// محاكاة تبث النص المعطى كعدة تغييرات (deltas).
export function mockStream(deltas: string[]) {
return new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-start', id: 't1' },
...deltas.map((delta) => ({ type: 'text-delta' as const, id: 't1', delta })),
{ type: 'text-end', id: 't1' },
{ type: 'finish', finishReason: { unified: 'stop' as const, raw: undefined }, usage },
],
}),
}),
});
}
مع وجود مجموعة الأدوات، تظل الاختبارات الجديدة قصيرة: mockText('...') لإجابة واحدة، و mockStream([...]) للبث، و generateResult([...]) داخل mockValues(...) للوكلاء متعددي الخطوات.
التحقق
قم بفحص الأنواع وتشغيل المجموعة. مع عدم تعيين مفتاح API وعدم وجود وصول للشبكة لأي مزود، ينجح كل شيء:
npm run typecheck # tsc --noEmit, strict
npm test # vitest run
المخرجات المتوقعة:
✓ src/agent.test.ts (2 tests)
✓ src/summarize.test.ts (1 test)
✓ src/triage.test.ts (3 tests)
Test Files 3 passed (3)
Tests 6 passed (6)
بما أن هذه الاختبارات لا تحتاج إلى بيانات اعتماد وتنتهي في أقل من ثانية، ضع npm run typecheck && npm test مباشرة في نفس وظيفة CI التي تدير اختبارات الوحدة الأخرى. لا يوجد شيء يتوقف على سر (secret) ولا توجد فواتير، لذا تحصل ميزات AI الخاصة بك على نفس شبكة أمان طلبات السحب (pull-request) مثل بقية الكود.
الأخطاء الشائعة
TS2724: '"ai/test"' has no exported member named 'MockLanguageModelV2'. Did you mean 'MockLanguageModelV3'? تمت إزالة فئات محاكاة V2 في AI SDK v6. استورد MockLanguageModelV3 بدلاً من ذلك — شكل الاستدعاء تغير أيضاً، لذا قم بتحديث كائن الاستجابة كما هو موضح في الخطوة 3.4
خطأ TS: الخصائص cacheRead، cacheWrite مفقودة. يتطلب نوع LanguageModelV3Usage الصارم كل حقل توكن. قم بتضمين cacheRead: undefined و cacheWrite: undefined في inputTokens، و reasoning: undefined في outputTokens، أو أعد استخدام ثابت usage الواحد من مجموعة أدوات الاختبار.
Cannot read properties of undefined (reading 'inputTokens') من mockValues. لقد قمت بتمرير دوال async () => ({...}) إلى mockValues. هي تعيد كل قيمة كما هي، لذا تستقبل SDK دالة بدلاً من نتيجة. مرر كائنات النتائج (أو باني async generateResult)، وليس دوال السهم التي تعيدها.
result.toolCalls فارغ بعد تشغيل أداة. هذه الحقول تحتفظ بالخطوة النهائية فقط، وهي دور النص. اقرأ استدعاءات الأدوات من الخطوات: result.steps.flatMap((s) => s.toolCalls).
اختبار البث بطيء، أو فشل تأكيدات finishReason/usage. تأخيرات simulateReadableStream الافتراضية هي 0، لذا اترك initialDelayInMs/chunkDelayInMs غير محددة في اختبارات الوحدة — القيم غير الصفرية (غالباً ما تُنسخ من عرض واجهة مستخدم بث) تتراكم عبر الأجزاء ويمكن أن تتجاوز مهلة Vitest. وإذا كنت تؤكد على finishReason أو usage، فقم بتضمين جزء finish: بدونه يكتمل البث، لكن finishReason يعود كـ 'other' وتكون إجماليات الاستخدام غير متاحة.
الخطوات التالية ومزيد من القراءة
تثبت اختبارات الوحدة القائمة على المحاكاة أن الكود الخاص بك يتصرف بشكل صحيح بالنظر إلى استجابة نموذج معروفة. هي لا تقيس ما إذا كانت موجهاتك تنتج إجابات جيدة من نموذج حقيقي — هذه هي وظيفة التقييمات (evals)، والتي يمكنك ربطها بـ CI كما هو موضح في دليلنا لاختبار موجهات LLM باستخدام promptfoo. استخدم كليهما: اختبارات الوحدة للمنطق، والتقييمات للجودة. إذا كنت جديداً على SDK نفسها، فابدأ بـ شرح Vercel AI SDK العملي الأوسع، وبالنسبة لعادات تصميم الاختبارات العامة، فإن دليل استراتيجيات اختبار الوحدة يتناسب جيداً مع كل ما سبق.
من هنا، قم بتوسيع المجموعة: أكد على result.usage.totalTokens للحماية من تراجع التكلفة، أو اختبر دالة execute للأداة بشكل منفصل، أو استخدم mockValues لمحاكاة نموذج يعيد المحاولة بعد استدعاء غير صحيح. كل اختبار جديد لا يكلف شيئاً لتشغيله ويصطاد فئة حقيقية من الأخطاء قبل أن تصل إلى المستخدم.
حواشي
-
Vitest على npm (4.1.9). https://www.npmjs.com/package/vitest ↩
-
aiعلى npm — 6.0.208 هو وسم التوزيعlatestاعتباراً من 20 يونيو 2026. https://www.npmjs.com/package/ai ↩ -
Vercel — AI SDK Core: الاختبار (مزودو المحاكاة والمساعدون:
MockLanguageModelV3،mockId،mockValues،simulateReadableStream). https://ai-sdk.dev/docs/ai-sdk-core/testing ↩ -
Vercel — الترقية من AI SDK 5.x إلى 6.0 (تمت إزالة فئات محاكاة V2 من
ai/test؛ استخدم فئات V3). https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0 ↩ ↩2 -
Vercel — AI SDK Core: توليد البيانات المهيكلة باستخدام
Output.object. https://ai-sdk.dev/docs/ai-sdk-core/generating-structured-data ↩ -
Vercel — AI SDK Core: استدعاء الأدوات والحلقات متعددة الخطوات باستخدام
stopWhen/stepCountIs. https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling ↩
Vercel — AI SDK Core: مرجع streamText. https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text ↩