Replacing More Reference Objects with Value Types

null

I was removing quite a few protocols and classes lately. Turns out, I like what's left.

I relied on classes for example because they can be subclassed as mocks in tests. Protocols provide a similar flexibility. But over the last 2 years, the behavior I was testing shrunk to simple value transformations.

Take a ChangeFileName service, for example. It is used by another service during mass renaming of files. It is also used when the user renames a single file. The updatedFilename(original:change:) method is the only interesting part of it. You call this method from other objects and verify this behavior in your tests. Then, one day, you look at the method body of updatedFilename again and find it can be shortened to:

func updatedFilename(original: Filename, change: FilenameChange) -> Filename {
    return original.changed(change)
}

That's similar to what I was looking at. Filename and FilenameChange are value types (structs) and their transformations are well-tested. The updatedFilename method only makes this call mock-able. But what for? You can change the tests just as well!

This is a typical object-based test with mocks and assertions on transformations:

  1. Is the other object called? (didUpdateFilename)
  2. Is the other object's result used? (testUpdatedFilename)
/// The test double/mock object
class ChangeFileNameDouble: ChangeFileName {
    var testUpdatedFilename: Filename = Filename("irrelevant")
    var didUpdateFilename: (original: Filename, change: FilenameChange)?
    override func updatedFilename(original: Filename, change: FilenameChange) -> Filename {
        didUpdateFilename = (original, change)
        return testUpdatedFilename
    }
}

func testMassRenaming_UpdatesFilename() {
    // Given
    let changeFileNameDouble = ChangeFileNameDouble()
    let service = MassFileRenaming(changeFileName: changeFileNameDouble)
    /// The expected result
    let newFilename = Filename("after the rename")
    changeFileNameDouble.testUpdatedFilename = newFilename
    /// Input parameters
    let filename = Filename("a filename")
    let change = Change.fileExtension(to: "txt")

    // When
    let result = service.massRename(filenames: [filename], change: change)

    // Then
    XCTAssertNotNil(changeFileNameDouble.didUpdateFilename)
    if let values = changeFileNameDouble.didUpdateFilename {
        XCTAssertEqual(values.original, filename)
       XCTAssertEqual(values.change, change)
    }
    XCTAssertEqual(result, [newFilename])
}

It's a bit harder to test for an array with 2+ elements, because then you have to collect an array of didUpdateFilename and prepare an array for testUpdatedFilename that's to be used by the MassFileRenaming service.

All you test is the contract between MassFileRenaming and ChangeFileName. Which is a lot, don't take me wrong! But then you also have to test ChangeFileName itself to make sure it does the right thing and doesn't just return some bogus values once you stop using a mock.

This test case can be changed to the mere value transformation itself without the "class behavior wrapper". And since you don't rely on a mock, it's easier to test collections of values:

func testMassRenaming_UpdatesFilename() {
    // Given
    let service = MassFileRenaming()
    /// Input parameters
    let filenames = [
        Filename("first filename"),
        Filename("another filename"),
        Filename("third filename")
    ]
    let change = Change.fileExtension(to: "txt")

    // When
    let result = service.massRename(filenames: , change: change)

    // Then
    let expectedFilenames = filenames.map { $0.changed(change) }
    XCTAssertEqual(result, expectedFilenames)
}

"But isn't this an integration test now?" Well, as always, it depends on your definition of a "unit"! You don't write adapters and wrappers for all UIKit/AppKit method calls, either, and treat the Foundation.URL, Int, or String as if there's nothing to worry about.

The Filename type, in this example, is reliable, too. If it's in another module, like MyAppCoreObjects, and thus even more isolated from your other app code, wouldn't you treat it the same as URL?

I still have to rethink some things with regard to value types. But one nice thing is that you can use self-created value types like any built-in types. No need to wrap in a class/reference object.

Maybe I need more time to adjust to this kind of thinking. I do know that the talk by Ken Scambler I mention time and again was very helpful to make the transition with confidence, like when I began changing RxSwift view models to free functions.

Browse the blog archive