Avatar ·

Как правильно тестировать приватные методы в Angular/TypeScript с помощью Jasmine?

Всем привет! Я новичок в Angular и TypeScript, и у меня возникла проблема с написанием unit-тестов для приватных методов. У меня есть сервис, который выполняет сложные вычисления, и я вынес часть логики в приватный метод для лучшей читаемости. Вот пример моего кода: ```typescript import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class CalculationService { public calculateTotal(items: number[]): number { const filtered = this.filterInvalidItems(items); return filtered.reduce((sum, item) => sum + item, 0); } private filterInvalidItems(items: number[]): number[] { return items.filter(item => item > 0 && item < 1000); } } ``` Когда я пытаюсь написать тест для `filterInvalidItems`, используя Jasmine, я получаю ошибку, что метод приватный и недоступен из теста: ```typescript describe('CalculationService', () => { let service: CalculationService; beforeEach(() => { service = new CalculationService(); }); it('should filter invalid items', () => { const result = service.filterInvalidItems([-1, 0, 500, 1500]); // Ошибка! expect(result).toEqual([500]); }); }); ``` Я понимаю, что TypeScript выбрасывает ошибку компиляции, так как `filterInvalidItems` приватный. Но мне кажется, что тестировать эту логику напрямую было бы удобнее, чем через публичный метод. Как правильно подойти к этой ситуации? Стоит ли менять модификатор доступа или есть другие способы? Я слышал, что можно использовать `// @ts-ignore` или обращаться через скобки, но это кажется костылем. Подскажите, как принято тестировать приватные методы в Angular-сообществе? <<>>

Почему не стоит тестировать приватные методы напрямую

То, что вы описываете — очень распространённая ошибка среди начинающих разработчиков. TypeScript намеренно скрывает приватные методы, чтобы вы не нарушали инкапсуляцию. Основная идея unit-тестирования — проверять публичное поведение класса, а не его внутреннюю реализацию. Если вы протестируете приватный метод, а потом решите его переименовать или разбить на два метода — тест сломается, хотя публичный API останется корректным.

Правильный подход: тестируйте через публичный интерфейс

В вашем случае достаточно написать тест для публичного метода calculateTotal, который косвенно проверит работу filterInvalidItems:

describe('CalculationService', () => {
  let service: CalculationService;

  beforeEach(() => {
    service = new CalculationService();
  });

  it('should calculate total only for valid items', () => {
    const items = [-1, 0, 500, 1500, 200];
    const result = service.calculateTotal(items);
    // Ожидаем: 500 + 200 = 700 (отрицательные, ноль и >1000 отброшены)
    expect(result).toBe(700);
  });

  it('should return 0 for empty array', () => {
    expect(service.calculateTotal([])).toBe(0);
  });

  it('should handle edge cases (only invalid items)', () => {
    expect(service.calculateTotal([-5, 0, 2000])).toBe(0);
  });
});

Эти тесты покрывают все сценарии, которые вы хотели проверить в приватном методе, но делают это через публичный контракт.

Когда приватные методы — это плохой дизайн?

Если приватный метод содержит сложную логику, которую вы хотите протестировать отдельно — это сигнал, что её стоит вынести в отдельный сервис или утилитарный класс:

// filter-utils.ts
export function filterValidItems(items: number[]): number[] {
  return items.filter(item => item > 0 && item < 1000);
}

// calculation.service.ts
import { filterValidItems } from './filter-utils';

@Injectable({ providedIn: 'root' })
export class CalculationService {
  public calculateTotal(items: number[]): number {
    const filtered = filterValidItems(items);
    return filtered.reduce((sum, item) => sum + item, 0);
  }
}

Теперь вы можете протестировать filterValidItems как чистую функцию без привязки к Angular-сервису.

Если очень нужно — "хаки" для доступа

В реальной практике это не рекомендуется, но вы можете использовать следующие приёмы (только для легаси-кода, который нельзя рефакторить):

  • Приведение типа через as any: (service as any).filterInvalidItems([...])
  • Использование скобочной нотации: service['filterInvalidItems']([...])
  • Комментарий // @ts-ignore перед строкой вызова

Все эти способы работают, потому что TypeScript — это надстройка, и в рантайме (JavaScript) приватность не сохраняется. Но помните: так вы тестируете детали реализации, а не поведение.

Итог

Лучшая практика — не тестировать приватные методы вообще. Тестируйте публичное поведение через публичные методы. Если логика слишком сложна — выносите её в отдельные модули. Это сделает ваш код более гибким, тестируемым и поддерживаемым.

<<>>

Альтернативный подход: рефакторинг через отдельный класс

Если вы всё же хотите тестировать логику фильтрации отдельно, рассмотрите вынесение её в чистый класс без Angular-декораторов. Это особенно полезно, если логика используется в нескольких сервисах:

export class ItemFilter {
  public filterValid(items: number[]): number[] {
    return items.filter(item => item > 0 && item < 1000);
  }
}

// В сервисе:
@Injectable()
export class CalculationService {
  private filter = new ItemFilter();

  public calculateTotal(items: number[]): number {
    return this.filter.filterValid(items)
      .reduce((sum, item) => sum + item, 0);
  }
}

Теперь вы легко тестируете ItemFilter как обычный класс, а сервис проверяете через моки:

describe('ItemFilter', () => {
  it('should remove negative numbers', () => {
    const filter = new ItemFilter();
    expect(filter.filterValid([-1, 5])).toEqual([5]);
  });
});

Этот подход сохраняет инкапсуляцию, улучшает переиспользование и делает тесты более изолированными. Единственный минус — нужно создавать дополнительный класс, но в долгосрочной перспективе это окупается.