DEV Community

Peter Merrill
Peter Merrill

Posted on • Originally published at peterm.hashnode.dev on

Testing Without the Tears: How Mocking Boundaries Can Strengthen Your Tests

Writing robust tests is a crucial line of defense for building high-quality software. However, external dependencies like databases, file systems, and even time itself can transform the testing process into a battlefield. They introduce complexity, slow things down, and make your tests brittle, leaving you with a maintenance headache and shaky confidence.

This article isn't about navigating that obstacle course it's about dismantling it. We'll explore a powerful approach called "mocking across architectural boundaries," inspired by Matthias Noback's insightful work. By shifting your perspective and focusing on the key interactions between your code and its external world, you can unlock a testing strategy that's:

  • Efficient: Say goodbye to time-consuming setups and teardowns. Focus on testing your core logic, not external intricacies.

  • Reliable: Gain confidence that your tests reflect the real-world behavior of your code, not a mocked-up mirage.

  • Maintainable: Write tests that are easier to understand and update, ensuring long-term code health.

Mocking the Wrong Thing

We've all been there: testing code that's best friends with external dependencies. The temptation to mock, down to the nitty-gritty details (think EntityManager in Doctrine ORM or DateTime in PHP), can be strong. It feels like you're giving your code a shield, keeping it safe and predictable during test time.

But hold on to your test hats, because this approach can backfire in a big way. Here's why:

  1. Brittle Bonds: Mocked dependencies become your code's new best friends, and any changes (like refactoring) can break your tests. Suddenly, you're stuck fixing tests instead of focusing on new features.

  2. Blinding You to Reality: By mocking everything, you're basically creating a test world that's pretty far removed from the real one. You're missing out on the "fun" stuff - network failures, disk errors, time zone woes - that your code might encounter in the wild.

  3. Confidence Deficit: Remember, the goal is to have faith in your code's ability to handle the real world, not just a test world. Mocked dependencies don't give you that confidence boost. It's like training for a marathon on a treadmill - sure, you're moving, but the real test is outside.

So, the golden rule? Don't mock what you don't own. Focus on testing your code's core logic and behavior, not the internal workings of its external pals. This means mocking the interfaces and abstractions you define, not the ones borrowed from libraries or frameworks. This way, your tests are self-sufficient, future-proof, and ultimately, more meaningful.

Mocking Across Architectural Boundaries

Instead of meticulously replicating every external dependency, a more robust approach exists: mocking across architectural boundaries. Imagine your application as a castle with fortified walls. Your code reigns supreme within, while external dependencies like databases and time systems lurk outside. The "gates" where these entities interact mark the boundaries.

Instead of getting entangled in external intricacies, define your own interfaces or abstractions at these boundaries. These interfaces act as controlled gateways, governing what enters and leaves your code.

During testing, you can mock these interfaces, creating predictable environments within your castle walls. By relying on these well-defined "gates," you isolate external complexity.

Here's a breakdown of this approach:

Step 1: Define Your Own Gates (Interfaces)

The end goal is to mock across architectural boundaries, not replicate every detail of external dependencies. That's why we'll define our own interfaces that act as abstractions, decoupling our code from concrete implementations.

Here's an example of two interfaces we might define:

interface EntityGateway {
  public function save(Entity $entity): void;
}

interface Timekeeper {
  public function now(): DateTimeImmutable;
}
Enter fullscreen mode Exit fullscreen mode

Think of these interfaces as the "gates" to your code's castle walls. They define the essential interactions with external dependencies, without implementation details. This allows you to seamlessly substitute actual dependencies with mocks during testing, focusing on the core logic.

Step 2: Implement Your Gates (But Keep It Real)

While Step 1 focused on defining abstract interfaces, Step 2 brings us back to the concrete world. Here, we'll create actual implementations of those interfaces.

For example, our EntityGateway and Timekeeper interfaces might be implemented as follows:

class DatabaseGateway implements EntityGateway {
  public function __construct(
    EntityManager $entityManager,
  ) {
  }

  public function save(Entity $entity): void {
    $this->entityManager->persist($entity);
    $this->entityManager->flush();
  }
}

class SystemTimekeeper implements Timekeeper {
  public function now(): DateTimeImmutable {
    return new DateTimeImmutable();
  }
}
Enter fullscreen mode Exit fullscreen mode

These implementations provide real-world functionality but remain decoupled from your code thanks to the interface abstraction.

Step 3: Embrace Your Gates (Use Interfaces in Your Code)

Now that we have defined our interfaces and implemented them, it's time to integrate them into your application's code. Remember, these are the "gates" controlling external interactions.

Embrace the Shift: Instead of directly relying on concrete implementations provided by libraries or frameworks, inject the defined interfaces into your classes. This means your code interacts with the external world through these abstractions, not the specific implementations themselves.

Refactoring Considerations: While this approach offers long-term benefits in terms of testability, flexibility, and maintainability, it might involve some adjustments to your existing codebase depending on its reliance on concrete implementations. You might need to refactor classes to inject the interfaces instead. However, the long-term benefits often outweigh any initial effort.

Here's an example of how our MyService might look:

class MyService {
  public function __construct(
    EntityGateway $gateway,
    Timekeeper $timekeeper,
  ) {
  }

  public function doSomething(): void {
    $entity = new Entity();
    $entity->setData('Secret Data');
    $entity->setCreatedAt($this->timekeeper->now());

    $this->gateway->save($entity);
  }
}
Enter fullscreen mode Exit fullscreen mode

By injecting interfaces, your code becomes agnostic to concrete implementations. This promotes:

  • Testability: Easily swap interfaces with mocks for isolated testing.

  • Flexibility: Update implementations without impacting core functionality.

  • Loose Coupling: Cleaner and more maintainable code.

Step 4: Mock the Gates in Tests

Now, let's leverage the true power of mocking across architectural boundaries! This is where your tests truly shine.

class MyServiceTest extends TestCase {
  public function testSomethingHappens() {
    // Mock the interface, not the concrete implementation
    $mockGateway = $this->createMock(EntityGateway::class);
    $mockGateway->expects($this->once())
      ->method('save')
      ->with($this->isInstanceOf(Entity::class));

    // Mock our interface for SystemTimeKeeper
    $mockTimekeeper = $this->createMock(Timekeeper::class);
    $mockTimekeeper->method('now')
      ->willReturn(new DateTimeImmutable('2024-02-23 15:03:00'));

    $service = new MyService($mockGateway, $mockTimekeeper);

    $service->doSomething();

    $this->addToAssertionCount(1);
  }
}
Enter fullscreen mode Exit fullscreen mode

By mocking the interfaces, you create controlled environments for testing. You can define mocked interface behavior to isolate scenarios and focus on your code's logic without relying on external dependencies.

Benefits:

  • Focus on code logic: Test how your code interacts with dependencies, not their intricacies.

  • Isolate complexity and simulate scenarios: Mock the "gates" to control what happens within your code.

  • Improved maintainability of tests: Reduced complexity leads to clearer, easier-to-understand, and easier-to-update tests.

By mocking interfaces instead of concrete implementations, you create predictable environments for testing. This allows you to focus on your code's core logic without getting bogged down in external complexities. Additionally, with simpler and more focused tests, you'll spend less time maintaining them and more time building great software.

Wrapping Up: Mocking Across Boundaries for Powerful Tests

There you have it! You've unlocked the secret weapon for taming external dependencies in your tests - mocking across architectural boundaries. Remember, resist the urge to mock everything and instead, focus on the critical "gates" where your code interacts with the outside world. Define your own interfaces, leverage them in tests, and let the real implementations handle the heavy lifting.

This approach gives you a test suite that's:

  • Laser-focused on your code's logic: No more getting bogged down in database details or time zones.

  • Immune to external chaos: Simulate different scenarios and isolate complexity with ease.

  • Ready for the real world: Confident that your code will work when it matters most, thanks to the real implementations behind the scenes.

Equipped with these techniques, you're well-positioned to tackle even the most challenging interactions with external dependencies in your tests. Start incorporating mocking across architectural boundaries today and watch your test suite become a powerful tool for building high-quality software. Remember, robust tests are an investment in the future of your codebase, ensuring its reliability and maintainability as it evolves.

If you're interested in further exploring the nuances of mocking and testing, here are some valuable resources to consider:

Top comments (0)