civil-and-structural-engineering
How to Implement Dependency Injection to Facilitate Unit Testing in Engineering Projects
Table of Contents
Dependency injection (DI) is a design pattern that directly supports testability and maintainability in software engineering. By decoupling component creation from component usage, DI allows developers to substitute real implementations with mock objects, stubs, or fakes during unit testing. This isolation is essential for verifying the behavior of individual units of code without interference from databases, network calls, or other external systems. Beyond testing, DI improves modularity, reduces tight coupling, and makes large codebases easier to reason about and evolve. In this article, we will explore what dependency injection is, how to implement it effectively, and how it facilitates robust unit testing in engineering projects.
Understanding Dependency Injection
At its core, dependency injection is about inverting the control of dependency creation. Instead of a class creating its own dependencies internally (for example, calling new Database() inside a constructor), those dependencies are provided—or injected—from the outside. This principle is a specific form of the Inversion of Control (IoC) pattern. The goal is to achieve loose coupling: a class depends on an abstraction (an interface or an abstract base class) rather than on a concrete implementation.
For example, consider a UserService that needs to persist user data. Without DI, the service might instantiate a specific MySQLDatabase class directly. If the team later decides to switch to PostgreSQL or use an in-memory store for testing, the UserService class must be rewritten. With DI, the service declares a dependency on a DatabaseInterface and receives an implementation at construction time. The service no longer has to know which database is used, making it both more flexible and far easier to test.
The benefits of DI extend beyond testing. They include:
- Reduced coupling: Components depend on abstractions, not concrete classes.
- Increased reusability: A component can be reused in different contexts by injecting different dependencies.
- Better adherence to the Single Responsibility Principle: Classes do not need to manage the lifecycle of their dependencies.
- Simplified configuration management: Dependencies can be wired together in a central location.
However, DI also introduces complexity if overused or applied without discipline. It requires careful design of interfaces and may lead to an explosion of small configuration files. For most medium-to-large projects, the advantages far outweigh the costs.
Types of Dependency Injection
There are three primary ways to inject dependencies: constructor injection, setter injection, and interface injection. Each has trade-offs and is suited for different situations.
Constructor Injection
Constructor injection is the most common and recommended approach, especially for mandatory dependencies. Dependencies are passed as parameters to the class constructor. This makes the required dependencies explicit and ensures the object is in a valid state immediately after construction.
class UserService {
private DatabaseInterface $database;
private LoggerInterface $logger;
public function __construct(DatabaseInterface $database, LoggerInterface $logger) {
$this->database = $database;
$this->logger = $logger;
}
public function registerUser(string $email, string $password): void {
$this->logger->info("Registering user: $email");
$this->database->save('users', ['email' => $email, 'password' => $password]);
}
}
During unit testing, you pass mock or stub implementations:
class UserServiceTest extends TestCase {
public function testRegisterUserLogsAndSaves(): void {
$mockDatabase = $this->createMock(DatabaseInterface::class);
$mockLogger = $this->createMock(LoggerInterface::class);
$mockLogger->expects($this->once())
->method('info')
->with($this->stringContains('Registering user'));
$mockDatabase->expects($this->once())
->method('save')
->with('users', $this->anything());
$service = new UserService($mockDatabase, $mockLogger);
$service->registerUser('[email protected]', 's3cret');
}
}
Constructor injection is ideal when the dependency is required for the object to function. It clearly documents the dependency chain and makes refactoring safe: adding a new required dependency will break all instantiations, forcing the developer to update them.
Setter Injection
Setter injection uses setter methods to provide dependencies after the object has been created. It is primarily used for optional dependencies or when you need to change the dependency after construction. However, it can lead to incomplete object states if a required dependency is never set.
class UserService {
private ?DatabaseInterface $database = null;
public function setDatabase(DatabaseInterface $database): void {
$this->database = $database;
}
public function registerUser(string $email, string $password): void {
if ($this->database === null) {
throw new \RuntimeException('Database not configured');
}
$this->database->save('users', ['email' => $email, 'password' => $password]);
}
}
Testing with setter injection is straightforward: you call the setter with a mock after constructing the object. The downside is that the object can exist in an invalid state, so it’s important to document which setter calls are mandatory. Many dependency injection frameworks support setter injection, but constructor injection is generally preferred for its explicitness.
Interface Injection
Interface injection forces a dependency to be provided by implementing a special interface. The object is “injected” with a dependency through a method defined in that interface. This pattern is rare because it adds complexity without much benefit in many languages. It is more common in frameworks that treat DI as a core concept, such as Java’s Spring. In PHP, you might see it in legacy code or frameworks that require a specific interface for configuring dependencies.
interface DatabaseAwareInterface {
public function setDatabase(DatabaseInterface $database): void;
}
class UserService implements DatabaseAwareInterface {
private DatabaseInterface $database;
public function setDatabase(DatabaseInterface $database): void {
$this->database = $database;
}
}
In practice, constructor injection covers the vast majority of use cases. Setter injection is useful for optional dependencies or those that need to be changed at runtime. Interface injection is best avoided unless you are building a framework that requires it.
Implementing Dependency Injection in Practice
To implement DI effectively, you must design your classes around abstractions (interfaces or abstract classes). Let’s walk through a realistic example: an order processing system that sends email notifications and logs every step.
First, define interfaces for the two external services:
interface MailerInterface {
public function send(string $to, string $subject, string $body): bool;
}
interface LoggerInterface {
public function log(string $level, string $message): void;
}
Then build the OrderProcessor class that depends on these abstractions:
class OrderProcessor {
private MailerInterface $mailer;
private LoggerInterface $logger;
public function __construct(MailerInterface $mailer, LoggerInterface $logger) {
$this->mailer = $mailer;
$this->logger = $logger;
}
public function processOrder(array $order): void {
$this->logger->log('info', 'Processing order ' . $order['id']);
// ... actual order processing logic ...
$sent = $this->mailer->send(
$order['customer_email'],
'Order Confirmation',
'Your order has been processed.'
);
if ($sent) {
$this->logger->log('info', 'Confirmation sent to ' . $order['customer_email']);
} else {
$this->logger->log('error', 'Failed to send email for order ' . $order['id']);
}
}
}
Now, unit testing OrderProcessor becomes trivial. You no longer need to set up a real mail server or file system for logs. Instead, you inject mock implementations:
class OrderProcessorTest extends TestCase {
public function testProcessOrderSendsConfirmation(): void {
$order = ['id' => 100, 'customer_email' => '[email protected]'];
$mockMailer = $this->createMock(MailerInterface::class);
$mockMailer->method('send')->willReturn(true);
$mockLogger = $this->createMock(LoggerInterface::class);
$mockLogger->expects($this->once())
->method('log')
->with('info', $this->stringContains('Processed'));
$processor = new OrderProcessor($mockMailer, $mockLogger);
$processor->processOrder($order);
}
}
This test verifies that the order processor calls the logger and mailer correctly, without requiring any infrastructure. The same approach scales to any external dependency: databases, APIs, file systems, or even other services within the same application.
Benefits for Unit Testing
Dependency injection fundamentally transforms unit testing by making isolation possible. Without DI, testing a class that calls a database or an external API would require expensive setup and teardown, often making tests slow, brittle, and order-dependent. With DI, each test can be run in near-zero time with full control over behavior.
- True unit isolation: The unit under test (e.g., a service class) is tested in complete isolation from its dependencies. Mocks and stubs simulate the behavior of those dependencies, allowing you to verify outputs, side effects, and interaction patterns.
- Simplified test setup: You no longer need to initialize a database or start a web server. Tests become purely in-memory and deterministic.
- Improved code modularity: Designing for DI forces you to write classes that depend on abstractions. This naturally leads to smaller, more focused classes that are easier to test individually.
- Facilitates testing of complex systems: In applications with many collaborating components, DI allows you to test each piece independently. Integration tests can then focus on verifying the interactions between real components, while unit tests cover the logic of each component.
- Reduces coupling between components: Loose coupling not only helps testing but also allows teams to work on different parts of the codebase without causing cascading failures.
Best Practices
To get the most out of dependency injection, follow these guidelines:
- Use constructor injection for mandatory dependencies. It makes the dependency requirements explicit and prevents objects from being created in an incomplete state.
- Employ interfaces (or type declarations) for dependencies. This enables easy mocking and substitution. Avoid depending on concrete classes unless they are value objects or have no side effects.
- Keep dependencies minimal and focused. A class with many dependencies suggests it has too many responsibilities. Apply the Single Responsibility Principle and refactor large classes into smaller ones.
- Do not manually wire dependencies in production code for large projects. Use a dependency injection container (also called an IoC container) to automate the wiring. Popular containers for PHP include PHP-DI and Symfony’s service container. For JavaScript/TypeScript, consider tsyringe or NestJS’s built-in DI.
- Prefer immutability where possible. After construction, try to avoid changing dependencies. This makes the object’s life cycle predictable.
- Write tests that mock at the boundary of your code. Mock only the dependencies your class uses directly. Avoid mocking deep inside the code; use integration tests for end-to-end scenarios.
- Use dependency inversion, not just injection. Remember that DI is a technique to achieve the Dependency Inversion Principle (DIP): high-level modules should not depend on low-level modules; both should depend on abstractions.
Common Pitfalls and How to Avoid Them
While DI is powerful, it can be misapplied. Here are some pitfalls to watch for:
- Over-injection (constructor bloat): A constructor with many parameters is a sign that the class is doing too much. Refactor the class into smaller, focused components. For example, instead of injecting three separate services, consider creating a facade that groups them.
- Using DI containers too early in small projects: For tiny applications, manually wiring dependencies is simpler and avoids the overhead of a container. Add a container only when the wiring becomes painful.
- Making every class injectable: Not everything needs to be injected. Value objects, DTOs, and other data-only classes should be instantiated directly. Over-engineering with DI adds unnecessary indirection.
- Relying on service locators instead of true injection: The service locator pattern hides dependencies and makes testing harder because dependencies are resolved internally. Stick to explicit dependency injection.
- Global mutable state: Even with DI, if your mocks or stubs leak state across tests (e.g., static properties), tests can become order-dependent. Always reset mocks and use fresh instances in each test.
Conclusion
Dependency injection is a cornerstone of testable software design. By decoupling the creation and resolution of dependencies from the logic that uses them, you gain the ability to test each piece of code in isolation, with minimal setup and maximum control. Beyond testing, DI leads to more modular, maintainable, and adaptable codebases. Start by designing your classes around interfaces, adopt constructor injection as a default, and utilize a lightweight container as your project grows. Your tests—and your future self—will thank you.
For further reading, consult Martin Fowler’s classic article on Inversion of Control and Dependency Injection and the PHPUnit documentation on test doubles.