Otávio’s blog

Unit Testing With Doubles and Fixtures

TL;DR:

Tests act as a safety net, help design better APIs, and serve as documentation. Different testing methods, including unit tests, UI tests, snapshot tests, and integration tests, offer unique benefits and complement each other.

Test doubles like dummies, mocks, stubs, partials (spies), and fakes are crucial for controlling testing environments and ensuring deterministic outcomes. Fixtures simplify test setup by providing helper methods for creating and configuring objects, making tests easier to read, write, and maintain.

In his book Working Effectively with Legacy Code, Michael Feathers defines legacy code as:

Legacy code is code without tests

What I like about this sentence is that it illustrates the importance of tests in software development.

There are different ways to view tests. In most cases, tests act as a safety net, helping engineers to catch unwanted changes in the code. However, tests can be much more than this. They can be used as a framework for writing better software, helping engineers to design better APIs and even be used as documentation to help newcomers understand the expected behavior and design decisions.

When writing tests, engineers may use different strategies. Some prefer to write them after the implementation is complete, while others prefer to write them before, such as in test-driven development (TDD) or behavior-driven development (BDD). However, the truth is that these different approaches do not diminish the importance of having good test coverage and testable code.

The Different Types of Tests

In software development, various testing methods ensure the robustness and reliability of code. No single approach is universally right or wrong; each method has its own context and suitability based on factors like development and maintenance costs, execution time, and the specific nature of the application being tested. These different types of tests often complement each other, creating a comprehensive testing strategy.

Unit Tests

Unit tests focus on the smallest testable parts of the software, such as functions or methods, testing them in isolation from the rest of the application. This isolation ensures that unit tests are quick to execute and highly cost-effective. They help ensure that individual components behave as expected without relying on other parts of the system.

UI Tests

UI tests verify the user interface by simulating user interactions with the application. These tests are more resource-intensive as they require the complete environment to be configured and often involve testing end-to-end workflows, such as completing a purchase process. UI tests provide valuable feedback on the user experience but are more costly to maintain and execute due to their complexity.

Snapshot Tests

Snapshot tests are particularly useful for testing UI components where visual consistency is critical. They capture the rendered output of a component and compare it against a previously saved snapshot. This helps detect unintended changes in the UI, especially when the layout or content is dynamically generated. Snapshot tests are a practical solution for ensuring the visual integrity of an application.

Integration Tests

Integration tests examine the interactions between multiple components or systems, ensuring that they work together as expected. These tests go beyond the scope of unit tests by verifying the behavior of integrated units, such as checking the flow of data between modules or the interaction with external services. Integration tests can include UI tests but can also be used to validate APIs, databases, and other interconnected systems.

Test Doubles

No matter the testing approach used, there are tools that allow us to have better control of the environment we are testing.

A Test Double is one of the most important tools in our toolbox when writing tests, especially unit tests. Using a test double, we can have full control of external entities and the testing environment in general. This is because test doubles replace important pieces of code with predetermined behavior. This is essential for maintaining determinism in tests, which means that given the same input, tests should always produce the same expected output.

Dummies

Dummies are the most basic type of Test Doubles. They do not offer any functionality and cannot be used to verify expectations. Their main use case is to fulfill arguments when calling methods and functions.

The ShapeProtocol below defines the draw method, and several different shapes can conform to it.

protocol ShapeProtocol {
    func draw(at point: CGPoint, radius: Float)
}

One of the possible objects conforming to the ShapeProtocol is the dummy type below. It doesn't have any implementation and, when its method is called, it doesn't do anything.

final class ShapeDummy: ShapeProtocol {
    func draw(at point: CGPoint, radius: Float) {
        /* no implementation here */
    }
}

In the example below, no matter which shape is passed to shouldDraw(shape:), the output is always the same, false.

func shouldDraw(shape: ShapeProtocol) -> Bool {
    false
}

In this situation a dummy, ShapeDummy in this case, is the most basic type which can be used to fulfill the function signature.

let result = shouldDraw(shape: ShapeDummy())
XCTAssertFalse(result)

Dummies are so minimal and limited that most people simply ignore them and use a mock instead. The overhead of creating and maintaining dummies is not worth it.

Mocks

Mocks are Test Doubles that are used to verify expectations. Like dummies, they do not implement any functionality. Instead, they provide a bare-bones interface that allows us to capture the interaction between the mock and the system under test.

Using the same protocol from the Dummies example, ShapeProtocol,

protocol ShapeProtocol {
    func draw(at point: CGPoint, radius: Float)
}

a basic example of a mock would be:

final class ShapeMock: ShapeProtocol {
    private(set) var didCallDraw = false
    private(set) var lastPoint: CGPoint?
    private(set) var lastRadius: Float?

    func draw(at point: CGPoint, radius: Float) {
        didCallDraw = true
        lastPoint = point
        lastRadius = radius
    }
}

The method draw(at:radius:) captures all the parameters passed to it, and sets a boolean indicating if the method was called or not.

Suppose we want to test a type, ShapeDrawer, which has a property shape:

struct ShapeDrawer {
    let shape: ShapeProtocol

    func draw() {
        shape.draw(at: .zero, radius: 5.0)
    }
}

In order to test it, instead of passing a real object , we pass an instance of ShapeMock, a concrete implementation of ShapeProtocol. This allows us to focus on the system under test, ShapeDrawer, while controlling the external entity.

func testDraw() {
    // Given

    let shapeMock = ShapeMock()

    let drawer = ShapeDrawer(
        shape: shapeMock
    )

    // When

    drawer.draw()

    // Then

    XCTAssertTrue(
        shapeMock.didCallDraw,
        "It should call draw"
    )

    XCTAssertEqual(
        shapeMock.lastPoint, .zero,
        "It should pass the correct point"
    )

    XCTAssertEqual(
        shapeMock.lastRadius, 5.0,
        "It should pass the correct radius"
    )
}

There are tools that can automatically generate mocks from protocols, such as Sourcery. The idea is the same: to capture interaction and verify expectations. Sourcery is my favorite solution for mocks because it saves time by eliminating the boilerplate code and creates a consistent interface for all mocks.

Some engineers prefer to avoid protocols, working with simple types such as structs and enums. In this scenario, ShapeProtocol would be replaced by a real type :

struct Shape {
    var draw: (CGPoint, Float) -> Void
}

while ShapeDrawer gets a real object injected:

struct ShapeDrawer {
    let shape: Shape

    func draw() {
        shape.draw(.zero, 5.0)
    }
}

The mock, in this scenario is a simple instance of Shape, using its closure to capture parameters:

func testDraw() {
    var passedPoint: CGPoint?
    var passedRadius: Float?

    // Given

    let drawer = ShapeDrawer(
        shape: .init(
            draw: { point, radius in
                passedPoint = point
                passedRadius = radius
            }
        )
    )

    // When

    drawer.draw()

    // Then

    XCTAssertEqual(
        passedPoint, .zero,
        "It should pass the correct point"
    )

    XCTAssertEqual(
        passedRadius, 5.0,
        "It should pas the correct radius"
    )
}

Stubs

Stubs are Mocks with injected behavior. They can be used to capture interaction and verify expectations, but they can also simulate different behaviors of the external entity.

A good example is the interaction with a network client when testing a view model or a repository. The subject under test is the view model or repository, and the network client is an external entity. When testing the view model, we do not want to make any network calls. We want the tests to be deterministic, meaning that the same input should always produce the same expected result.

In addition, we want to cover different situations that the network client might return. For example, we might want to test the view model for success and failure cases, and depending on the result, the system under test should behave differently.

To achieve this, we can inject behavior into the network client mock. This means that we are stubbing the mock, which is why it is called a stub.

The ArchiverProtocol below defines the contract of an archiver which has two methods: one to archive an object and another one to retrieve it.

protocol ArchiverProtocol {
    typealias Content = Encodable
    func archive(_ object: Content)
    func unarchive() -> Content?
}

The most basic mock implementation for the ArchiverProtocol can be seen below. The method unarchive() returns nil just because the interface requires something to return.

final class ArchiverMock: ArchiverProtocol {
    private(set) var lastArchivedObject: Content?

    func archive(_ object: Content) {
        lastArchivedObject = object
    }

    func unarchive() -> Content? {
        nil
    }
}

The stub, on the other hand, introduces behavior to a mock. Below it's possible to control the return value of the unarchive() method, which will allow us to control the interaction of the archiver with the code which consumes it.

final class ArchiverMock: ArchiverProtocol {
    private(set) var lastArchivedObject: Content?
    private(set) var stubbedUnarchiveReturn: Content?

    func archive(_ object: Content) {
        lastArchivedObject = object
    }

    func unarchive() -> Content? {
        stubbedUnarchiveReturn
    }
}

extension ArchiverMock {
    func stubUnarchive(toReturn value: Content) {
        stubbedUnarchiveReturn = value
    }
}

Taking as an example a view model, ViewModel, which depends on the archiver to display data and respond to the user interaction:

struct ViewModel {
    let archiver: ArchiverProtocol

    var title: String? {
        archiver.unarchive() as? String
    }

    func didSelect(_ value: String) {
        archiver.archive(value)
    }
}

In the code above, there is no reference to the archiver's real implementation. We do not know whether it is reading and writing data to a file on disk, using Core Data, or some other way to persist information. This is the main reason to mock and stub the archiver, since the view model is the system under test.

The test below reflects the case where the archiver does not have any stored data. In this case, the view title is nil.

func testTitleWhenArchiveIsEmpty() {
    let archiverMock = ArchiverMock()
    let viewModel = ViewModel(archiver: archiverMock)

    XCTAssertNil(
        viewModel.title,
        "It shouldn't have a title when there's nothing archived"
    )
}

On the other hand, when the mock is stubbed to return a value, the value from the title property should match the stubbed value from the archiver.

func testTitleWhenArchiveHasValue() {
    // Given

    let archiverMock = ArchiverMock()
    archiverMock.stubUnarchive(toReturn: "Some Title")

    // When

    let viewModel = ViewModel(archiver: archiverMock)

    // Then
    
    XCTAssertEqual(
        viewModel.title,
        "Some Title",
        "It should return the archived title"
    )
}

In other words, stubs allow us to inject behavior into a mock so that we have full control over the interactions between the system under test and external dependencies. This means that we can simulate different behaviors of the external dependency, regardless of how it actually behaves in production.

Partials

Partials, sometimes called Spies, are Test Doubles that can be used to verify expectations and behave exactly like the external entity they replace. In most cases, a partial is simply a subclass of the type it replaces. The main difference is that it implements the same mechanism as mocks to capture arguments.

Partials are extremely useful when it might be difficult or impossible to use another type of testing double, or when it is important to maintain the behavior of the external entity.

Because they are subclasses, partials must override methods to capture arguments. When doing so, it is not necessary to override all the methods from the superclass. Only the methods where arguments need to be captured need to be overridden.

The overridden methods should then call the superclass once the arguments have been captured. This is important to ensure that the partial remains in a consistent internal state and does not behave strangely.

final class UINavigationControllerPartial: UINavigationController {
    private(set) var lastViewController: UIViewController?
    private(set) var lastAnimated = false

    override func pushViewController(
        _ viewController: UIViewController,
        animated: Bool
    ) {
        lastViewController = viewController
        lastAnimated = animated

        super.pushViewController(
            viewController,
            animated: animated
        )
    }
}

Although partials are easy to implement, they still behave like the original type. This can introduce side effects, such as leaving behind changes to the test environment that could affect subsequent tests.

Fakes

Fakes are Test Doubles that have real implementations. They are real objects, but they take shortcuts or alternative solutions to fulfill their purpose. For example, a complex database setup can be replaced with a lightweight in-memory solution, which is much simpler to initialize and control.

In iOS/macOS, UserDefaults stores and reads data from the file system. Using a real UserDefaults in tests can lead to side effects if there is no proper cleanup after each test. This is because UserDefaults can leave data behind in the file system, which can affect the results of subsequent tests.

Core Data is a more complex framework that requires a complex setup. Reading and writing entries in Core Data requires a special context with access having to run on a specific thread. This can make it difficult to test Core Data in isolation, and it can also lead to side effects if there is no proper cleanup after each test.

Implementation of alternative solutions:

Below, a possible implementation of a persistence service which uses core data underneath.

final class PersistenceService {
    init(inMemory: Boolean = false) {
        let container = NSPersistentContainer(...)

        if inMemory {
            let storeDescription = container.persistentStoreDescriptions...

             storeDescription.url = URL(
                fileURLWithPath: "/dev/null"
            )
        }

        container.loadPersistentStores { description, error in
            // ...
        }
    }
}

In this case, a view model could take the persistence service which holds the entities in memory allowing to test the view model when data is added to the persistence service (and container).

func testShowMenuIfNotEmpty() {
    // Given
    let service = PersistenceService(inMemory: true)
    let viewModel = MenuViewModel(service: service)

    // When
    service.add("Burrito")

    // Then
    XCTAssertTrue(
        viewModel.shouldDisplayMenu
    )
}

The main difference between the two fakes mentioned above is that the first one, UserDefaults, requires a new type to be created and used in the tests. The latter, Core Data, doesn't require a new type to be created, but instead requires a minor modification to the production code.

Both solutions achieve the same goal, which is to inject a type with a real implementation that mimics the real object they replace, making it easier to test the subject under test.

Fakes can improve test performance, reduce side effects in the testing environment, and allow determinism in test execution. They can also be interesting when working with SwiftUI previews.

Fixtures

Tests should be easy to read, write, and maintain. To achieve this, test methods should be as concise as possible. Fixtures can help with this by providing helper methods to hide the complexity of creating certain types used in tests.

A fixture is a type with helper methods to create and configure objects used in tests. This can make it much easier to write tests, as you don't have to worry about the details of creating and configuring objects.

Martin Fowler calls fixtures "object mothers" because they can be used to "give birth" to objects in tests:

An object mother is a kind of class used in testing to help create example objects that you use for testing. Object Mother is just a catchy name for such a factory. The name was coined on a Thoughtworks project at the turn of the century and it's catchy enough to have stuck.

In the example below, for instance, RouteFixture is a fixture factory that builds a Route object used in a test. The Route class has an initializer with multiple parameters, and repeating this initialization in every test method would just increase the complexity of the tests.

RouteFixture provides a helper method called makeRoute() that takes care of creating the Route object for us. This makes it much easier to write tests, as we don't have to worry about the details of creating the Route object.

enum RouteFixture {
    static func makeRoute() -> Route {
        Route(
            origin: .init(lat: 52.3667544, long: 13.4607836),
            destination: .init(lat: 52.516366, long: 13.377178),
            vehicle: .car,
            type: .shortestRoute,
            time: Date()
        )
    }

    static func makeComplexRoute() -> Route { /* ... */ }
}

When the fixture is used, the test function becomes more readable and descriptive. The complexity of creating and configuring the Route object is taken out of the equation, leaving only the code that is relevant to the test.

func testViewModelWithValidRoute() {
    let route = RouteFixture.makeRoute()
    let viewModel = RouteViewModel(route: route)

    XCTAssertEqual(
        viewModel.route,
        route,
        "It should have the correct route"
    )
}

In most cases, a fixture is used in several other test cases. Therefore, it is a good practice to have them in their own files. This makes it easier to find and maintain the fixtures, and it also helps to keep the test cases organized.

Creating dedicated factory types

This is the pattern used in the example above. It consists of factories for each type they construct.

enum FooFixture {
    static func makeFoo() -> Foo
    static func makeAnotherFoo() -> Foo
}

enum BarFixture {
    static func makeBar() -> Bar
    static func makeAnotherBar() -> Bar
}

Usage:

func testSomething() {
    let foo = FooFixture.makeFoo()
    let bar = BarFixture.makeAnotherBar()

    // ...
}

Creating namespaces

An elegant solution for building fixtures is to use nested types, which creates a namespace for the factories. This improves readability and discoverability. In the example below, all the methods that generate Foo objects are under the Fixture.Foo namespace.

enum Fixture {}

extension Fixture {
    enum Foo {
        static func makeFoo() -> Foo
    }
    enum Bar {
        static func makeBar() -> Bar
    }
}

Usage:

func testSomething() {
    let foo = Fixture.Foo.makeFoo()
    let bar = Fixture.Bar.makeBar()
    // ...
}

Extending types

Another approach is to simply extend types, adding new factory methods to create new instances. This solution is simple, but it mixes production and testing code, which can be problematic. It also makes discoverability a little harder, as the factory methods are not namespaced.

extension Foo {
    static func makeFixture() -> Foo
}

extension Bar {
    static func makeFixture() -> Bar
}

Usage:

func testSomething() {
    let foo = Foo.makeFixture()
    let bar = Bar.makeFixture()
    // ...
}

No matter the strategy used to create new instances, the idea behind fixtures is the same: to make tests easier to read and write by hiding the complexity of initializing complex types.

Fixtures can also be used as Fakes in SwiftUI previews. This can be useful for keeping the PreviewProvider implementation simple and easy to read and maintain.

References

#handpick #swift #testing