الحالة المعقدة والجلب والاختبار
لنواصل توسيع نسخة Zustand من تطبيق الملاحظات.
لتسهيل التطوير، سنغيّر الحالة الأولية بحيث تحتوي مسبقاً على بضع ملاحظات:
const initialNotes = [
{
id: 1,
content: 'Zustand is less complex than Redux',
important: true,
}, {
id: 2,
content: 'React app benefits from custom hooks',
important: false,
}, {
id: 3,
content: 'Remember to sleep well',
important: true,
}
]
const useNoteStore = create((set) => ({
notes: initialNotes,
// ...
}
حالة أكثر تعقيداً
لننفّذ ترشيح الملاحظات المعروضة في التطبيق، بما يتيح تقييد الملاحظات الظاهرة. يُنفَّذ الترشيح باستخدام أزرار الراديو (radio buttons):

يبرز السؤال حول أفضل طريقة للتعامل مع إدارة حالة المرشّح. يوجد أساساً خياران: إنشاء store منفصل في Zustand للمرشّح، أو إضافته إلى الـ store الحالي. وكلا الحلّين مبرَّر. توصي أفضل الممارسات الموجودة على الإنترنت بإبقاء الأشياء غير المترابطة في stores منفصلة تماماً. ومع ذلك، فإن قائمة الملاحظات والترشيح مرتبطان ارتباطاً وثيقاً بما يكفي لوضع كليهما في الـ store نفسه:
const useNoteStore = create((set) => ({
notes: initialNotes,
filter: 'all',
actions: {
add: note => set(
state => ({ notes: state.notes.concat(note) })
),
toggleImportance: id => set(
state => ({
notes: state.notes.map(note =>
note.id === id ? { ...note, important: !note.important } : note
)
})
),
setFilter: value => set(() => ({ filter: value }))
}
}))
export const useNotes = () => useNoteStore((state) => state.notes)
export const useFilter = () => useNoteStore((state) => state.filter)
export const useNoteActions = () => useNoteStore((state) => state.actions)
المكوّن الذي يضبط قيمة المرشّح:
import { useNoteActions } from './store'
const VisibilityFilter = () => {
const { setFilter } = useNoteActions()
return (
<div>
<input
type="radio"
name="filter"
onChange={() => setFilter('all')}
defaultChecked
/>
all
<input
type="radio"
name="filter"
onChange={() => setFilter('important')}
/>
important
<input
type="radio"
name="filter"
onChange={() => setFilter('nonimportant')}
/>
not important
</div>
)
}
export default VisibilityFilter
يعرض مكوّن App المرشّح:
const App = () => (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
يمكن التعامل مع ترشيح الملاحظات المعروضة في مكوّن NoteList على النحو التالي مثلاً:
import { useNotes, useFilter } from './store'
import Note from './Note'
const NoteList = () => {
const notes = useNotes()
const filter = useFilter()
const notesToShow = notes.filter(note => {
if (filter === 'important') return note.important
if (filter === 'nonimportant') return !note.important
return true
})
return (
<ul>
{notesToShow.map(note => (
<Note key={note.id} note={note} />
))}
</ul>
)
}
الحل الأكثر أناقة هو تضمين منطق الترشيح مباشرةً في دالة useNotes الخاصة بالـ store:
import { create } from 'zustand'
const useNoteStore = create((set) => ({
// ...
}))
export const useNotes = () => {
const notes = useNoteStore((state) => state.notes)
const filter = useNoteStore((state) => state.filter)
if (filter === 'important') return notes.filter(n => n.important)
if (filter === 'nonimportant') return notes.filter(n => !n.important)
return notes
}
وهكذا تُعيد دالة useNotes دائماً قائمة ملاحظات مرشَّحة بالطريقة المطلوبة. بل إن مستخدِم الدالة، وهو مكوّن NoteList، لا يحتاج حتى إلى معرفة أن المرشّح موجود:
import { useNotes } from './store'
import Note from './Note'
const NoteList = () => {
// يحصل المكوّن دائماً على مجموعة الملاحظات المرشَّحة بشكل صحيح
const notes = useNotes()
return (
<ul>
{notes.map(note => (
<Note key={note.id} note={note} />
))}
</ul>
)
}
الحل أنيق!
حل بديل ممكن
يتمثل البديل في تنفيذ الترشيح مباشرةً داخل دالة المحدِّد (selector)، بحيث تُقرأ كل من الملاحظات والمرشّح في استدعاء واحد لـ useNoteStore:
export const useNotes = () => useNoteStore(({ notes, filter }) => { if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important)
return notes })
لكن هذا الأسلوب لا يعمل، لأنه يؤدي إلى حلقة إعادة عرض لا نهائية عند تغيير المرشّح.
والسبب كما يلي: يقارن Zustand القيمة المُعادة من المحدِّد باستخدام المعامل ===. ولأن notes.filter(...) تُنشئ مصفوفة جديدة في كل عرض، يفسّرها React دائماً على أنها حالة جديدة ويُطلق عرضاً آخر، يُنشئ بدوره مصفوفة جديدة، وهكذا دواليك.
الحل هو إضافة useShallow، التي تستبدل مقارنة === بمقارنة سطحية: فهي تقارن عناصر المصفوفة واحداً واحداً. وإذا لم يتغير المحتوى، تُعيد مرجع المصفوفة القديمة بدلاً من مصفوفة جديدة، فيرى React أن الحالة مستقرة ولا يعيد العرض.
import { useShallow } from 'zustand/react/shallow'
//...
export const useNotes = () => useNoteStore(useShallow(({ notes, filter }) => { if (filter === 'important') return notes.filter(n => n.important) if (filter === 'nonimportant') return notes.filter(n => !n.important)
return notes }))
الحل يعمل، لكنه أصعب قليلاً في الفهم. نستخدم في مادة المقرر النسخة المعروضة سابقاً باستدعاءين منفصلين لـ useNoteStore.
الشيفرة الحالية للتطبيق متاحة كاملة على GitHub، في الفرع part6-3.
7. anecdotes، الخطوة 5
البيانات إلى الخادم
لنوسّع التطبيق بحيث تُخزَّن الملاحظات في الواجهة الخلفية. سنستخدم JSON Server المألوف من الجزء الثاني.
احفظ الحالة الأولية لقاعدة البيانات في ملف db.json في جذر المشروع:
{
"notes": [
{
"id": 1,
"content": "Zustand is less complex than Redux",
"important": true
},
{
"id": 2,
"content": "React app benefits from custom hooks",
"important": false
},
{
"id": 3,
"content": "Remember to sleep well",
"important": true
}
]
}
ثبّت JSON Server:
npm install json-server --save-dev
وأضف السطر التالي إلى قسم scripts في package.json:
"scripts": {
"server": "json-server -p 3001 db.json",
// ...
}
شغّل JSON Server بالأمر npm run server.
Fetch API
في تطوير البرمجيات، غالباً ما يتعين عليك التفكير فيما إذا كانت ميزة معينة تُنفَّذ باستخدام مكتبة خارجية أم بالاستفادة من الحلول الأصلية التي توفرها البيئة. ولكل من الأسلوبين مزاياه وتحدياته.
استخدمنا في أجزاء سابقة من هذا المقرر مكتبة Axios لإجراء طلبات HTTP. لنألف الآن طريقة بديلة لإجراء طلبات HTTP باستخدام Fetch API الأصلية.
من المعتاد أن تكون مكتبة خارجية مثل Axios مبنية باستخدام مكتبات خارجية أخرى. فمثلاً، إذا ثبّتت Axios في مشروع بالأمر npm install axios، يكون ناتج الطرفية:
$ npm install axios
added 23 packages, and audited 302 packages in 1s
71 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
إذن لن يثبّت الأمر مكتبة Axios فقط، بل أكثر من 20 حزمة npm أخرى تحتاجها Axios لتعمل.
توفّر Fetch API طريقة مشابهة لإجراء طلبات HTTP كما في Axios، لكن استخدام Fetch API لا يتطلب تثبيت مكتبات خارجية. وتصبح صيانة التطبيق أسهل عندما تكون المكتبات الواجب تحديثها أقل، كما يتحسن الأمان لأن سطح الهجوم المحتمل للتطبيق يقل. يُتناول أمان التطبيقات وصيانتها في الجزء السابع من المقرر.
تُجرى الطلبات عملياً باستخدام دالة fetch(). ولهذه الصيغة بعض الاختلافات مقارنةً بـ Axios. وسنلاحظ قريباً أن Axios اعتنى ببعض الأمور نيابةً عنا ويسّر حياتنا. غير أننا سنستخدم Fetch API الآن لأنه حل أصلي واسع الاستخدام ينبغي أن يكون كل مطوّر Full Stack على دراية به.
جلب البيانات من الخادم
لننشئ دالة تجلب البيانات من الواجهة الخلفية في ملف src/services/notes.js:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
const data = await response.json()
return data
}
export default { getAll }
لنلقِ نظرة أدق على تنفيذ دالة getAll. تُجلب الملاحظات الآن من الواجهة الخلفية باستدعاء دالة fetch() التي أُعطي لها عنوان URL الخاص بالواجهة الخلفية كوسيط. ولم يُحدَّد نوع الطلب بشكل منفصل، لذا تنفّذ fetch الإجراء الافتراضي، وهو طلب GET.
عند وصول الاستجابة، نتحقق مما إذا كان الطلب قد نجح بالنظر إلى حقل response.ok ونرفع خطأً عند الحاجة:
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
تأخذ السمة response.ok القيمة true إذا نجح الطلب، أي إذا كان رمز حالة الاستجابة ضمن النطاق 200-299. أما لجميع رموز الحالة الأخرى، مثل 404 أو 500، فتأخذ القيمة false.
لاحظ أن fetch لا ترفع خطأً تلقائياً حتى إذا كان رمز حالة الاستجابة، مثلاً، 404. يجب تنفيذ معالجة الأخطاء يدوياً، كما فعلنا الآن.
إذا نجح الطلب، تُحوَّل البيانات الواردة في الاستجابة إلى صيغة JSON:
const data = await response.json()
لا تحوّل fetch تلقائياً البيانات التي قد تصحب الاستجابة إلى صيغة JSON؛ بل يجب إجراء التحويل يدوياً. ويجدر بالذكر أيضاً أن response.json() دالة غير متزامنة، لذا يجب استخدام الكلمة المفتاحية await معها.
لنبسّط الشيفرة قليلاً بإعادة البيانات التي تُعيدها دالة response.json() مباشرةً:
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()
}
لنضف إلى الـ store دالة يمكن استخدامها لتهيئة الحالة بملاحظات مجلوبة من الخادم:
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
// ...
setFilter: value => set(() => ({ filter: value })),
initialize: notes => set(() => ({ notes }))
}
}))
لننفّذ تهيئة الملاحظات في مكوّن App. وكما جرت العادة عند جلب البيانات من الخادم، نستخدم خطاف useEffect:
const App = () => {
const { initialize } = useNoteActions()
useEffect(() => {
noteService.getAll().then(notes => initialize(notes))
}, [initialize])
return (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
}
وهكذا تُجلب الملاحظات من الخادم باستخدام دالة getAll() التي عرّفناها، ثم تُخزَّن باستخدام دالة initialize التابعة للـ store. وتُنفَّذ هذه الإجراءات في خطاف useEffect، ما يعني أنها تُنفَّذ أثناء العرض الأول لمكوّن App.
لنلقِ نظرة أدق على تفصيل صغير واحد. لقد أضفنا دالة initialize إلى مصفوفة اعتماديات خطاف useEffect. وإذا حاولنا استخدام مصفوفة اعتماديات فارغة، يعطينا ESLint التحذير التالي: React Hook useEffect has a missing dependency: 'initialize'. فما الذي يحدث؟
ستعمل الشيفرة منطقياً بالطريقة نفسها تماماً حتى لو استخدمنا مصفوفة اعتماديات فارغة، لأن initialize تشير إلى الدالة نفسها طوال تنفيذ البرنامج. ومع ذلك، من ممارسات البرمجة الجيدة إضافة جميع المتغيرات والدوال المستخدمة في خطاف useEffect والمعرَّفة داخل المكوّن إلى الاعتماديات. فهذا يساعد على تجنب أخطاء غير متوقعة.
إرسال البيانات إلى الخادم
لننفّذ بعد ذلك وظيفة إرسال ملاحظة جديدة إلى الخادم. وفي الوقت نفسه يمكننا التدرّب على كيفية إجراء طلب POST باستخدام دالة fetch().
لنوسّع شيفرة التواصل مع الخادم في src/services/notes.js كما يلي:
const baseUrl = 'http://localhost:3001/notes'
const getAll = async () => {
const response = await fetch(baseUrl)
if (!response.ok) {
throw new Error('Failed to fetch notes')
}
return await response.json()
}
const createNew = async (content) => {
const response = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, important: false }),
})
if (!response.ok) {
throw new Error('Failed to create note')
}
return await response.json()
}
export default { getAll, createNew }
لنلقِ نظرة أدق على تنفيذ دالة createNew. يحدّد الوسيط الأول لدالة fetch() عنوان URL الذي يُوجَّه إليه الطلب. أما الوسيط الثاني فهو كائن يحدّد التفاصيل الأخرى للطلب، مثل نوع الطلب والترويسات والبيانات المُرسَلة مع الطلب. ويمكننا زيادة توضيح الشيفرة بتخزين الكائن الذي يحدّد تفاصيل الطلب في متغير مساعد منفصل options:
const createNew = async (content) => {
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, important: false }),
}
const response = await fetch(baseUrl, options)
const response = await fetch(baseUrl, options)
if (!response.ok) {
throw new Error('Failed to create note')
}
return await response.json()
}
لنلقِ نظرة أدق على كائن options:
- method يحدّد نوع الطلب، وهو في هذه الحالة POST
- headers يحدّد ترويسات الطلب. نُرفق بالطلب الترويسة 'Content-Type': 'application/json' لكي يعرف الخادم أن البيانات المضمّنة مع الطلب بصيغة JSON، ويتمكن من التعامل مع الطلب بشكل صحيح
- body يحتوي على البيانات التي ستُرسَل مع الطلب. لا يمكن أن يحتوي الحقل على كائن JavaScript مباشرةً، بل يجب أولاً تحويله إلى نص JSON باستدعاء JSON.stringify()
وكما في طلب GET، نتحقق هنا أيضاً من رمز حالة الاستجابة بحثاً عن أخطاء:
if (!response.ok) {
throw new Error('Failed to create note')
}
إذا نجح الطلب، يُعيد JSON Server الملاحظة التي أُنشئت للتو، وقد ولّد لها أيضاً id فريداً. ولا تزال البيانات الواردة في الاستجابة بحاجة إلى التحويل إلى صيغة JSON باستخدام دالة response.json():
return await response.json()
لنغيّر بعد ذلك مكوّن NoteForm في تطبيقنا بحيث تُرسَل الملاحظة الجديدة إلى الواجهة الخلفية. وتتغير دالة addNote في المكوّن قليلاً:
import { useNoteActions } from './store'
import noteService from './services/notes'
const NoteForm = () => {
const { add } = useNoteActions()
const addNote = async (e) => {
e.preventDefault()
const content = e.target.note.value
const newNote = await noteService.createNew(content)
add(newNote)
e.target.reset()
}
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}
export default NoteForm
عند إنشاء ملاحظة جديدة في الواجهة الخلفية باستدعاء دالة createNew()، نحصل في المقابل على كائن يصف الملاحظة، وقد ولّدت الواجهة الخلفية له id.
الشيفرة الحالية للتطبيق متاحة كاملة على GitHub، في الفرع part6-4.
الـ actions غير المتزامنة
مقاربتنا جيدة إلى حد كبير، لكنها مؤسفة من جهة واحدة، إذ يحدث التواصل مع الخادم داخل شيفرة الدوال التي تعرّف المكوّنات. وسيكون أفضل لو أمكن تجريد التواصل بعيداً عن المكوّنات، بحيث لا تحتاج إلا إلى استدعاء دالة مناسبة يوفّرها الـ store.
نريد أن يهيّئ App حالة التطبيق كما يلي:
const App = () => {
const { initialize } = useNoteActions()
useEffect(() => {
initialize()
}, [initialize])
return (
<div>
<NoteForm />
<VisibilityFilter />
<NoteList />
</div>
)
}
ويُنشئ NoteForm بدوره ملاحظة جديدة هكذا:
const NoteForm = () => {
const { add } = useNoteActions()
const addNote = async (e) => {
e.preventDefault()
const content = e.target.note.value
await add(content)
e.target.reset()
}
return (
<form onSubmit={addNote}>
<input name="note" />
<button type="submit">add</button>
</form>
)
}
أما التغيير في store.js فهو كما يلي:
import { create } from 'zustand'
import noteService from './services/notes'
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content)
set(state => ({ notes: state.notes.concat(newNote) }))
},
initialize: async () => {
const notes = await noteService.getAll()
set(() => ({ notes }))
},
// ...
}
}))
وهكذا حُوّلت الدالتان add وinitialize إلى دالتين غير متزامنتين، تستدعيان أولاً دالة noteService المناسبة، ثم تحدّثان الحالة.
الحل أنيق، إذ أصبحت إدارة الحالة والتواصل مع الخادم منفصلين تماماً خارج مكوّنات React.
لنُكمل التطبيق بمزامنة تغييرات تبديل الأهمية مع الخادم.
نوسّع noteService.js كما يلي:
const update = async (id, note) => {
const response = await fetch(`${baseUrl}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
})
if (!response.ok) {
throw new Error('Failed to update note')
}
return await response.json()
}
export default { getAll, createNew, update }
أما التغيير في دالة toggleImportance التابعة للـ store فهو كما يلي:
const useNoteStore = create((set) => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content)
set(state => ({ notes: state.notes.concat(newNote) }))
},
toggleImportance: async (id) => {
const note = useNoteStore.getState().notes.find(n => n.id === id)
const updated = await noteService.update(
id, { ...note, important: !note.important }
)
set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
setFilter: value => set(() => ({ filter: value })),
initialize: async () => {
const notes = await noteService.getAll()
set(() => ({ notes }))
}
}
}))
ثمة تفصيل جدير بالملاحظة في الدالة الجديدة. تستقبل الدالة معرّف الملاحظة (id) كوسيط. لكن يجب إرسال الملاحظة المعدَّلة إلى الواجهة الخلفية. ويمكن العثور عليها باستدعاء دالة getState التابعة للـ store:
const note = useNoteStore.getState().notes.find(n => n.id === id)
تحتوي stores الخاصة بـ Zustand أيضاً على عدد من الدوال المساعدة الأخرى، قد تكون مفيدة في بعض الحالات.
لنغيّر أيضاً تعريف الـ store بحيث نمرّر كذلك الوسيط get إلى الدالة المُعطاة لـ create، والذي يمكننا من خلاله الوصول إلى قيم الحالة عند الحاجة:
const useNoteStore = create((set, get) => ({
notes: [],
filter: '',
actions: {
toggleImportance: async (id) => {
const note = get().notes.find(n => n.id === id)
const updated = await noteService.update(
id, { ...note, important: !note.important }
)
set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
// ...
}
}))
تُعيد دالة get الحالة الحالية للـ store. فمثلاً، يعطينا الاستدعاء get().notes ملاحظات الـ store الحالية. ودالة get مكافئة وظيفياً لاستدعاء useNoteStore.getState()، لكنها الطريقة الأكثر أصالة للإشارة إلى حالة الـ store من داخل دوال الـ store نفسها.
شيفرة التطبيق على GitHub في الفرع part6-5.
8. anecdotes، الخطوة 6
9. anecdotes، الخطوة 7
10. anecdotes، الخطوة 8
11. anecdotes، الخطوة 9
12. anecdotes، الخطوة 10
13. فحص anecdotes
الوسيطات
عند تطوير تطبيق، تصادفك غالباً حالات يصعب فيها فهم سبب سلوك التطبيق على نحو غير متوقع. فتتغير الحالة نتيجة استدعاء دالة action ما، لكن لا يتضح أي استدعاء غيّر ماذا وبأي ترتيب. ولا يساعد التسجيل التقليدي للدوال الفردية في وحدة التحكم إلا بحدود ضيقة.
يدعم Zustand ما يُسمى الوسيطات (middlewares)، ويمكن استخدامها لإضافة وظائف إلى stores بشكل شفاف، دون المساس بمنطق الـ store نفسه. وفكرة الوسيط بسيطة: فهو «يلتفّ» حول الـ store ويمكنه، مثلاً، تسجيل كل تغيير في الحالة تلقائياً.
وصيغة دوال الوسيط غامضة بعض الشيء. وفي ما يلي logger يطبع دائماً الحالة القديمة والجديدة للـ store كلما تغيرت الحالة:
const logger = (config) => (set, get) => config(
(...args) => {
console.log('prev state', get());
set(...args);
console.log('next state', get());
},
get
);
يُفعَّل الوسيط بـ«التفاف» الدالة المُعطاة كوسيط لـ create في Zustand:
const useNoteStore = create(logger((set, get) => ({
notes: [],
filter: '',
actions: {
// ...
}
})))
الآن، كلما تغيرت حالة الـ store، يمكننا دائماً رؤية كيف تتغير الحالة في وحدة التحكم:

عملياً، يعمل الوسيط الذي عرّفناه باستبدال الدالة الأصلية set بالدالة
(...args) => {
console.log('prev state', get());
set(...args);
console.log('next state', get());
}
التي تطبع، إضافةً إلى استدعاء set، الحالة القديمة والجديدة (المتاحة عبر دالة get) في وحدة التحكم. أما الوسيط الثاني فهو get القديمة دون تغيير.
يمتلك Zustand أيضاً وسيط devtools جاهزاً يدمج الـ store مع إضافة Redux DevTools في المتصفح. وتُعدّ Devtools أداة تطوير مفيدة للغاية، إذ تتيح لك تتبّع تغييرات الحالة بصرياً.
الإعداد بسيط:
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
const useNoteStore = create(devtools((set, get) => ({
notes: [],
filter: '',
actions: {
// ...
}
})))
عند تثبيت إضافة Redux DevTools في المتصفح، يمكن فحص حالة الـ store وتغييراتها في أدوات تطوير المتصفح:

اختبار stores في Zustand
أخيراً، لننظر في اختبار stores الخاصة بـ Zustand باستخدام Vitest.
لنبدأ، للتبسيط، بـ store العدّاد:
import { create } from 'zustand'
const useCounterStore = create(set => ({
counter: 0,
actions: {
increment: () => set(state => ({ counter: state.counter + 1 })),
decrement: () => set(state => ({ counter: state.counter - 1 })),
zero: () => set(() => ({ counter: 0 })),
}
}))
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)
export default useCounterStore
أضفنا إلى التعريف تصديراً لأجل الاختبارات، يتيح للاختبار الوصول إلى الـ store.
لنثبّت Vitest:
npm install --save-dev vitest
لننفّذ الاختبار في ملف store.test.js:
import { beforeEach, describe, expect, it } from 'vitest'
import useCounterStore from './store'
beforeEach(() => {
useCounterStore.setState({ counter: 0 })
})
describe('counter store', () => {
it('initial state is 0', () => {
expect(useCounterStore.getState().counter).toBe(0)
})
it('increment increases counter by 1', () => {
useCounterStore.getState().actions.increment()
expect(useCounterStore.getState().counter).toBe(1)
})
it('decrement decreases counter by 1', () => {
useCounterStore.getState().actions.decrement()
expect(useCounterStore.getState().counter).toBe(-1)
})
it('zero resets counter to 0', () => {
useCounterStore.getState().actions.increment()
useCounterStore.getState().actions.increment()
useCounterStore.getState().actions.zero()
expect(useCounterStore.getState().counter).toBe(0)
})
})
الاختبارات بسيطة إلى حد كبير، إذ تستفيد من دالة getState التابعة للـ store، ما يتيح لها قراءة حالة الـ store وتنفيذ دواله.
قبل كل اختبار، يُعاد ضبط الـ store إلى حالته الأولية في كتلة beforeEach باستخدام دالة setState التابعة له.
إعادة ضبط الـ store إلى حالته الأولية بسيطة في حالتنا. لكن الأمر ليس كذلك دائماً بالضرورة. يصف توثيق Zustand طريقة لإنشاء نسخة من stores لأجل الاختبار تُعاد ضبطها تلقائياً إلى حالتها الأولية قبل كل اختبار. غير أن الطريقة معقّدة بما يكفي وغير ضرورية لنا، لذا سنتجاهلها الآن.
إذن تستخدم الاختبارات الـ store مباشرةً. وإذا نُفّذ منطق أكثر تعقيداً عبر خطافات مخصّصة لاستخدام الـ store، فقد يلزم كتابة اختبارات تستفيد أيضاً من الخطافات. وفي العدّاد، يحدث استخدام الـ store عبر الخطافين useCounter و useCounterControls:
const useCounterStore = create(set => ({
// ...
}))
export const useCounter = () => useCounterStore(state => state.counter)
export const useCounterControls = () => useCounterStore(state => state.actions)
في هذه الحالة لا تحتوي الخطافات على أي منطق، بل تكشف فقط وبشكل منفصل القيمة المخزنة في الـ store ودوال الـ store. لذا فإن أسلوب الاختبار الذي استخدمناه أعلاه جيد تماماً.
ومع ذلك، لنصنع نسخة أخرى من الاختبارات لأغراض التوضيح، يُستخدم فيها الـ store بالطريقة نفسها تماماً التي يستخدمه بها التطبيق.
إن useCounter و useCounterControls خطافان من React، لذا يتطلب اختبارهما React Testing Library ومكتبة jsdom:
npm install --save-dev @testing-library/react jsdom
لنضف إعداد بيئة الاختبار إلى vite.config.js:
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
},
})
والاختبارات كما يلي:
import { beforeEach, describe, expect, it } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import useCounterStore, { useCounter, useCounterControls } from './store'
beforeEach(() => {
useCounterStore.setState({ counter: 0 })
})
describe('counter hooks', () => {
it('useCounter returns initial value of 0', () => {
const { result } = renderHook(() => useCounter())
expect(result.current).toBe(0)
})
it('increment updates counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => controls.current.increment())
expect(counter.current).toBe(1)
})
it('decrement updates counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => controls.current.decrement())
expect(counter.current).toBe(-1)
})
it('zero resets counter', () => {
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
act(() => {
controls.current.increment()
controls.current.increment()
controls.current.zero()
})
expect(counter.current).toBe(0)
})
})
ثمة أمور قليلة مثيرة للاهتمام في الاختبار. في بداية الاختبارات، تُعرَض الخطافات باستخدام دالة renderHook:
const { result: counter } = renderHook(() => useCounter())
const { result: controls } = renderHook(() => useCounterControls())
وبهذه الطريقة يصل الاختبار إلى القيم التي تُعيدها الخطافات، والمخزنة في المتغيرين counter و controls.
تُستدعى الخطافات بتغليف الاستدعاء داخل دالة act:
act(() => {
controls.current.increment()
controls.current.increment()
controls.current.zero()
})
وأخيراً يأتي توقّع الاختبار:
expect(counter.current).toBe(0)
وكما نرى، للوصول إلى الخطاف نفسه لا نزال بحاجة إلى أخذ الحقل current من الكائن الذي تُعيده renderHook، وهو ما يقابل القيمة الحالية للخطاف.
ما هي act؟
act دالة مساعدة تضمن معالجة جميع تحديثات الحالة وآثارها الجانبية قبل أن تتابع شيفرة الاختبار.
عندما يحدث تغيير في الحالة داخل مكوّن أو خطاف في React، لا يحدّث React الحالة فوراً بل يضع التحديثات في قائمة انتظار. وتجبر act هذه التحديثات المنتظرة على التنفيذ.
بدون act، قد يتحقق اختبار من الحالة قبل أن يتيح لـ React الوقت لتحديثها، ما يؤدي إلى فشل الاختبار أو إعطاء نتائج غير صحيحة.
تُغلّف React Testing Library كثيراً من دوالها (مثل fireEvent وuserEvent) داخل act تلقائياً، لكن عند اختبار الخطافات مباشرةً تكون الحاجة إليها مطلوبة عادةً.
يستخدم الاختبار عبر الخطافات مكتبة React Testing Library ويعرض الخطافات في سياق React حقيقي باستخدام jsdom. وهذا الأسلوب أبطأ بكثير من الاختبارات التي تستخدم الـ store مباشرةً، لذا إذا لم تحتوِ الخطافات على منطق معقّد، فقد يكفي إجراء الاختبارات باستخدام الـ store مباشرة.
الشيفرة التي تحتوي على اختبارات عدّاد Zustand متاحة على GitHub.
اختبار store الملاحظات
اختبار store تطبيق الملاحظات حالة أكثر تحدياً بعض الشيء، لأن الـ store يحتوي على دوال غير متزامنة تستدعي الخادم:
import { create } from 'zustand'
import noteService from './services/notes'
const useNoteStore = create(set => ({
notes: [],
filter: '',
actions: {
add: async (content) => {
const newNote = await noteService.createNew(content)
set(state => ({ notes: state.notes.concat(newNote) }))
},
toggleImportance: async (id) => {
const note = useNoteStore.getState().notes.find(n => n.id === id)
const updated = await noteService.update(
id, { ...note, important: !note.important }
)
set(state => ({
notes: state.notes.map(n => n.id === id ? updated : n)
}))
},
setFilter: value => set(() => ({ filter: value })),
initialize: async () => {
const notes = await noteService.getAll()
set(() => ({ notes }))
}
}
}))
export const useNotes = () => {
const notes = useNoteStore((state) => state.notes)
const filter = useNoteStore((state) => state.filter)
if (filter === 'important') return notes.filter(n => n.important)
if (filter === 'nonimportant') return notes.filter(n => !n.important)
return notes
}
export const useFilter = () => useNoteStore((state) => state.filter)
export const useNoteActions = () => useNoteStore((state) => state.actions)
هذه المرة تحتوي useNotes أيضاً على قدر كبير من المنطق، لذا يُحتمل أن يكون الاختبار عبر الخطافات باستخدام React Testing Library هو الأنسب.
لنثبّت المكتبات المطلوبة:
npm install --save-dev vitest @testing-library/react jsdom
لنضف إعداد بيئة الاختبار إلى vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
},
})
الجزء الأول من الاختبارات كما يلي:
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
vi.mock('./services/notes', () => ({
default: {
getAll: vi.fn(),
createNew: vi.fn(),
update: vi.fn(),
}
}))
import noteService from './services/notes'
import useNoteStore, { useNotes, useFilter, useNoteActions } from './store'
beforeEach(() => {
useNoteStore.setState({ notes: [], filter: '' })
vi.clearAllMocks()
})
describe('useNoteActions', () => {
it('initialize loads notes from service', async () => {
const mockNotes = [{ id: 1, content: 'Test', important: false }]
noteService.getAll.mockResolvedValue(mockNotes)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.initialize()
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
})
it('add appends a new note', async () => {
const newNote = { id: 2, content: 'New note', important: false }
noteService.createNew.mockResolvedValue(newNote)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.add('New note')
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toContainEqual(newNote)
})
it('toggleImportance flips important flag', async () => {
const note = { id: 1, content: 'Test', important: false }
useNoteStore.setState({ notes: [note] })
noteService.update.mockResolvedValue({ ...note, important: true })
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.toggleImportance(1)
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current[0].important).toBe(true)
})
})
ثمة الكثير مما يحتاج إلى استيعاب في هذه الاختبارات. تنشئ الاختبارات، باستخدام Vitest، نسخة mock من noteService المسؤولة عن التواصل مع الخادم:
import { describe, it, expect, beforeEach, vi } from 'vitest'
vi.mock('./services/notes', () => ({
default: {
getAll: vi.fn(),
createNew: vi.fn(),
update: vi.fn(),
}
}))
تستبدل vi.mock الـ noteService في وحدة ./services/notes بنسختها الخاصة، حيث تُستبدل جميع الدوال بدوال mock تُعيدها vi.fn.
قبل كل اختبار، يُعاد ضبط الـ store إلى حالته الأولية وتُفرَّغ دوال mock:
beforeEach(() => {
useNoteStore.setState({ notes: [], filter: '' })
vi.clearAllMocks()
})
في بداية كل اختبار، يُخبَر noteService المُستبدَل، عبر دالة mockResolvedValue، بكيفية تصرفه في سياق الاختبار:
it('initialize loads notes from service', async () => {
const mockNotes = [{ id: 1, content: 'Test', important: false }]
noteService.getAll.mockResolvedValue(mockNotes)
const { result } = renderHook(() => useNoteActions())
await act(async () => {
await result.current.initialize()
})
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
})
أولاً، يحدّد الاختبار أنه عند استدعاء دالة noteService.getAll، تُعاد إلى الـ store الملاحظات الموجودة في مصفوفة mockNotes.
والشيء الذي يجري اختباره هو استدعاء دالة initialize:
await act(async () => {
await result.current.initialize()
})
ولأن هذه دالة غير متزامنة، يجب انتظار اكتمال الاستدعاء بالكلمة المفتاحية await.
وأخيراً، يتحقق الاختبار من أن حالة الـ store تحتوي على القائمة نفسها من الملاحظات التي أعادتها noteService.getAll المُستبدَلة:
const { result: notesResult } = renderHook(() => useNotes())
expect(notesResult.current).toEqual(mockNotes)
تتبع الاختبارات الأخرى النمط نفسه: أولاً يُحدَّد ما تُعيده دالة noteService التي يستدعيها الـ store، ثم يُشغَّل الاختبار الفعلي.
ويتحقق الجزء الثاني من الاختبارات من أن الترشيح يعمل بشكل صحيح:
describe('useNotes filtering', () => {
const notes = [
{ id: 1, content: 'A', important: true },
{ id: 2, content: 'B', important: false },
]
beforeEach(() => {
useNoteStore.setState({ notes })
})
it('returns all notes with no filter', () => {
const { result } = renderHook(() => useNotes())
expect(result.current).toHaveLength(2)
})
it('filters important notes', () => {
useNoteStore.setState({ notes, filter: 'important' })
const { result } = renderHook(() => useNotes())
expect(result.current).toEqual([notes[0]])
})
it('filters nonimportant notes', () => {
useNoteStore.setState({ notes, filter: 'nonimportant' })
const { result } = renderHook(() => useNotes())
expect(result.current).toEqual([notes[1]])
})
})
تُهيَّأ الحالة بملاحظتين، إحداهما مهمة والأخرى غير مهمة. وتتحقق حالات الاختبار الثلاث من أن useNotes تُعيد الملاحظات الصحيحة لجميع قيم المرشّح.
شيفرة التطبيق النهائية على GitHub في الفرع part6-6.
14. anecdotes، الخطوة 11
15. anecdotes، الخطوة 12
16. anecdotes، الخطوة 13
17. anecdotes، الخطوة 14
18. anecdotes، الفحص النهائي