تستنویسی در javascript/typescript — بخش ۸: Snapshot، Coverage و CI
این آخرین مقاله سری تستنویسی ماست.
از بخش ۱ تا ۷ این مسیر رو رفتیم: unit test، matchers، E2E، DOM، mock، MSW. حالا سه موضوع آخر از دوره :
- Snapshot testing — ذخیره خروجی و مقایسه
- Code coverage — ببین چقدر کدت تست شده
- CI با GitHub Actions — تست خودکار روی هر PR
خب — جمعبندی سفر تستنویسی!
Snapshot Testing چیه؟
Snapshot test یعنی خروجی تابع یا کامپوننت رو یک بار ذخیره میکنی. دفعههای بعد Vitest خروجی جدید رو با همون فایل مقایسه میکنه. اگر فرق کرد — تست fail میشه.
خوبه برای خروجی بزرگ یا پیچیده — HTML طولانی، object بزرگ، serialize ثابت.
مثال ساده
// formatter.js
export function formatUser(user) {
return `User: ${user.name}, Age: ${user.age}`;
}
// formatter.test.js
import { test, expect } from 'vitest';
import { formatUser } from './formatter';
test('formats user information correctly', () => {
const user = { name: 'Alice', age: 30 };
const formattedUser = formatUser(user);
expect(formattedUser).toMatchSnapshot();
});
اولین بار Vitest فایلی مثل __snapshots__/formatter.test.js.snap میسازه:
User: Alice, Age: 30
بارهای بعد اگر خروجی عوض بشه بدون آپدیت snapshot — fail.
آپدیت snapshot
وقتی تغییر عمدی بود:
npx vitest -u
# یا
npx vitest --update
Inline Snapshot — snapshot داخل فایل تست
برای snapshotهای کوچک، inline راحتتره:
test('formats user information correctly', () => {
const user = { name: 'Alice', age: 30 };
const formattedUser = formatUser(user);
expect(formattedUser).toMatchInlineSnapshot(`"User: Alice, Age: 30"`);
});
مزیت: بین فایل تست و snapshot جدا نمیپری.
Property Matchers — وقتی id و تاریخ هر بار فرق میکنه
Snapshot میتونه شکننده بشه اگر id یا timestamp هر بار عوض بشه. راهحل:
const user = { name: 'Alice', id: 12345, createdAt: new Date() };
expect(user).toMatchSnapshot({
id: expect.any(Number),
createdAt: expect.any(Date),
});
فقط name دقیق چک میشه — بقیه «هر عددی» یا «هر تاریخی» قبوله.
مثال API response
test('fetches data correctly', async () => {
const data = await fetchData();
expect(data).toMatchSnapshot({
id: expect.any(Number),
createdAt: expect.any(String),
});
});
کی Snapshot مفیده؟
| موقعیت | چرا؟ |
|---|---|
| HTML/template | سریع regression میگیری |
| serialize object | تغییر ناخواسته در ساختار |
| خروجی بزرگ | assertion دستی خستهکننده |
کی Snapshot بدرد نخوره؟
- snapshot هزار خط — کسی نمیخونه
- فقط یک مقدار مهمه →
expect(x).toBe(5)بهتره - بدون فکر
-uمیزنید → باگ پنهان میشه
rule of thumb: snapshot برای regression سریع — assertion مشخص برای تست ماندگار.
جایگزین Snapshot
اغلب assertion مشخص بهتره.
به جای snapshot کل دکمه
const button = screen.getByRole('button');
expect(button.textContent).toBe('Click Me');
expect(button.classList).toContain('btn-primary');
فقط چیزی که مهمه — نه کل HTML.
تست رفتار به جای markup
await user.click(button);
expect(screen.getByText('1')).toBeInTheDocument();
اگر padding عوض شد، تست بیدلیل fail نمیشه.
assertion متنی برای محتوا
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
expect(screen.getByText('Developer')).toBeInTheDocument();
نگهداری Snapshot — نکات
وقتی fail شد
- diff رو بخون — تغییر عمدی بود یا باگ؟
- عمدی بود →
vitest -u - باگ بود → کد رو درست کن، snapshot آپدیت نکن!
اشتباه رایج
«عجله دارم، -u بزنم بره» — این خطرناکه.
snapshot کوچک نگه دار
- همه چیز رو snapshot نکن
- object بزرگ رو بشکن به تستهای کوچکتر
- snapshot + assertion مشخص با هم:
expect(user.name).toBe('Alice');
expect(formattedUser).toMatchSnapshot();
مثال snapshot برای HTML
// template.js
export function renderList(items) {
return `<ul>${items.map((item) => `<li>${item}</li>`).join('')}</ul>`;
}
test('renders list correctly', () => {
const items = ['Apple', 'Banana', 'Cherry'];
const html = renderList(items);
expect(html).toMatchSnapshot();
});
اگر template عوض شد — سریع میفهمی.
In-Source Testing — تست داخل خود فایل
برای in-source testing یه ترفند سریع برای حین توسعه نشون میده:
import { describe, it, expect } from 'vitest';
export function add(a, b) {
return a + b;
}
if (import.meta.vitest) {
describe('add', () => {
it('adds two numbers correctly', () => {
expect(add(2, 3)).toBe(5);
});
it('handles negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
});
}
import.meta.vitest فقط وقتی Vitest اجرا میشه true است — توی production این بلوک اجرا نمیشه.
چرا استفاده کنیم؟
- کمتر context switch — همه چیز یک فایل
- سریع — برای آزمایش کوچک
- موقت — معمولاً «معمولاً وقتی حالم به هم زده این کار رو میکنم — بعداً میبرمش فایل تست جدا.»
محدودیت
- پروژه بزرگ → فایل تست جدا بهتره
- برای CI و تیم → سازماندهی جداگانه
Code Coverage — پوشش کد
Code coverage نشون میده چند درصد کدت موقع اجرای تست، واقعاً اجرا شده.
نکته: ابزار مفیده — ولی تعقیب ۱۰۰٪ هدف خوبی نیست.
نصب و اجرا
npx vitest --coverage
Vitest ممکنه بپرسه @vitest/coverage-v8 نصب کنه — بگید yes.
یا:
npm test -- --coverage
خروجی توی پوشه coverage/ — گزارش HTML:
npx vite preview --outDir coverage
انواع coverage
| نوع | معنی ساده |
|---|---|
| Statements | چند خط اجرا شد |
| Branches | چند شاخه if/else رفت |
| Functions | چند تابع صدا زده شد |
| Lines | چند خط پوشش داده شد |
خروجی console — مثال
---------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
---------------|---------|----------|---------|---------|
All files | 85.7 | 66.66 | 80 | 85.7 |
math.js | 100 | 100 | 100 | 100 |
helper.js | 66.66 | 50 | 50 | 66.66 |
---------------|---------|----------|---------|---------|
خطوط قرمز توی HTML report = تست نشده.
مثال coverage — math.js
// src/math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
// tests/math.test.js
import { add, subtract } from '../src/math';
test('adds numbers correctly', () => {
expect(add(2, 3)).toBe(5);
});
test('subtracts numbers correctly', () => {
expect(subtract(5, 3)).toBe(2);
});
npx vitest --coverage → هر دو تابع ۱۰۰٪.
پیدا کردن branch پوششنداده
// src/utils/helper.js
export function greet(name) {
if (!name) {
return 'Hello, Stranger!';
}
return `Hello, ${name}!`;
}
test('greets a named person', () => {
expect(greet('Alice')).toBe('Hello, Alice!');
});
فقط یک branch تست شده — coverage گزارش میده if (!name) تست نشده.
اضافه کردن تست
test('greets a stranger when no name is provided', () => {
expect(greet()).toBe('Hello, Stranger!');
});
حالا branch coverage کاملتره.
تنظیم coverage در vitest.config
export default defineConfig({
test: {
coverage: {
include: ['src/**/*'],
exclude: [
'test/**',
'**/*.test.{js,jsx}',
'**/*.config.*',
'**/coverage/**',
],
all: true,
},
},
});
exclude فایلهایی که نباید شمرده بشن — تستها، config، type definition.
threshold — با احتیاط
coverage: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
}
threshold خیلی سخت توصیه نمیکنه — ولی میتونه از افت ناگهانی coverage جلوگیری کنه.
نادیده گرفتن خطوط
/* c8 ignore next */
if (process.platform === 'win32') console.info('hello world');
/* c8 ignore start */
function dontMindMe() {
// کد platform-specific
}
/* c8 ignore stop */
فقط برای کدی که واقعاً تستپذیر نیست — نه برای پنهان کردن باگ!
چرا ۱۰۰٪ Coverage هدف نیست
Coverage عدده — نه کیفیت.
مشکل تعقیب ۱۰۰٪
it('loads with no errors', () => {
expect(true).toBe(true);
});
Coverage میره بالا — ارزش صفر.
نکات مهم
- ۸۰/۲۰ — آخرین درصد coverage خیلی سختتر از اولیهاست
- Coverage فقط unit test رو میبینه — integration و E2E جدا هستن
- زیر ~۶۰٪ شاید باید فکر کنی — ولی عدد ثابت مقدس نیست
- گاهی یک integration test بهتر از ده تا تست بیمعنی
- بزرگترین فایده coverage: حین نوشتن feature جدید — ببینی کجا تست کم داری
کاربران آمار coverage شما رو نمیبینن — میخوان برنامه کار کنه.
Continuous Integration — GitHub Actions
CI یعنی هر بار کد push یا PR باز شد، تستها خودکار اجرا بشن.
برای CI یک workflow ساده کافی است.
چرا CI؟
| فایده | توضیح |
|---|---|
| تشخیص زود | باگ قبل از merge |
| یکنواختی | همه PRها همون تستها |
| صرفهجویی وقت | دستی تست نزن |
| feedback سریع | PR قرمز = مشکل |
ساختار پوشه
your-repo/
├── .github/
│ └── workflows/
│ └── ci.yml
.github/workflows/ci.yml — پایه
name: CI
on:
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
هر step چیکار میکنه؟
- checkout — کد repo رو میگیره
- setup-node — Node نصب + cache برای سرعت
- npm ci — dependencyها (تمیزتر از
npm installبرای CI) - npm test — Vitest اجرا میشه
اگر تست fail → PR قرمز.
CI با coverage
- name: Run tests with coverage
run: npx vitest --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage
گزارش coverage به عنوان artifact ذخیره میشه — میتونی بعداً دانلود کنی.
CI روی push هم
on:
push:
branches: [main]
pull_request:
branches: [main]
هم push به main، هم PR — هر دو تست میشن.
pnpm یا yarn
مثال pnpm:
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm test
package manager فرق میکنه — workflow یکیه.
نقشه کل سری — هشت بخش
| بخش | موضوع | |||
|---|---|---|---|---|
| ۱ | مقدمات | |||
| ۲ | Unit + TDD | |||
| ۳ | Matchers + Hooks | |||
| ۴ | E2E Playwright | |||
| ۵ | DOM + Testing Library | |||
| ۶ | Mock + Doubles | |||
| ۷ | MSW + Todo | |||
| ۸ | Snapshot + Coverage + CI | -------- | ------- | |
| ۱ | مقدمات | Intro | ||
| ۲ | Unit + TDD | The Basics | ||
| ۳ | Matchers + Hooks | The Basics | ||
| ۴ | E2E Playwright | Next Steps | ||
| ۵ | DOM + Testing Library | Testing the DOM | ||
| ۶ | Mock + Doubles | Faking It | ||
| ۷ | MSW + Todo | task-list + MSW | ||
| ۸ | Snapshot + Coverage + CI | Next Steps + Infrastructure |
هرمی که کل سری ساخت
┌─────────────┐
│ E2E (۴) │ ← کم، گران، مهم
└──────┬──────┘
┌───────────┴───────────┐
│ Integration / MSW (۷) │
└───────────┬───────────┘
┌────────────────┴────────────────┐
│ DOM Testing Library (۵) │
└────────────────┬────────────────┘
┌─────────────────────┴─────────────────────┐
│ Unit Tests (۲، ۳، ۶) │ ← زیاد، سریع
└─────────────────────────────────────────┘
پایه: unit test زیاد. بالا: E2E کم ولی برای journeyهای مهم.
توصیه نهایی برای تیم واقعی
- تست برای اعتماد — نه برای عدد روی گزارش
- هرم تست — unit زیاد، E2E کم
- Mock با احتیاط — MSW برای API
- Testing Library — از دید کاربر
- CI — feedback سریع روی PR
- Snapshot — کم و با review
- Coverage — راهنما، نه هدف ۱۰۰٪
جمعبندی بخش ۸
| موضوع | یک خط |
|---|---|
| Snapshot | خروجی ذخیره + مقایسه |
| Inline snapshot | کوچک، داخل فایل تست |
| Property matchers | id/timestamp داینامیک |
| جایگزین snapshot | assertion مشخص بهتره |
| In-source test | سریع، موقت |
| Coverage | ببین کجا تست نداری |
| ۱۰۰٪ coverage | هدف نیست |
| CI | GitHub Actions + Vitest |
تمرینهای پیشنهادی بعد از سری
از تمرینهایی که توی مقالات نرفتیم (اختیاری):
guess-the-number-example— TDD بازیauthentication-example— API با supertestparameterizing-tests— تست پارامتریکdebugging-vitest-tests— دیباگ Vitest
مسیر یادگیری بعد از سری
هفته ۱: unit test روی پروژه خودت
هفته ۲: Testing Library برای کامپوننت
هفته ۳: MSW برای API
هفته ۴: یک E2E با Playwright
هفته ۵: CI روی GitHub
عجله نکن — هر هفته یک لایه.
تشکر و خداحافظی
از بخش ۱ تا ۸:
مقدمات → Unit → پیشرفته → E2E → DOM → Mock → MSW → Snapshot/CI
هر لایه جای خودش رو داره. همه رو با هم — نه فقط یکی.
تستنویسی عادت میخواد — هر روز یک تست کوچک بهتر از هفتهای بدون تست.
موفق باشید و به تستنویسی ادامه بدید!
نگهداری Snapshot — راهنمای کامل
خوب، بد، و snapshot
| توضیح | |
|---|---|
| خوب | سریع — یک بار capture، بعد auto-compare |
| بد | باید diff رو بخونی — context نداره |
| snapshot fail | یا آپدیت عمدی، یا باگ |
سه قدم وقتی fail شد
قدم ۱: diff رو ببین — عمدی بود؟
قدم ۲: عمدی بود → npx vitest --update
عمدی نبود → کد رو درست کن، snapshot آپدیت نکن
قدم ۳: snapshot بزرگ؟ → بشکن به تستهای کوچکتر
Custom Serializer برای snapshot
import { expect } from 'vitest';
expect.addSnapshotSerializer({
test: (val) => val instanceof Date,
serialize: (val) => val.toISOString(),
});
تاریخ همیشه یک شکل serialize میشه — کمتر brittle.
CI — تست workflow
بعد از push کردن ci.yml:
- تب Actions در GitHub
- workflow CI رو ببین
- یه branch بساز، PR باز کن
- چک سبز یا قرمز رو ببین
git checkout -b test-ci
touch dummy.txt
git add dummy.txt
git commit -m "Test CI workflow"
git push origin test-ci
Branch Protection — اجبار تست قبل از merge
برای جلوگیری از merge کد خراب:
- Settings → Branches
- Add rule برای
main - Require status checks to pass before merging
- workflow CI رو انتخاب کن
حالا PR بدون تست سبز merge نمیشه.
CI Best Practices
| نکته | چرا؟ |
|---|---|
| Cache | cache: 'npm' — نصب سریعتر |
| چند Node | matrix: [18.x, 20.x] برای سازگاری |
| fail-fast | اولین fail، بقیه jobها stop |
| بهروز actions | @v4 نه @v2 قدیمی |
چند نسخه Node
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
فقط اگر server-side code داری مهمه — برای frontend ساده یک نسخه کافیه.
CI Troubleshooting
| مشکل | راهحل |
|---|---|
| تست local pass، CI fail | env variable چک کن |
| permission | workflow باید روی default branch باشه |
| YAML syntax | linter یا editor GitHub |
| cache خراب | cache key عوض شده |
Coverage در CI — workflow کامل
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage
Coverage threshold — چی میگه؟
میتونی threshold بذاری:
coverage: {
statements: 54.92,
thresholdAutoUpdate: true,
}
یعنی: از این پایینتر نرو — و اگر بالاتر رفتی، baseline جدید بشه.
ولی mandate 100% رو توصیه نمیکنه. عدد پایین و معقول بهتر از تعقیب بیهدف.
snapshot برای API — با MSW
ترکیب بخش ۷ و ۸:
test('API response structure', async () => {
const data = await fetch('/api/todos').then((r) => r.json());
expect(data).toMatchSnapshot({
'0': { id: expect.any(Number), title: expect.any(String) },
});
});
MSW جواب mock میده — snapshot ساختار رو چک میکنه.
مقایسه ابزارهای بخش ۸
| ابزار | هدف | زیادهروی |
|---|---|---|
| Snapshot | regression سریع | snapshot 1000 خطی |
| Coverage | پیدا کردن gap | تعقیب 100٪ |
| CI | feedback خودکار | 10 job موازی بیدلیل |
| In-source | آزمایش سریع | جایگزین test suite |
چکلیست پایان سری
قبل از بستن این سری، خودت رو بپرس:
- unit test برای logic اصلی دارم؟
- DOM با Testing Library از دید کاربر؟
- API با MSW یا mock مناسب؟
- یک E2E برای journey مهم؟
- CI روی PR؟
- coverage به عنوان راهنما، نه هدف؟
یک روز کاری با تست — تصویر کلی
صبح: feature جدید → TDD (قرمز-سبز-refactor)
ظهر: PR باز → CI تست میزنه
عصر: coverage نگاه میکنی → یه branch تست نشده
شب: E2E یک بار برای journey اصلی
نه همه چیز هر روز — ولی این چرخه ایدهآله.
مثالهای عملی Coverage — گامبهگام
شروع: فقط add تست شده
// cart.js
export function calculateTotal(items) {
if (!items || items.length === 0) return 0;
return items.reduce((sum, item) => sum + item.price, 0);
}
export function applyDiscount(total, percent) {
if (percent < 0 || percent > 100) throw new Error('Invalid discount');
return total * (1 - percent / 100);
}
test('calculates total', () => {
expect(calculateTotal([{ price: 10 }, { price: 20 }])).toBe(30);
});
Coverage: calculateTotal خوب — applyDiscount صفر!
اضافه کردن تست برای branch
test('empty cart returns 0', () => {
expect(calculateTotal([])).toBe(0);
});
test('applyDiscount works', () => {
expect(applyDiscount(100, 10)).toBe(90);
});
test('invalid discount throws', () => {
expect(() => applyDiscount(100, 150)).toThrow('Invalid discount');
});
حالا branch coverage هم بهتره.
Snapshot + Testing Library — مرز باریک
برای کامپوننت React، Testing Library assertion معمولاً بهتر از snapshot کل DOM:
// ترجیح
expect(screen.getByRole('heading', { name: 'Welcome' })).toBeInTheDocument();
// نه این
expect(container).toMatchSnapshot(); // هر تغییر CSS = fail
Snapshot برای serialize یا template string — نه هر کامپوننت React.
In-source vs فایل جدا — جدول تصمیم
| In-source | فایل تست جدا | |
|---|---|---|
| سرعت setup | سریع | کندتر |
| تیم بزرگ | بد | خوب |
| CI | هر دو کار میکنه | استاندارد |
| موقت | آزمایش سریع | فایل تست جدا |
GitHub Actions — yarn مثال
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn test
package manager عوض — workflow تقریباً یکی.
fail-fast در matrix
strategy:
fail-fast: true
matrix:
node-version: [18.x, 20.x]
اولین fail → jobهای دیگه stop — وقت CI ذخیره میشه.
یادآوری: چه چیزی coverage نمیبینه
- E2E توی coverage unit نیست
- TypeScript types جایگزین تست نیستن — ولی کمک میکنن
- Integration test ممکنه خطی رو cover کنه که unit coverage نشون نمیده
پس coverage یک نگاه — نه کل تصویر.
Snapshot property matcher — مثال کامل
test('user object snapshot', () => {
const user = {
id: 42,
name: 'Alice',
email: 'alice@example.com',
updatedAt: new Date().toISOString(),
};
expect(user).toMatchSnapshot({
id: expect.any(Number),
updatedAt: expect.any(String),
});
});
name و email دقیق — id و updatedAt flexible.
vitest.config — in-source testing
برای in-source test، Vitest خودکار فایلهای source رو هم اسکن میکنه — فقط import.meta.vitest gate کافیه.
npx vitest
هم فایلهای *.test.js هم بلوکهای in-source اجرا میشن.
سری کامل — فایلهای این مجموعه
| بخش | فایل |
|---|---|
| ۱ | testing-series-part-1-introduction.md |
| ۲ | testing-series-part-2-unit-testing.md |
| ۳ | testing-series-part-3-unit-testing-advanced.md |
| ۴ | testing-series-part-4-e2e-testing.md |
| ۵ | testing-series-part-5-dom-testing-library.md |
| ۶ | testing-series-part-6-mocking-test-doubles.md |
| ۷ | testing-series-part-7-msw-todo-list.md |
| ۸ | testing-series-part-8-snapshot-coverage-ci.md |
هشت بخش — از مقدمات تا CI.
خب دوستان، به آخر سری رسیدیم. امیدوارم از unit تا CI یه تصویر روشن داشته باشید. حالا نوبت شماست — توی پروژه واقعی تمرین کنید. ممنون که همراه بودید!