Hitting the target with TestHelpers

15 Jul 2025 6 min read

As a project grows, modularisation becomes a valuable tool for managing that growth. In the projects I've worked on, the benefits of modularisation are almost exclusively felt in the production target, with little benefit being felt in the test target. This disparity in benefits doesn't need to exist - modularisation should benefit both production and test targets. All we need to do is realise that there are more than just tests in a test target.

A photo of an arrow impaled in a target

This post will examine how to organise a project to ensure that modularisation reduces complexity in both production and test targets by introducing test-helper targets to each module.

Lining up the target 🏹

Once we start modularising a project, we quickly discover that not all modules are the same. Some modules are feature modules, designed to be consumed by the end user; others are utility modules, designed to be consumed by other modules. When a 3rd-party module uses a utility module, the developer will add that utility module as a dependency of the 3rd-party module. The functionality of the utility module is then consumed as-is. However, when that same developer adds tests to the 3rd-party module, they often have to write test doubles of the utility module's public functionality to test their module's functionality, as the utility module doesn't provide those test doubles. Suppose that the utility module is a dependency for multiple other modules - each of those other modules now has to either:

  1. Rewrite those same test doubles leading to duplicated work.
  2. Introduce a test double module for each utility module, leading to a lot more module management being required.
  3. Introduce a common shared test double module leading to different domains in the project artificially living together (i.e. no production reason) so unintentionally coupling those otherwise independent modules together.

So, we either waste development time reinventing something that already exists, or we increase project complexity with unnecessary modules or we couple modules together when there is no production value in it.

🤮

A better approach is to have each utility module produce a test-helper target containing the test doubles of that module's public interface. Any 3rd-party test target can then consume that utility module's public test double functionality in the same way as its production target can consume that utility module's public production functionality.

A dedicated test-helper target for each utility module has the following advantages:

  1. Eliminates duplication of test doubles - rather than each 3rd-party module having to implement its own test double, those test doubles are grouped and exposed via the test-helper target of the module.
  2. Improves discoverability by creating a source-of-truth - having a single source-of-truth for each module's test doubles means that everyone knows where to get those test doubles. If the required test double does not exist, then they can fill in the gap, helping future consumers of that utility module.
  3. Improves consistency of each test double type - grouping the test doubles makes it easier to spot inconsistencies.
  4. Improves portability - the module provides a public test interface, allowing any 3rd-party module to more quickly integrate the utility module into both production and test targets, with production and test double functionality being consumed as-is.

Let's add a test-helper target to an example app.

Adding a test-helper target

Checkout the example app to follow along.

The example app has a workspace containing two projects:

  1. TestHelpers-Example - the app project.
  2. Networking - a utility framework project.

TestHelpers-Example depends on Networking.

Currently, Networking lacks a test-helper target to make writing unit tests in TestHelpers-Example easier; let's change that.

  1. Open the Networking target list and add a new target by clicking on the + symbol:

Screenshot of adding a new target

  1. On the Choose a template for your new target window, scroll down and select Framework:

Screenshot of selecting framework template

  1. On the Choose options for your new target window:
    3.1. Give the new target a product-name of NetworkingTestHelpers.
    3.2. Select None in the Testing System dropdown list.

Screenshot of naming new target

I normally suffix TestHelpers to the project name to get the test-helper target name.

Following those steps should add a framework target to the project.

  1. All that is left to do now is to add the production target as a dependency on the new test-helpers target:

Screenshot of adding the production target as a dependency of the TestHelper target

Now that you have a test-helper target, let's populate it.

Adding test doubles

The test-helper target contains the public test doubles for that utility module. The public test doubles are only those types with access control levels of open or public.

Networking only has one protocol and one concrete type: NetworkingService and DefaultNetworkingService. DefaultNetworkingService is a concrete type and, as such, is the concern of the production code; therefore, we can ignore it. Instead, let's focus on the protocol - NetworkingService:

public protocol NetworkingService {
    func makeRequest(url: URL) async -> Data?
}

A stub test double of NetworkingService might look like:

import Networking // 1

public class StubNetworkingService: NetworkingService { // 2
    public enum Event: Equatable {
        case makeRequestCalled(URL)
    }

    private(set) public var events = [Event]()

    public var dataToBeReturned: Data?

    public init() { }

    public func makeRequest(url: URL) async -> Data? {
        events.append(.makeRequestCalled(url))

        return dataToBeReturned
    }
}

This isn't a post about how to write test doubles, so there is no need to get in touch about how you consider StubNetworkingService to be more of a Spy than a Stub.

Here's what we did:

  1. Imported the Networking production target using a standard import. When adding a test double to the test-helpers target, avoid using @testable import, as the test-helpers target should have the same constraints as any other module when dealing with the production target. We don't want to accidentally break the encapsulation of Networking via its test doubles.
  2. Created a public test double.

Now that we have a test-helper target and a test double, let's use them.

Using the test-helper target

In TestHelpers-Example, the type AwesomeFeature uses NetworkingService. To ensure that awesomeness continues, we need to write unit tests. Thankfully, TestHelpers-Example already has a unit test target. However, that target doesn't depend upon our new test-helpers target, so open TestHelpers-ExampleTests in the target list and add NetworkingTestHelpers as a dependency:

Screenshot of adding TestHelper target as dependency

Open AwesomeFeatureTests and add NetworkingTestHelpers as an import:

import NetworkingTestHelpers

Again, note that we don't use @testable import here.

We can now use StubNetworkingService to write any unit tests as we would with any other imported type:

struct AwesomeFeatureTests {

    // MARK: - Tests

    @Test("Given gainAwesomeness is called, then networking service is called with the passed in URL")
    func checkNetworkRequestIsMade() async {
        let networkingService = StubNetworkingService() // 1
        let url = URL(string: "https://example.com")!
        let sut = AwesomeFeature(networkingService: networkingService)

        await sut.gainAwesomeness(from: url)

        #expect(networkingService.events == [.makeRequestCalled(url)])
    }
}

Here's what we did:

  1. Used the StubNetworkingService declared in NetworkingTestHelpers to test whether the instance AwesomeFeature works as expected.

Nothing about the above test is unusual, except that we are using an imported test double.

If another module needs to verify that its networking calls are being made correctly. In that case, importing NetworkingTestHelpers will enable the other test module to access the functionality of StubNetworkingService in much the same way as its production target accesses NetworkingService.

Bullseye 🎯

This post provides a straightforward example of using a module's test target to share a test double. While the example might be simple, it illustrates how this approach can effectively scale to share any necessary test double while adhering to the principles of modularisation.

Writing effective unit tests is crucial for any app, but the process can be time-consuming. By utilising test-helper targets, we can maximise the efficiency of our unit testing efforts. This approach eliminates duplicated work, enhances discoverability, improves consistency, and increases the portability of each module.

To see the completed project with a test-helper target, checkout the NetworkingTestHelpers branch.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.