تست‌نویسی در javascript/typescript — بخش ۸: Snapshot، Coverage و CI

تست‌نویسی در javascript/typescript — بخش ۸: Snapshot، Coverage و CI

بهمن ۰۳, ۱۴۰۴

این آخرین مقاله سری تست‌نویسی ماست.

از بخش ۱ تا ۷ این مسیر رو رفتیم: unit test، matchers، E2E، DOM، mock، MSW. حالا سه موضوع آخر از دوره :

  1. Snapshot testing — ذخیره خروجی و مقایسه
  2. Code coverage — ببین چقدر کدت تست شده
  3. 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 شد

  1. diff رو بخون — تغییر عمدی بود یا باگ؟
  2. عمدی بود → vitest -u
  3. باگ بود → کد رو درست کن، 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 می‌ره بالا — ارزش صفر.

نکات مهم

  1. ۸۰/۲۰ — آخرین درصد coverage خیلی سخت‌تر از اولی‌هاست
  2. Coverage فقط unit test رو می‌بینه — integration و E2E جدا هستن
  3. زیر ~۶۰٪ شاید باید فکر کنی — ولی عدد ثابت مقدس نیست
  4. گاهی یک integration test بهتر از ده تا تست بی‌معنی
  5. بزرگ‌ترین فایده 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 چیکار می‌کنه؟

  1. checkout — کد repo رو می‌گیره
  2. setup-node — Node نصب + cache برای سرعت
  3. npm ci — dependencyها (تمیزتر از npm install برای CI)
  4. 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های مهم.


توصیه نهایی برای تیم واقعی

  1. تست برای اعتماد — نه برای عدد روی گزارش
  2. هرم تست — unit زیاد، E2E کم
  3. Mock با احتیاط — MSW برای API
  4. Testing Library — از دید کاربر
  5. CI — feedback سریع روی PR
  6. Snapshot — کم و با review
  7. 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 با supertest
  • parameterizing-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:

  1. تب Actions در GitHub
  2. workflow CI رو ببین
  3. یه branch بساز، PR باز کن
  4. چک سبز یا قرمز رو ببین
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 کد خراب:

  1. Settings → Branches
  2. Add rule برای main
  3. Require status checks to pass before merging
  4. 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 یه تصویر روشن داشته باشید. حالا نوبت شماست — توی پروژه واقعی تمرین کنید. ممنون که همراه بودید!