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делает подстановку мока сложнее.