Dependency Injection для тестируемости

Dependency Injection (DI) — паттерн, при котором зависимости (сервисы, репозитории) передаются компоненту/классу снаружи, а не создаются внутри, что позволяет заменять их на моки в тестах.

Зачем нужно

Если класс сам создаёт зависимости (new ApiClient), протестировать его без реального API невозможно. DI инвертирует это: класс объявляет что ему нужно (через конструктор или параметры функции), а вызывающая сторона передаёт реализацию. В тестах передаётся mock-объект, в production — реальный. Это делает юнит-тесты быстрыми, изолированными и без побочных эффектов.

Где используется

  • Сервисы с зависимостями от API, БД, localStorage
  • Angular — встроенный DI-контейнер
  • React — DI через props, Context API, или функциональные параметры
  • Inversify.js — DI-контейнер для TypeScript

DI без контейнера (простейший способ)

// Интерфейс — что нужно сервису (не конкретная реализация)
interface IUserRepository {
  findById(id: string): Promise<User>;
  save(user: User): Promise<void>;
}

// Сервис принимает зависимость через конструктор
class UserService {
  constructor(
    private repository: IUserRepository, // DI: зависимость снаружи
    private emailService: IEmailService,
  ) {}

  async updateEmail(userId: string, newEmail: string): Promise<void> {
    const user = await this.repository.findById(userId);
    user.email = newEmail;
    await this.repository.save(user);
    await this.emailService.sendConfirmation(newEmail);
  }
}

// Production: реальные зависимости
const userService = new UserService(
  new ApiUserRepository,
  new SmtpEmailService,
);

// Тест: mock-зависимости
const mockRepository: IUserRepository = {
  findById: jest.fn.mockResolvedValue({ id: '1', email: 'old@test.com' }),
  save: jest.fn.mockResolvedValue(undefined),
};
const mockEmailService = { sendConfirmation: jest.fn };

const userService = new UserService(mockRepository, mockEmailService);

test('updateEmail сохраняет новый email', async () => {
  await userService.updateEmail('1', 'new@test.com');
  expect(mockRepository.save).toHaveBeenCalledWith(
    expect.objectContaining({ email: 'new@test.com' })
  );
});

DI через параметры функции (функциональный стиль)

// Зависимость — параметр функции (не хардкод внутри)
async function fetchUser(
  id: string,
  fetcher = fetch // по умолчанию реальный fetch
): Promise<User> {
  const response = await fetcher(`/api/users/${id}`);
  return response.json();
}

// Production — реальный fetch по умолчанию
const user = await fetchUser('42');

// Тест — mock вместо fetch
const mockFetch = jest.fn.mockResolvedValue({
  json:  => ({ id: '42', name: 'Test User' }),
});
const user = await fetchUser('42', mockFetch);
expect(mockFetch).toHaveBeenCalledWith('/api/users/42');

DI в React через Context

// Context как DI-контейнер в React
const ServicesContext = createContext(null);

// Production: реальные сервисы
function App() {
  const services = {
    userService: new UserService(new ApiRepository),
    authService: new AuthService,
  };

  return (
    <ServicesContext.Provider value={services}>
      <Router />
    </ServicesContext.Provider>
  );
}

// В тестах: mock-сервисы
function TestWrapper({ children }) {
  const mockServices = {
    userService: { getUser: jest.fn.mockResolvedValue(mockUser) },
    authService: { login: jest.fn },
  };

  return (
    <ServicesContext.Provider value={mockServices}>
      {children}
    </ServicesContext.Provider>
  );
}

// Компонент не знает про реализацию
function UserPage() {
  const { userService } = useContext(ServicesContext);
  const [user, setUser] = useState(null);

  useEffect(() => {
    userService.getUser('me').then(setUser);
  }, [userService]);

  return <div>{user?.name}</div>;
}

Частые ошибки

  • Хардкод зависимостейconst api = new ApiClient внутри класса/функции; невозможно заменить в тестах.
  • DI-контейнер ради DI — Inversify или tsyringe для небольшого проекта избыточны; простой конструктор с параметрами справится.
  • Передают конкретный класс вместо интерфейса — принимать ApiRepository вместо IRepository делает подстановку мока сложнее.

Связанные темы

Ресурсы