تست‌نویسی در javascript/typescript — بخش ۳: تکنیک‌های پیشرفته تست واحد

تست‌نویسی در javascript/typescript — بخش ۳: تکنیک‌های پیشرفته تست واحد

آذر ۲۷, ۱۴۰۴

در مقاله قبلی، با مبانی تست واحد و TDD آشنا شدیم. در این مقاله تکنیک‌های پیشرفته تست نویسی واحد رو با هم بررسی می‌کنیم. در این مقاله، ابزارهای قدرتمندی رو یاد می‌گیریم که به ما کمک می‌کنن تست‌های حرفه‌ای‌تر و انعطاف‌پذیر‌تری بنویسیم.

تساوی مرجعی و ساختاری (Referential vs Structural Equality)

توی جاوااسکریپت (و قطعا تایپ‌اسکریپت)، تفاوت بین toBe و toEqual بسیار مهمه و انتخاب نادرست، می‌تونه به تست‌های اشتباه و گمراه‌کننده منجر بشه. داستان به این حقیقت برمی‌گرده که دو متغیر از نوع ‌reference در جاوااسکریپت، اگر رفرنس مشترکی نداشته باشند، حتی اگرمقادیر کاملا یکسانی داشته باشند، باز هم با هم مساوی نیستند. مثال:

let a = { x:1 };
let b = { x:1 };
console.log(a===b); // false

استفاده از toBe (مقایسه مرجعی)

متد toBe برای مقایسه مرجعی استفاده می‌شه و مشابه === یا ()Object.is عمل می‌کنه:

test('strings should be strictly equal', () => {
  expect('string').toBe('string');
});

test('numbers should be strictly equal', () => {
  expect(2).toBe(2);
});

test('booleans should be strictly equal', () => {
  expect(true).toBe(true);
  expect(false).toBe(false);
});

test('undefined and null should be strictly equal to themselves', () => {
  expect(undefined).toBe(undefined);
  expect(null).toBe(null);
});

همه تست‌های فوق پاس میشن، اما وقتی به سراغ اشیا و آرایه‌ها می‌ریم، مشکلاتی پیش میاد:

test.fails('objects should not be strictly equal', () => {
  expect({ a: 1 }).toBe({ a: 1 }); // it fails!
});

test.fails('arrays should not be strictly equal', () => {
  expect([1, 2, 3]).toBe([1, 2, 3]); // it fails!
});

test.fails('functions should not be strictly equal', () => {
  expect(() => {}).toBe(() => {}); // it fails!
});

پس می‌بینیم که استفاده از toBe در چنین مواردی راهکار درستی نیست و باید سراغ toEqual رفت.

استفاده از toEqual (مقایسه ساختاری)

متد toEqual برای مقایسه ساختاری استفاده می‌شه و به صورت shallow مقادیر رو بررسی می‌کنه:

test('objects with the same properties are equal', () => {
  expect({ a: 1, b: 2 }).toEqual({ a: 1, b: 2 });
});

test('arrays should be equal', () => {
  expect([1, 2, 3]).toEqual([1, 2, 3]);
});

test('nested objects should be equal', () => {
  expect({ a: 1, b: { c: 2 } }).toEqual({ a: 1, b: { c: 2 } });
});

test('multi-dimensional arrays should be equal', () => {
  expect([1, [2, 3]]).toEqual([1, [2, 3]]);
});

حالا تمامی تست های فوق به درستی پاس می شوند.

تفاوت toEqual و toStrictEqual

متد toStrictEqual نسخه سفت‌وسخت‌تری از toEqual هست:

class Person {
  constructor(name) {
    this.name = name;
  }
}

test('objects with undefined properties are equal to objects without those properties', () => {
  expect({ a: 1 }).toEqual({ a: 1, b: undefined }); // ✅ موفق می‌شه
});

test('objects with undefined properties are NOT strictly equal to objects without those properties', () => {
  expect({ a: 1 }).not.toStrictEqual({ a: 1, b: undefined }); // ✅ موفق می‌شه
});

test('instances are equal to object literals with the same properties', () => {
  expect(new Person('Alice')).toEqual({ name: 'Alice' }); // ✅ موفق می‌شه
});

test('instances are NOT strictly equal to object literals with the same properties', () => {
  expect(new Person('Alice')).not.toStrictEqual({ name: 'Alice' }); // ✅ موفق می‌شه
});

دقت کنید که در تست‌های فوق با not عدم تساوی رو بررسی کردیم.

کدام روش رو انتخاب کنیم؟

  • متد toBe: وقتی می‌خوایم مطمئن بشیم دو متغیر به یک شیء اشاره می‌کنن
  • متد toEqual: وقتی می‌خوایم مطمئن بشیم دو شیء محتوای یکسانی دارن (معمولاً اینو می‌خوایم)
  • متد toStrictEqual: وقتی می‌خوایم نسبت به toEqual دقیقتر باشیم و undefinedها و انواع شیء رو هم بررسی کنیم

مثال عملی با کلاس‌ها

اگر بخواهیم یک مثال از مفهوم تساوی اینبار در ساختار کلاسی بزنیم، بدین صورت میشه.

class Calculator {
  constructor() {
    this.result = 0;
    this.history = [];
  }

  add(num) {
    this.result += num;
    this.history.push(`+${num}`);
    return this;
  }

  subtract(num) {
    this.result -= num;
    this.history.push(`-${num}`);
    return this;
  }
}

حالا تستش:

test('calculator should work correctly', () => {
  const calc = new Calculator();
  calc.add(5).subtract(2);
  
  expect(calc).not.toBe(new Calculator()); // it fails!
  
  expect(calc).toEqual({
    result: 3,
    history: ['+5', '-2']
  }); // it success!
});

همسان‌یابی نامتقارن (Asymmetric Matchers)

همسان‌یابی نامتقارن بدون شک یکی از قدرتمندترین ابزارهای تست نویسیه. matcher ها به ما اجازه می‌دن فقط قسمت‌هایی از داده‌ها رو تست کنیم که واقعاً مهم هستن و بقیه رو نادیده بگیریم. این کار باعث می‌شه تست‌هامون انعطاف‌پذیرتر و مقاوم‌تر نسبت به تغییرات غیرضروری بشن.

چرا از همسان‌یابی نامتقارن استفاده کنیم؟

تصور کنید یه API داریم که اطلاعات کاربر رو برمی‌گردونه:

{
  id: "user-123",
  name: "John Doe",
  email: "john@example.com",
  createdAt: "2024-01-15T10:30:00Z",
  lastLogin: "2024-01-20T15:45:00Z",
  settings: {
    theme: "dark",
    notifications: true
  }
}

اگه بخوایم این رو با toEqual تست کنیم، باید همه فیلدها رو دقیق مشخص کنیم. این کار تست رو خیلی سفت می‌کنه و با هر تغییر کوچیک (مثلاً تغییر timestamp)، تست fail میشه.

همسان‌یابی نامتقارن به ما اجازه می‌دن فقط روی چیزایی تمرکز کنیم که واقعاً برامون مهم هستن.

استفاده از ()expect.any برای تست type داده

از ()expect.any برای تست type یک مقدار، بدون اینکه مقدار دقیق رو مشخص کنیم، استفاده میشه.

it('should create a user with correct structure', () => {
  const user = createUser('John', 'john@example.com');
  
  expect(user).toEqual({
    id: expect.any(String),        // فقط مهمه که رشته باشه
    name: 'John',                  // این رو دقیق تست می‌کنیم
    email: 'john@example.com',     // این رو دقیق تست می‌کنیم
    createdAt: expect.any(Date),   // فقط مهمه که تاریخ باشه
    isActive: expect.any(Boolean)  // فقط مهمه که بولین باشه
  });
});

انواع ()expect.any

expect.any(String)     // برای رشته‌ها
expect.any(Number)     // برای اعداد
expect.any(Boolean)    // برای بولین‌ها
expect.any(Object)     // برای اشیا
expect.any(Array)      // برای آرایه‌ها
expect.any(Function)   // برای توابع
expect.any(Date)       // برای تاریخ‌ها
expect.any(Error)      // برای خطاها

استفاده از ()expect.objectContaining برای تست بخشی از object

این matcher رو وقتی می‌خوایم فقط بعضی از پراپرتی‌های یه شیء رو تست کنیم استفاده می‌شه. یک مثال از پاسخ یک API:

const apiResponse = {
  status: 'success',
  data: {
    user: {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      profile: {
        avatar: 'https://example.com/avatar.jpg',
        bio: 'Software Developer',
        location: 'Tehran'
      }
    }
  },
  meta: {
    timestamp: '2024-01-20T10:30:00Z',
    version: '1.0.0'
  }
};

تستی که براش نوشتیم:

it('should return user data with correct name and email', () => {
expect(apiResponse).toEqual({
  status: 'success',
  data: {
    user: expect.objectContaining({   //other field of user object aren't important
      name: 'John Doe',
      email: 'john@example.com'
    })
  },
  meta: expect.any(Object) // It's just important to exist!
});
});

استفاده از ()expect.arrayContaining - تست بخشی از آرایه

وقتی می‌خوایم مطمئن بشیم یه آرایه شامل بعضی مقادیر خاص هست استفاده می‌شه:

it('should include required permissions', () => {
  const userPermissions = ['read', 'write', 'delete', 'admin', 'manage_users'];
  
  expect(userPermissions).toEqual(
    expect.arrayContaining(['read', 'write', 'delete'])
  );
});

it('should not include banned permissions', () => {
  const userPermissions = ['read', 'write', 'delete'];
  
  expect(userPermissions).toEqual(
    expect.arrayContaining(['read', 'write'])
  );
  
  expect(userPermissions).not.toEqual(
    expect.arrayContaining(['admin', 'superuser'])
  );
});

استفاده از ()expect.stringContaining - تست بخشی از رشته

برای تست کردن اینکه یه رشته شامل یه زیررشته خاص هست استفاده می‌شه:

it('should include error message with user ID', () => {
  const errorMessage = 'User with ID 12345 was not found in the database';
  
  expect(errorMessage).toEqual(
    expect.stringContaining('User with ID')
  );
  
  expect(errorMessage).toEqual(
    expect.stringContaining('was not found')
  );
});

استفاده از ()expect.stringMatching - تست رشته با regex

برای تست کردن رشته با استفاده از عبارات regex استفاده می‌شه:

it('should validate email format', () => {
  const email = 'user123@example.com';
  
  expect(email).toEqual(
    expect.stringMatching(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
  );
});

it('should validate phone number format', () => {
  const phone = '+98 912 345 6789';
  
  expect(phone).toEqual(
    expect.stringMatching(/^\+\d{1,3}\s\d{3}\s\d{3}\s\d{4}$/)
  );
});

it('should validate log message format', () => {
  const logMessage = '2024-01-20 10:30:00 [ERROR] Database connection failed';
  
  expect(logMessage).toEqual(
    expect.stringMatching(/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s\[ERROR\]/)
  );
});

استفاده از ()expect.stringMatching با الگوهای رایج

// تست تاریخ
expect(dateString).toEqual(
  expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)
);

// تست ساعت
expect(timeString).toEqual(
  expect.stringMatching(/^\d{2}:\d{2}:\d{2}$/)
);

// تست کد پستی ایران
expect(postalCode).toEqual(
  expect.stringMatching(/^\d{5}-\d{5}$/)
);

// تست شماره کارت بانکی
expect(cardNumber).toEqual(
  expect.stringMatching(/^\d{4}\s\d{4}\s\d{4}\s\d{4}$/)
);

ترکیب matcherها برای تست‌های پیچیده

می‌تونیم matcher ها رو با هم ترکیب کنیم تا تست‌های پیچیده‌تری بنویسیم:

it('should return complete user profile', () => {
  const userProfile = {
    id: 'user-123',
    personalInfo: {
      name: 'John Doe',
      email: 'john@example.com',
      phone: '+98 912 345 6789'
    },
    accountInfo: {
      createdAt: '2024-01-01T00:00:00Z',
      lastLogin: '2024-01-20T10:30:00Z',
      status: 'active'
    },
    permissions: ['read', 'write', 'comment']
  };
  
  expect(userProfile).toEqual({
    id: expect.stringMatching(/^user-\d+$/),
    personalInfo: expect.objectContaining({
      name: expect.stringContaining('John'),
      email: expect.stringMatching(/.+@.+\..+/),
      phone: expect.stringMatching(/^\+\d{1,3}\s\d{3}\s\d{3}\s\d{4}$/)
    }),
    accountInfo: expect.objectContaining({
      createdAt: expect.any(String),
      lastLogin: expect.any(String),
      status: expect.stringMatching(/^(active|inactive|suspended)$/)
    }),
    permissions: expect.arrayContaining(['read', 'write'])
  });
});

مثال عملی: تست API Response

بیاید یه مثال عملی از تست کردن response یه API با استفاده از همسان‌یابی نامتقارن بزنیم:

// API endpoint: GET /api/users
describe('GET /api/users', () => {
  it('should return list of users with correct structure', async () => {
    const response = await request(app)
      .get('/api/users')
      .expect(200);

    const users = response.body;

    expect(users).toEqual({
      data: expect.arrayContaining([
        expect.objectContaining({
          id: expect.any(Number),
          name: expect.any(String),
          email: expect.stringMatching(/.+@.+\..+/),
          createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}/),
          profile: expect.objectContaining({
            avatar: expect.any(String),
            bio: expect.any(String)
          })
        })
      ]),
      meta: expect.objectContaining({
        total: expect.any(Number),
        page: expect.any(Number),
        limit: expect.any(Number)
      })
    });
  });

  it('should return user with posts', async () => {
    const response = await request(app)
      .get('/api/users/1')
      .expect(200);

    const user = response.body;

    expect(user).toEqual({
      id: 1,
      name: expect.any(String),
      email: expect.stringMatching(/.+@.+\..+/),
      posts: expect.arrayContaining([
        expect.objectContaining({
          id: expect.any(Number),
          title: expect.any(String),
          content: expect.any(String),
          createdAt: expect.any(String)
        })
      ])
    });
  });
});

مثال: تست خطاهای API

describe('Error handling', () => {
  it('should return 404 for non-existent user', async () => {
    const response = await request(app)
      .get('/api/users/99999')
      .expect(404);

    expect(response.body).toEqual({
      error: expect.objectContaining({
        message: expect.stringContaining('not found'),
        code: 'USER_NOT_FOUND',
        timestamp: expect.any(String)
      })
    });
  });

  it('should return 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({
        name: 'John Doe',
        email: 'invalid-email', // ایمیل نامعتبر
        password: 'password123'
      })
      .expect(400);

    expect(response.body).toEqual({
      error: expect.objectContaining({
        message: expect.stringContaining('Invalid email'),
        fields: expect.arrayContaining(['email']),
        code: 'VALIDATION_ERROR'
      })
    });
  });
});

نکات استفاده از همسان‌یابی نامتقارن

  1. از matcher ها فقط برای فیلدهای غیرضروری استفاده کنید:

    • فیلدهای کلیدی رو دقیق تست کنید
    • فقط فیلدهایی که می‌تونن تغییر کنن رو با matcher تست کنید
  2. از regexهای خوانا استفاده کنید:

    • لطفا regexهای پیچیده رو مستند کنید!
    • از نام‌های متغیر مناسب، برای توضیح pattern regex استفاده کنید
  3. تست‌ها رو خوانا نگه دارید:

    • استفاده از matcher نباید منجر بشه تست ما ناخوانا بشه
    • از کامنت برای توضیح منطق تست استفاده کنید
  4. از matcher ها بیش از حد استفاده نکنید:

    • تست‌های خیلی انعطاف‌پذیر ممکنه خطاهای واقعی رو پوشش بدن
    • فقط اندازه کافی انعطاف بذارید

مثال پیشرفته: تست سیستم احراز هویت

describe('Authentication System', () => {
  it('should generate valid JWT token', () => {
    const user = { id: 1, email: 'john@example.com' };
    const token = generateJWT(user);

    // تست ساختار توکن
    expect(token).toEqual(expect.stringMatching(/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/));

    // تست دیکد کردن توکن
    const decoded = decodeJWT(token);
    expect(decoded).toEqual({
      userId: 1,
      email: 'john@example.com',
      iat: expect.any(Number),
      exp: expect.any(Number)
    });

    // تست اعتبار زمانی توکن (باید بیشتر از یه ساعت دیگه منقضی بشه)
    const expirationTime = decoded.exp;
    const currentTime = Math.floor(Date.now() / 1000);
    const timeUntilExpiration = expirationTime - currentTime;
    expect(timeUntilExpiration).toBeGreaterThan(3600); // بیشتر از 1 ساعت
  });

  it('should validate password complexity', () => {
    const validPasswords = [
      'StrongPass123!',
      'MyPassword456@',
      'ComplexPass789#'
    ];

    const invalidPasswords = [
      '123',                    // خیلی کوتاه
      'password',               // بدون عدد و کاراکتر خاص
      'PASSWORD123',            // بدون حرف کوچک
      'password123',            // بدون حرف بزرگ و کاراکتر خاص
      'Pass word123'            // فاصله داره
    ];

    validPasswords.forEach(password => {
      expect(validatePassword(password)).toBe(true);
    });

    invalidPasswords.forEach(password => {
      expect(validatePassword(password)).toBe(false);
    });
  });

  it('should generate secure session ID', () => {
    const sessionId = generateSessionId();
    
    // تست طول و فرمت session ID
    expect(sessionId).toEqual(
      expect.stringMatching(/^[a-f0-9]{32}$/)
    );

    // تست منحصربفرد بودن (دو session ID متوالی باید متفاوت باشن)
    const sessionId2 = generateSessionId();
    expect(sessionId).not.toBe(sessionId2);
  });
});

همسان‌یابی نامتقارن ابزارهای قدرتمندی هستن که به ما کمک می‌کنن تست‌هامون هم دقیق باشن هم انعطاف‌پذیر. با استفاده از این matcher ها می‌تونیم تست‌هایی بنویسیم که:

  • مقاوم نسبت به تغییرات غیرضروری باشن
  • خوانا و قابل فهم باشن
  • تمرکز روی چیزایی داشته باشن که واقعاً مهم هستن
  • پوشش کاملی از شرایط مختلف داشته باشن

هوک‌های تست (Testing hooksُ)

هوک‌ها یکی از مهمترین ابزارهای تست نویسی هستن و استفاده درست ازشون باعث می‌شه تست‌هامون تمیز، قابل اعتماد و ایزوله باشن.

چرا هوک‌ها مهم هستن؟

  1. جلوگیری از تکرار کد: کدهای تکراری رو در هوک‌ها قرار می‌دیم
  2. ایزوله نگه داشتن تست‌ها: هر تست مستقل از تست‌های دیگه باشه
  3. پاکسازی منابع: جلوگیری از memory leak و نشتی منابع
  4. سرعت بخشیدن به تست‌ها: با استفاده از beforeAll برای تنظیماتی که نیاز به تکرار ندارن

انواع هوک‌ها

هوک beforeEach - اجرا قبل از هر تست

هوک beforeEach قبل از هر تست در یک describe بلاک اجرا می‌شه:

describe('Counter', () => {
  beforeEach(() => {
    counter.reset();
    console.log('Counter reset before each test');
  });

  it('starts at zero', () => {
    expect(counter.value).toBe(0);
  });

  it('can increment', () => {
    counter.increment();
    expect(counter.value).toBe(1);
  });

  it('can decrement', () => {
    counter.increment();
    counter.decrement();
    expect(counter.value).toBe(0);
  });
});

نکته مهم: این کد سه بار اجرا می‌شه (یک بار قبل از هر تست).

هوک afterEach - اجرا بعد از هر تست

هوک afterEach بعد از هر تست اجرا می‌شه:

describe('File operations', () => {
  const tempFile = '/tmp/test-file.txt';

  beforeEach(() => {
    // Create test file
    fs.writeFileSync(tempFile, 'test content');
  });

  afterEach(() => {
    // Clean up test file
    if (fs.existsSync(tempFile)) {
      fs.unlinkSync(tempFile);
    }
    console.log('Test file cleaned up');
  });

  it('should read file content', () => {
    const content = fs.readFileSync(tempFile, 'utf8');
    expect(content).toBe('test content');
  });

  it('should write to file', () => {
    fs.writeFileSync(tempFile, 'new content');
    const content = fs.readFileSync(tempFile, 'utf8');
    expect(content).toBe('new content');
  });
});

هوک beforeAll - اجرا یک بار قبل از همه تست‌ها

هوک beforeAll فقط یک بار قبل از اجرای همه تست‌ها اجرا می‌شه:

describe('Database operations', () => {
  beforeAll(async () => {
    console.log('Setting up database connection...');
    await db.connect();
    await db.migrate();
  }, 10000);

  afterAll(async () => {
    console.log('Cleaning up database...');
    await db.disconnect();
  });

  beforeEach(async () => {
    await db.clear();
    await seedTestData();
  });

  it('should create user', async () => {
    const user = await createUser({ name: 'John', email: 'john@example.com' });
    expect(user).toBeDefined();
  });

  it('should find user by email', async () => {
    await createUser({ name: 'Jane', email: 'jane@example.com' });
    const user = await findUserByEmail('jane@example.com');
    expect(user.email).toBe('jane@example.com');
  });
});

هوک afterAll - اجرا یک بار بعد از همه تست‌ها

هوک afterAll فقط یک بار بعد از اجرای همه تست‌ها اجرا می‌شه:

describe('API Integration', () => {
  let server;

  beforeAll(async () => {
    server = await startServer();
  });

  afterAll(async () => {
    await server.stop();
    console.log('Server stopped');
  });

  it('should respond to health check', async () => {
    const response = await request(server).get('/health');
    expect(response.status).toBe(200);
  });
});

ترتیب اجرای هوک‌ها

ترتیب اجرای هوک‌ها به این صورته:

  1. هوک beforeAll (یک بار در ابتدا)
  2. برای هر تست:
    • هوک beforeEach
    • اجرای تست
    • هوک afterEach
  3. هوک afterAll (یک بار در انتها)
describe('Hook execution order', () => {
  beforeAll(() => console.log('1. beforeAll'));

  beforeEach(() => console.log('2. beforeEach'));

  it('test 1', () => {
    console.log('3. test 1');
  });

  it('test 2', () => {
    console.log('4. test 2');
  });

  afterEach(() => console.log('5. afterEach'));

  afterAll(() => console.log('6. afterAll'));
});

// خروجی اجرا:
// 1. beforeAll
// 2. beforeEach
// 3. test 1
// 5. afterEach
// 2. beforeEach
// 4. test 2
// 5. afterEach
// 6. afterAll

هوک‌های Async

هوک‌ها می‌تونن async باشن و می‌تونیم از async/await استفاده کنیم:

describe('Async hooks', () => {
  let database;
  let server;
  let mockData;

  beforeAll(async () => {
    console.log('Setting up test environment...');
    
    // Connect to database
    database = await connectToTestDatabase();
    await database.migrate();
    
    // Start test server
    server = await startTestServer();
    
    // Seed test data
    mockData = await seedTestData();
  }, 30000); // Timeout 30 seconds

  afterAll(async () => {
    console.log('Cleaning up test environment...');
    
    // Stop server
    if (server) {
      await server.stop();
    }
    
    // Close database connection
    if (database) {
      await database.disconnect();
    }
  });

  beforeEach(async () => {
    // Clear data before each test
    await database.clear();
    
    // Reset mocks
    jest.clearAllMocks();
    
    console.log('Test setup complete');
  });

  afterEach(async () => {
    // Clean up any created resources
    await cleanupTestResources();
    
    // Verify no leaks
    await verifyNoResourceLeaks();
    
    console.log('Test cleanup complete');
  });

  it('should handle user registration', async () => {
    const userData = {
      name: 'John Doe',
      email: 'john@example.com',
      password: 'password123'
    };

    const response = await request(server)
      .post('/api/users')
      .send(userData);

    expect(response.status).toBe(201);
    expect(response.body.user.email).toBe(userData.email);
  });

  it('should authenticate user', async () => {
    // Create user first
    await createUser(mockData.user);

    const response = await request(server)
      .post('/api/auth/login')
      .send({
        email: mockData.user.email,
        password: mockData.user.password
      });

    expect(response.status).toBe(200);
    expect(response.body.token).toBeDefined();
  });
});

هوک‌های تودرتو (Nested Hooks)

هوک‌ها می‌تونن در describeهای تودرتو استفاده بشن:

describe('Outer suite', () => {
  beforeAll(() => console.log('Outer beforeAll'));
  afterAll(() => console.log('Outer afterAll'));
  
  beforeEach(() => console.log('Outer beforeEach'));
  afterEach(() => console.log('Outer afterEach'));

  describe('Inner suite A', () => {
    beforeEach(() => console.log('Inner A beforeEach'));
    afterEach(() => console.log('Inner A afterEach'));

    it('test in A', () => {
      console.log('Test in A');
    });
  });

  describe('Inner suite B', () => {
    beforeEach(() => console.log('Inner B beforeEach'));
    afterEach(() => console.log('Inner B afterEach'));

    it('test in B', () => {
      console.log('Test in B');
    });
  });
});

// خروجی:
// Outer beforeAll
// Outer beforeEach
// Inner A beforeEach
// Test in A
// Inner A afterEach
// Outer afterEach
// Outer beforeEach
// Inner B beforeEach
// Test in B
// Inner B afterEach
// Outer afterEach
// Outer afterAll

هوک‌های conditional

گاهی اوقات می‌خوایم هوک‌ها رو شرطی اجرا کنیم:

describe('Conditional hooks', () => {
  let skipDatabaseTests = false;

  beforeAll(async () => {
    // Check if database is available
    try {
      await testDatabaseConnection();
    } catch (error) {
      skipDatabaseTests = true;
      console.log('Database not available, skipping database tests');
    }
  });

  beforeEach(() => {
    if (skipDatabaseTests) {
      // Skip this test suite
      return;
    }
    
    // Normal setup
    resetDatabase();
  });

  describe('Database tests', () => {
    beforeAll(() => {
      if (skipDatabaseTests) {
        // Skip this entire suite
        return;
      }
    });

    it('should create user', async () => {
      if (skipDatabaseTests) {
        // Skip this test
        return;
      }
      
      const user = await createUser({ name: 'John' });
      expect(user).toBeDefined();
    });
  });
});

بهترین روش‌ها برای استفاده از هوک‌ها

1. هوک‌ها رو ساده نگه دارید

هوک‌ها نباید پیچیده باشن:

// ❌ بد
beforeEach(async () => {
  // Too much logic in beforeEach
  await setupDatabase();
  await seedUsers();
  await seedPosts();
  await seedComments();
  await setupCache();
  await setupQueue();
  // ... more setup
});

// ✅ خوب
beforeEach(async () => {
  await resetTestEnvironment();
});

2. از timeout مناسب استفاده کنید

برای هوک‌های ناهمزمان، timeout مناسب تنظیم کنید:

beforeAll(async () => {
  await heavyDatabaseSetup();
}, 60000); // 60 seconds timeout

3. خطاها رو به درستی هندل کنید

beforeEach(async () => {
  try {
    await setupTest();
  } catch (error) {
    console.error('Setup failed:', error);
    throw error; // Re-throw to fail the test
  }
});

4. پاکسازی رو فراموش نکنید

afterEach(async () => {
  // Clean up resources
  await cleanupFiles();
  await cleanupNetworkConnections();
  await resetMocks();
  
  // Verify cleanup
  await verifyNoLeaks();
});

مثال کامل: تست یک سیستم کامل

بیایین یه مثال کامل از استفاده از هوک‌ها در یه سیستم واقعی بزنیم:

describe('E-commerce System', () => {
  let app;
  let server;
  let database;
  let redis;
  let mockPaymentService;

  beforeAll(async () => {
    // Setup test environment
    process.env.NODE_ENV = 'test';
    process.env.DATABASE_URL = 'test-database-url';
    process.env.REDIS_URL = 'test-redis-url';

    // Start services
    database = await setupTestDatabase();
    redis = await setupTestRedis();
    app = await setupTestApp({ database, redis });
    server = await startTestServer(app);

    console.log('✅ Test environment ready');
  }, 60000);

  afterAll(async () => {
    // Cleanup test environment
    await server.stop();
    await database.disconnect();
    await redis.disconnect();
    
    console.log('✅ Test environment cleaned up');
  });

  beforeEach(async () => {
    // Reset state before each test
    await database.clear();
    await redis.flushAll();
    mockPaymentService = setupMockPaymentService();
    
    // Seed basic data
    await seedBasicProducts();
    await seedBasicUsers();
    
    console.log('✅ Test setup complete');
  });

  afterEach(async () => {
    // Cleanup after each test
    await cleanupTestOrders();
    await cleanupTestPayments();
    await resetRateLimits();
    
    // Verify no side effects
    const activeConnections = await getActiveConnections();
    expect(activeConnections).toBe(0);
    
    console.log('✅ Test cleanup complete');
  });

  describe('User Registration', () => {
    it('should register new user', async () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com',
        password: 'securePassword123'
      };

      const response = await request(server)
        .post('/api/auth/register')
        .send(userData);

      expect(response.status).toBe(201);
      expect(response.body.user.email).toBe(userData.email);
      expect(response.body.token).toBeDefined();
    });

    it('should not allow duplicate email', async () => {
      const userData = {
        name: 'John Doe',
        email: 'john@example.com',
        password: 'securePassword123'
      };

      // First registration
      await request(server)
        .post('/api/auth/register')
        .send(userData);

      // Second registration with same email
      const response = await request(server)
        .post('/api/auth/register')
        .send(userData);

      expect(response.status).toBe(409);
      expect(response.body.error).toContain('email already exists');
    });
  });

  describe('Product Management', () => {
    let authToken;

    beforeEach(async () => {
      // Create admin user and get auth token
      const adminUser = await createAdminUser();
      authToken = generateAuthToken(adminUser);
    });

    it('should create new product', async () => {
      const productData = {
        name: 'Test Product',
        description: 'A test product',
        price: 29.99,
        stock: 100
      };

      const response = await request(server)
        .post('/api/products')
        .set('Authorization', `Bearer ${authToken}`)
        .send(productData);

      expect(response.status).toBe(201);
      expect(response.body.product.name).toBe(productData.name);
      expect(response.body.product.price).toBe(productData.price);
    });

    it('should list products', async () => {
      // Create test products
      await createProduct({ name: 'Product 1', price: 10 });
      await createProduct({ name: 'Product 2', price: 20 });

      const response = await request(server)
        .get('/api/products')
        .query({ page: 1, limit: 10 });

      expect(response.status).toBe(200);
      expect(response.body.products).toHaveLength(2);
      expect(response.body.pagination.total).toBe(2);
    });
  });

  describe('Shopping Cart', () => {
    let user;
    let authToken;
    let product;

    beforeEach(async () => {
      user = await createUser({ name: 'John', email: 'john@example.com' });
      authToken = generateAuthToken(user);
      product = await createProduct({ name: 'Test Product', price: 100, stock: 10 });
    });

    it('should add item to cart', async () => {
      const response = await request(server)
        .post('/api/cart/items')
        .set('Authorization', `Bearer ${authToken}`)
        .send({
          productId: product.id,
          quantity: 2
        });

      expect(response.status).toBe(200);
      expect(response.body.cart.items).toHaveLength(1);
      expect(response.body.cart.items[0].productId).toBe(product.id);
      expect(response.body.cart.items[0].quantity).toBe(2);
    });

    it('should not allow adding more than stock', async () => {
      const response = await request(server)
        .post('/api/cart/items')
        .set('Authorization', `Bearer ${authToken}`)
        .send({
          productId: product.id,
          quantity: 15 // More than stock
        });

      expect(response.status).toBe(400);
      expect(response.body.error).toContain('insufficient stock');
    });
  });

  describe('Order Processing', () => {
    let user;
    let authToken;
    let cart;

    beforeEach(async () => {
      user = await createUser({ name: 'John', email: 'john@example.com' });
      authToken = generateAuthToken(user);
      
      // Setup cart with items
      cart = await setupUserCart(user.id, [
        { productId: 1, quantity: 2 },
        { productId: 2, quantity: 1 }
      ]);
    });

    it('should create order from cart', async () => {
      const orderData = {
        shippingAddress: {
          street: '123 Main St',
          city: 'Tehran',
          postalCode: '12345'
        },
        paymentMethod: 'credit_card'
      };

      const response = await request(server)
        .post('/api/orders')
        .set('Authorization', `Bearer ${authToken}`)
        .send(orderData);

      expect(response.status).toBe(201);
      expect(response.body.order.status).toBe('pending');
      expect(response.body.order.total).toBeGreaterThan(0);
    });

    it('should process payment', async () => {
      const order = await createOrder(user.id, cart.items);
      
      const paymentData = {
        orderId: order.id,
        paymentMethod: 'credit_card',
        cardToken: 'test_card_token'
      };

      const response = await request(server)
        .post('/api/orders/process-payment')
        .set('Authorization', `Bearer ${authToken}`)
        .send(paymentData);

      expect(response.status).toBe(200);
      expect(response.body.payment.status).toBe('completed');
      expect(response.body.order.status).toBe('paid');
    });
  });
});

خطاهای رایج در استفاده از هوک‌ها

1. فراموش کردن await

// ❌ بد
beforeEach(() => {
  setupDatabase(); // Missing await
});

// ✅ خوب
beforeEach(async () => {
  await setupDatabase();
});

2. استفاده اشتباه از beforeAll

// ❌ بد - beforeAll doesn't wait for async operations
beforeAll(() => {
  const result = await asyncOperation(); // This will cause an error
});

// ✅ خوب
beforeAll(async () => {
  const result = await asyncOperation();
});

3. پاکسازی نادرست

// ❌ بد - Not cleaning up properly
afterEach(() => {
  // Forgetting to clean up resources
});

// ✅ خوب
afterEach(async () => {
  await cleanupResources();
  await verifyCleanup();
});

4. وابستگی بین تست‌ها

// ❌ بد - Test B depends on Test A
describe('Bad example', () => {
  it('Test A', () => {
    // Modifies global state
  });

  it('Test B', () => {
    // Depends on state modified by Test A
  });
});

// ✅ خوب - Each test is independent
describe('Good example', () => {
  beforeEach(() => {
    // Setup for each test
  });

  it('Test A', () => {
    // Independent test
  });

  it('Test B', () => {
    // Independent test
  });
});

هوک‌ها ابزارهای قدرتمندی هستن که با استفاده درست از اونها می‌تونیم تست‌هایی بنویسیم که:

  • تمیز و خوانا باشن
  • ایزوله و مستقل باشن
  • قابل اعتماد و بدون side effect باشن
  • سریع و بهینه باشن

تست کد async پیشرفته

تست کد async می‌تونه چالش برانگیز باشه، اما با تکنیک‌های پیشرفته می‌تونیم کنترل بهتری روی تست‌هامون داشته باشیم.

تست‌های موازی و ترتیب اجرا

describe('Async operations', () => {
  it('should handle multiple async operations', async () => {
    const promises = [
      fetchData(1),
      fetchData(2),
      fetchData(3)
    ];

    const results = await Promise.all(promises);
    expect(results).toHaveLength(3);
  });

  it('should handle async operations in sequence', async () => {
    const result1 = await fetchData(1);
    const result2 = await fetchData(result1.id);
    const result3 = await fetchData(result2.id);

    expect(result3).toBeDefined();
  });
});

تست timeoutها و خطاهای زمان‌بندی شده

describe('Timeout handling', () => {
  it('should timeout after specified duration', async () => {
    await expect(
      timeoutOperation(1000)
    ).rejects.toThrow('Operation timed out');
  }, 2000);

  it('should handle race conditions', async () => {
    const fastOperation = delay(100).then(() => 'fast');
    const slowOperation = delay(1000).then(() => 'slow');

    const result = await Promise.race([fastOperation, slowOperation]);
    expect(result).toBe('fast');
  });
});

شبیه‌سازی شرایط network

describe('Network conditions', () => {
  beforeEach(() => {
    // Mock network delays
    jest.spyOn(global, 'fetch').mockImplementation((url) => {
      return new Promise((resolve) => {
        setTimeout(() => {
          resolve({
            ok: true,
            json: () => Promise.resolve({ data: 'test' })
          });
        }, 1000); // 1 second delay
      });
    });
  });

  it('should handle slow network', async () => {
    const startTime = Date.now();
    const result = await fetch('/api/data');
    const duration = Date.now() - startTime;

    expect(duration).toBeGreaterThan(1000);
    expect(result.ok).toBe(true);
  });
});

تست نویسی یک سفر یادگیری مستمره و هرچقدر بیشتر تمرین کنید، بهتر می‌شید. با یادگیری این تکنیک‌های پیشرفته، شما الان می‌تونید تست‌های حرفه‌ای‌تر و قدرتمند‌تری بنویسید که کیفیت کد شما رو به میزان قابل توجهی افزایش بدن.

موفق باشید و به تست نویسی ادامه بدید!

خب دوستان، امیدوارم این مقاله براتون مفید بوده باشه و بتونید از این تکنیک‌های پیشرفته در پروژه‌های واقعی استفاده کنید. توی مقاله بعدی، قصد داریم وارد تست‌های یکپارچگی (integration test) بشیم و یاد بگیریم چطور مولفه‌های مختلف سیستم رو با هم تست کنیم. تا بعد!