← Назад к вопросам

Как реализованы тесты на проекте?

2.0 Middle🔥 291 комментариев
#Soft skills и опыт работы#Тестирование

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI29 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как реализованы тесты на проекте?

Тестирование — не хобби, а обязательная часть разработки. На моем проекте используется многоуровневая стратегия тестирования с высокой coverage и fast feedback loop. Покажу реальный пример архитектуры.

Pyramid Testing: правильная структура

Использую тестовую пирамиду:

      ┌─────────────────┐
      │   E2E Тесты     │  5% (Google Chrome)
      ├─────────────────┤
      │ Integration     │ 20% (API, БД, очереди)
      ├─────────────────┤
      │    Unit         │ 75% (функции, классы)
      └─────────────────┘

Unit тесты (75%)

  • Быстрые (миллисекунды)
  • Изолированные (mock зависимости)
  • Дешевые в поддержке
  • Запускаются на CI за 10 секунд

Integration тесты (20%)

  • Проверяют взаимодействие с БД, кэшем, очередями
  • Более медленные, но все еще быстрые
  • Запускаются на CI за 30-60 секунд

E2E тесты (5%)

  • Тестируют весь workflow (API -> БД -> сообщения)
  • Самые медленные
  • Запускаются ночью или перед релизом

1. Unit тесты с Jest

// src/services/__tests__/UserService.test.ts
import { UserService } from '../UserService';
import { UserRepository } from '../../repositories/UserRepository';

describe('UserService', () => {
  let userService: UserService;
  let mockRepository: jest.Mocked<UserRepository>;

  beforeEach(() => {
    // Mock репозитория
    mockRepository = {
      findById: jest.fn(),
      save: jest.fn(),
      delete: jest.fn(),
    } as any;

    userService = new UserService(mockRepository);
  });

  describe('getUserProfile', () => {
    it('should return user profile if user exists', async () => {
      // Arrange
      const userId = 'user-123';
      const userData = {
        id: userId,
        email: 'user@example.com',
        name: 'John Doe',
      };

      mockRepository.findById.mockResolvedValue(userData);

      // Act
      const result = await userService.getUserProfile(userId);

      // Assert
      expect(result).toEqual(userData);
      expect(mockRepository.findById).toHaveBeenCalledWith(userId);
      expect(mockRepository.findById).toHaveBeenCalledTimes(1);
    });

    it('should throw NotFoundException if user does not exist', async () => {
      // Arrange
      const userId = 'non-existent';
      mockRepository.findById.mockResolvedValue(null);

      // Act & Assert
      await expect(userService.getUserProfile(userId)).rejects.toThrow(
        'User not found'
      );
    });

    it('should handle database errors gracefully', async () => {
      // Arrange
      mockRepository.findById.mockRejectedValue(
        new Error('Database connection failed')
      );

      // Act & Assert
      await expect(userService.getUserProfile('any')).rejects.toThrow(
        'Database connection failed'
      );
    });
  });

  describe('updateUserEmail', () => {
    it('should update email and return updated user', async () => {
      const userId = 'user-123';
      const newEmail = 'newemail@example.com';
      const updatedUser = {
        id: userId,
        email: newEmail,
        name: 'John Doe',
      };

      mockRepository.findById.mockResolvedValue({
        id: userId,
        email: 'old@example.com',
        name: 'John Doe',
      });

      mockRepository.save.mockResolvedValue(updatedUser);

      // Act
      const result = await userService.updateUserEmail(userId, newEmail);

      // Assert
      expect(result.email).toBe(newEmail);
      expect(mockRepository.save).toHaveBeenCalledWith(
        expect.objectContaining({
          id: userId,
          email: newEmail,
        })
      );
    });
  });
});

2. Integration тесты с тестовой БД

// src/services/__tests__/UserService.integration.test.ts
import { Database } from '../../database/Database';
import { UserRepository } from '../../repositories/UserRepository';
import { UserService } from '../UserService';

describe('UserService Integration', () => {
  let db: Database;
  let userRepository: UserRepository;
  let userService: UserService;

  // beforeAll запускается один раз перед всеми тестами
  beforeAll(async () => {
    db = new Database({
      host: 'localhost',
      database: process.env.TEST_DB_NAME || 'test_db',
      // ...
    });

    await db.connect();
    await db.runMigrations(); // Накатываем миграции
  });

  // afterAll убирает за собой
  afterAll(async () => {
    await db.rollbackMigrations(); // Откатываем миграции
    await db.disconnect();
  });

  // beforeEach очищает данные перед каждым тестом
  beforeEach(async () => {
    await db.truncateAllTables();
  });

  it('should create user and retrieve from database', async () => {
    // Arrange
    const userData = {
      email: 'test@example.com',
      name: 'Test User',
      password: 'hashed_password',
    };

    // Act
    const createdUser = await userRepository.create(userData);

    // Assert
    const foundUser = await userRepository.findById(createdUser.id);
    expect(foundUser).toEqual(expect.objectContaining(userData));
    expect(foundUser.id).toBeDefined();
  });

  it('should update user and persist changes', async () => {
    // Arrange
    const user = await userRepository.create({
      email: 'test@example.com',
      name: 'Original Name',
      password: 'hashed',
    });

    // Act
    await userRepository.save({
      ...user,
      name: 'Updated Name',
    });

    // Assert
    const updated = await userRepository.findById(user.id);
    expect(updated.name).toBe('Updated Name');
  });

  it('should enforce unique email constraint', async () => {
    // Arrange
    const email = 'unique@example.com';
    await userRepository.create({
      email,
      name: 'User 1',
      password: 'hashed',
    });

    // Act & Assert
    await expect(
      userRepository.create({
        email,
        name: 'User 2',
        password: 'hashed',
      })
    ).rejects.toThrow('Unique constraint violation');
  });
});

3. API Integration тесты

// src/api/__tests__/users.e2e.test.ts
import request from 'supertest';
import { createApp } from '../../app';
import { Database } from '../../database/Database';

describe('POST /api/v1/users', () => {
  let app;
  let db: Database;

  beforeAll(async () => {
    app = createApp();
    db = new Database({ host: 'localhost', database: 'test_db' });
    await db.connect();
    await db.runMigrations();
  });

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

  afterAll(async () => {
    await db.rollbackMigrations();
    await db.disconnect();
  });

  it('should create user with valid data', async () => {
    // Act
    const response = await request(app)
      .post('/api/v1/users')
      .send({
        email: 'newuser@example.com',
        name: 'New User',
        password: 'SecurePassword123',
      })
      .expect(201); // Проверяем status code

    // Assert
    expect(response.body).toHaveProperty('id');
    expect(response.body.email).toBe('newuser@example.com');
    expect(response.body).not.toHaveProperty('password'); // Пароль не должен возвращаться
  });

  it('should return 400 if email is invalid', async () => {
    // Act & Assert
    await request(app)
      .post('/api/v1/users')
      .send({
        email: 'invalid-email',
        name: 'User',
        password: 'password',
      })
      .expect(400);
  });

  it('should return 409 if email already exists', async () => {
    // Arrange
    const email = 'existing@example.com';
    await request(app)
      .post('/api/v1/users')
      .send({
        email,
        name: 'First User',
        password: 'password123',
      });

    // Act & Assert
    await request(app)
      .post('/api/v1/users')
      .send({
        email,
        name: 'Second User',
        password: 'password123',
      })
      .expect(409);
  });
});

4. Асинхронные очереди - тестирование с VCR

// src/services/__tests__/EmailService.test.ts
import nock from 'nock';
import { EmailService } from '../EmailService';

describe('EmailService with HTTP calls', () => {
  let emailService: EmailService;

  beforeEach(() => {
    emailService = new EmailService('https://api.sendgrid.com');
  });

  afterEach(() => {
    nock.cleanAll(); // Убираем все mocks
  });

  it('should send email via SendGrid API', async () => {
    // Arrange: мокируем HTTP запрос
    nock('https://api.sendgrid.com')
      .post('/v3/mail/send', {
        personalizations: [{ to: [{ email: 'user@example.com' }] }],
        from: { email: 'noreply@app.com' },
        subject: 'Welcome',
        content: [{ type: 'text/html', value: 'Welcome!' }],
      })
      .reply(202); // SendGrid возвращает 202 при успехе

    // Act
    const result = await emailService.send({
      to: 'user@example.com',
      subject: 'Welcome',
      html: 'Welcome!',
    });

    // Assert
    expect(result.status).toBe('sent');
  });

  it('should handle SendGrid API errors', async () => {
    // Arrange
    nock('https://api.sendgrid.com')
      .post('/v3/mail/send')
      .reply(400, { errors: [{ message: 'Invalid email' }] });

    // Act & Assert
    await expect(
      emailService.send({
        to: 'invalid-email',
        subject: 'Test',
        html: 'Test',
      })
    ).rejects.toThrow('Invalid email');
  });
});

5. Redis / Cache тесты

// src/services/__tests__/CacheService.test.ts
import Redis from 'ioredis';
import { CacheService } from '../CacheService';

describe('CacheService', () => {
  let redis: Redis;
  let cacheService: CacheService;

  beforeAll(async () => {
    redis = new Redis({
      host: process.env.REDIS_HOST || 'localhost',
      db: 15, // Используем отдельную БД для тестов
    });
    cacheService = new CacheService(redis);
  });

  beforeEach(async () => {
    await redis.flushdb(); // Очищаем БД перед каждым тестом
  });

  afterAll(async () => {
    await redis.disconnect();
  });

  it('should cache value and retrieve it', async () => {
    // Arrange
    const key = 'user:123';
    const value = { id: 123, name: 'John' };

    // Act
    await cacheService.set(key, value, 3600);
    const result = await cacheService.get(key);

    // Assert
    expect(result).toEqual(value);
  });

  it('should expire cache after TTL', async () => {
    // Arrange
    await cacheService.set('temp', 'value', 1); // TTL 1 сек

    // Act
    await new Promise(resolve => setTimeout(resolve, 1100)); // Ждем 1.1 сек
    const result = await cacheService.get('temp');

    // Assert
    expect(result).toBeNull();
  });
});

6. Тестирование с Mocking библиотеками

// Mock модуля
jest.mock('../../external/PaymentGateway', () => ({
  PaymentGateway: {
    charge: jest.fn(),
  },
}));

import { PaymentGateway } from '../../external/PaymentGateway';

describe('PaymentService', () => {
  it('should process payment successfully', async () => {
    (PaymentGateway.charge as jest.Mock).mockResolvedValue({
      transactionId: 'tx-123',
      status: 'success',
    });

    const result = await paymentService.processPayment(100, 'card-token');

    expect(result.status).toBe('success');
    expect(PaymentGateway.charge).toHaveBeenCalledWith(100, 'card-token');
  });
});

7. Coverage отчет

# Запуск с coverage
npm test -- --coverage

# Результат:
# Statements   : 92.5% ( 445/482 )
# Branches     : 88.3% ( 234/265 )
# Functions    : 90.1% ( 91/101 )
# Lines        : 93.2% ( 401/430 )

Правило: coverage >= 85%, ideally 90%+. Но это не золотой стандарт — важнее качество тестов, чем их количество.

8. CI/CD интеграция

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_DB: test_db
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

      redis:
        image: redis:7
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 6379:6379

    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'

      - run: npm install
      - run: npm run lint
      - run: npm test -- --coverage
      - run: npm run test:integration

      # Upload coverage report
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

9. Best Practices

1. Тестируй поведение, а не реализацию

// ❌ Плохо: тестируем реализацию
expect(userService.cache).toBeDefined();

// ✅ Хорошо: тестируем поведение
const user1 = await userService.getUser('123');
const user2 = await userService.getUser('123');
expect(mockRepository.findById).toHaveBeenCalledTimes(1); // Кэш работает

2. Используй descriptive names

// ❌ Плохо
it('works', async () => {});

// ✅ Хорошо
it('should return cached user on second request without hitting database', async () => {});

3. Используй AAA pattern (Arrange-Act-Assert)

it('should update user email', async () => {
  // Arrange
  const user = { id: '123', email: 'old@example.com' };
  
  // Act
  await userService.updateEmail(user.id, 'new@example.com');
  
  // Assert
  const updated = await userRepository.findById(user.id);
  expect(updated.email).toBe('new@example.com');
});

Итог

Тестирование — это инвестиция в качество. На первый взгляд медленнее, но:

  • Легче рефакторить без страха что-то сломать
  • Меньше ошибок в production
  • Код становится лучше (если пишешь тесты до кода — TDD)
  • Новичкам легче разобраться в коде через тесты

Мой подход: Unit + Integration = 95%, E2E = 5%. Это баланс скорости обратной связи и надежности.

Как реализованы тесты на проекте? | PrepBro