Quality Coding
Shares

How to Mock Standalone Functions …Without Changing the Call Sites

Shares

We’ve looked at ways to mock methods in Swift. But what about standalone functions? Is there a way to mock them as well?

Yes! Not only can we mock Swift standalone functions, but we can do it without changing the call sites.

Methods vs. standalone functions

Methods are functions associated with a type. Mock the type, and you can intercept the method. In Swift, we prefer to do this with protocols. When we don’t control the type signature, we can fall back on partial mocking.

But a function lives on its own, without any associated data. In other words, it has no self.

Looking for “seams”

In other languages, standalone functions present a problem. We still want to intercept these calls, for two main reasons:

  • To spy on the arguments a function receives.
  • To stub any return values.

But a standalone function, living on its own, is a locked-down dependency. How can we intercept it?

Let’s see if we can identify a “seam”.

sewing stitch remover

Disclosure: The book links below are affiliate links. If you buy anything, I earn a commission, at no extra cost to you.

In Working Effectively with Legacy Code, here’s how Michael Feathers defines “seam”:

A seam is a place where you can alter behavior in your program without editing in that place.

In other words, we could always create some sort of wrapper with a different name, and call it instead. But that’s not ideal, because we’d have to change the call sites to use this other name. It would be better if we could leave the call sites alone.

Example: Let’s mock the precondition function

In the Marvel Browser TDD sample app, I used Domain Driven Design to figure out that I was missing an object. That object is a NetworkRequest, a wrapper for URLSessionTask. It has a start method that so far looks like this:

func start(_ task: URLSessionTaskProtocol) {
    currentTask = task
    task.resume()
}

I want to make this safer by forbidding start if currentTask is non-nil. At first I thought about using assert. But the problem with assert is that it’s only active for debug builds. To keep the assertion in place for release builds, we can call precondition instead:

func start(_ task: URLSessionTaskProtocol) {
    precondition(currentTask == nil)
    currentTask = task
    task.resume()
}

There are at least two seams we can explore to make this testable.

Object seams

Working Effectively with Legacy Code offers many techniques for uncovering seams. Remember, we want to change the behavior without altering the call site.

One way is to promote the function call to a method call. What happens if we add a precondition method to the NetworkRequest class? We can do this by copying its signature (except we have to use an explicit empty string for the default message).

class NetworkRequest {
    // ...

    func precondition(_ condition: @autoclosure () -> Bool,
                      _ message: @autoclosure () -> String = "",
                      file: StaticString = #file,
                      line: UInt = #line) {
        Swift.precondition(condition, message, file: file, line: line)
    }

    // ...
}

All this does is delegate to Swift’s built-in precondition function. The call site

precondition(currentTask == nil)

is now equivalent to

self.precondition(currentTask == nil)

but the self-dot is implied.

Now for testing purposes, we can override that method in a test-specific subclass:

class TestableNetworkRequest: NetworkRequest {
    var preconditionFailed = false

    override func precondition(_ condition: @autoclosure () -> Bool,
                               _ message: @autoclosure () -> String = "",
                               file: StaticString = #file,
                               line: UInt = #line) {
        if !condition() {
            preconditionFailed = true
        }
    }
}

The System Under Test (SUT) will be a TestableNetworkRequest instead of a NetworkRequest. This lets us write the unit test:

func testStart_WithExistingTask_ShouldFailPrecondition() {
    sut.start(fakeTask)
    
    sut.start(fakeTask)
    
    XCTAssertTrue(sut.preconditionFailed, "Expected precondition failure")
}

Namespace seams

Promoting precondition to a method works great when we call it from a class. But what if we want to call it from a struct or an enum? Then we can’t create a test-specific subclass.

A StackOverflow answer by Nikolaj Schumacher shows another way. The fully-qualified name of Swift’s built-in precondition function is Swift.precondition. How does the call site know what to call?

Apparently Swift uses some kind of namespace resolution. I don’t know the details of the resolution rules. (Perhaps someone can explain in the comments how imported frameworks work.) But at the very least, the compiler looks first within the namespace of your current target. Failing that, it will fall back to the Swift namespace.

So we can promote the function call from the Swift namespace to our own. We do this by defining our own precondition function, with some helper closures:

let defaultPrecondition = { Swift.precondition($0, $1, file: $2, line: $3) }
var evaluatePrecondition: (Bool, String, StaticString, UInt) -> Void = defaultPrecondition

func precondition(_ condition: @autoclosure () -> Bool,
                  _ message: @autoclosure () -> String = "",
                  file: StaticString = #file,
                  line: UInt = #line) {
    evaluatePrecondition(condition(), message(), file, line)
}

As you can see, precondition calls a global closure evaluatePrecondition. By default, it uses defaultPrecondition which calls Swift.precondition. So we preserve the original behavior.

With a global closure, tests need to be careful to set up and restore it. We can do this using XCTestCase’s setUp and tearDown:

var preconditionFailed = false

override func setUp() {
    super.setUp()
    sut = NetworkRequest()
    
    evaluatePrecondition = { condition, message, file, line in
        if !condition {
            self.preconditionFailed = true
        }
    }
}

override func tearDown() {
    sut = nil
    evaluatePrecondition = defaultPrecondition
    super.tearDown()
}

Now we can write our unit test:

func testStart_WithExistingTask_ShouldFailPrecondition() {
    sut.start(fakeTask)
    
    sut.start(fakeTask)
    
    XCTAssertTrue(preconditionFailed, "Expected precondition failure")
}

Update: Mach seam for assert/precondition

The object seam and namespace seam techniques work for any Swift standalone functions. But for Swift’s assert or precondition calls, there’s also a Mach seam. Matt Gallagher wrote a Mach exception handler that can be inserted when running on simulator.

Thank you to Jakub Turek for letting me know about this lower-level seam.

Techniques for mocking Swift standalone functions

We’ve looked at two techniques for controlling standalone functions in Swift:

  • If you call the function from a class, promote the function call to a method call. Override it in a test-specific subclass.
  • If you call the function from a type that prevents subclassing, create a function with the same signature in your code. Use closures to let you replace the behavior, with suitable defaults.

These techniques let us change the behavior without changing the call sites.

Of course, if we can’t find a suitable technique, the final fallback is to give up and change the call sites. We can then use any kind of wrapper we want.

But this can create resistance from other developers on the team. “Why should I change this calling code? It works fine!” If we can avoid changing call sites, we can reduce friction against unit testing. It also reduces the danger of not calling the wrapper.

Throw it away and TDD it!

Let’s get meta, and step back from Swift function mocking for a bit. How does all this fit with Test Driven Development?

Successful unit testing often requires us to find find seams. While I learned this approach from Working Effectively with Legacy Code, it applies to test-driven code as well. Often with TDD, I’ll say to myself, “The production code will look something like this. How do I make this testable?”

That question leads me on a hunt. I use spike solutions to check:

  • Does my idea of the production code even work?
  • What technique can I use to unit test this?

Once the spike has given me an answer, I throw it away and start afresh. Then I can do a proper test-driven approach.

Why throw away a “working solution” just to start over with TDD?

  • Strict TDD will lead to other tests. The simplest, dumbest code that passes our failing test is precondition(false). Clearly, we need a test that calling start once doesn’t trigger the precondition failure.
  • Refactoring can lead to different production code. Things may start the way I first imagined. But the 3-step “TDD waltz” includes refactoring. And continuous refactoring often leads to something different.

There’s a difference between code with unit tests added afterwards, and code made with TDD. So don’t stop at “I can write a unit test for this.” You solved the hardest part, so go all the way!

Have you read the Legacy Code book? What did you get out of it? Please share in the comments below.

About the Author Jon Reid

Jon is a coach and consultant on iOS Clean Code (Test Driven Development, unit testing, refactoring, design). He’s been practicing TDD since 2001. You can learn more about his background, or see what services he can bring to your organization.

follow me on:
Disclosure: The book links below are affiliate links. If you buy anything, I earn a commission, at no extra cost to you.

Leave a Comment:

2 comments
Pavel says a couple of weeks ago

Always Interesting to read you articles. Thanks.

Does this approach works with assert? Tried to test but no success, fake assert was never called and test always stops on the real assert in the code

Reply
    Jon Reid says a couple of weeks ago

    Pavel, I assume you’re trying the namespace approach. Make sure your assert code is included in your main target, not your test target. Otherwise it will already be compiled as Swift.assert and your test won’t be able to change it.

    Also, check out the update I added to the article, about a Mach exception handling seam. For assert/precondition specifically, it offers another way to trap the calls.

    Reply
Add Your Reply