Best Practice for Handling AnyCancellable in Swift Testing Suites

Hi all,

I'm migrating an XCTestCase class that has a single stored property:
var cancellable: AnyCancellable? that it sets to nil in its deinit.

When migrating this to Swift Testing, I can use a class as the suite and not change much.

But since structs are preferred as Swift Testing suites, I can use a struct (since each test method gets its own separate SomeSuite instance), like this:

@MainActor
struct SomeSuite {
  typealias SUT = SomeViewModelConformingToObservedObject
  var cancellable: AnyCancellable?
    
  @Test("Verify Some Behavior")
  mutating func verifySomeBehavior() async throws {
    // Given
    let sut = SUT(...)
    var observedStates = [SUT.State]()
    cancellable = sut.$state.sink { observedStates.append($0) }
    // When
    await sut.handle(event: .viewWillAppear)
    // Then
    #expect(observedStates == [.notStarted, .loading, .displaying(someExpectedContent)])
  }
  
  ...
}

However, using mutating methods for every test that need to observe state transitions feels a bit off.

So a third option could be to box the AnyCancellable in a reference type box.cancellable = sut.$state.sink…, which isn't very elegant either.


Is there a nicer way to do this?

I also believe actor types are fully supported if you prefer reference semantics. Have you tried a @Suite actor?

Yeah, an actor would work as well as class I assume. But both options feel a bit overkill since I don't really need an actor or class, no actual need for a deinit (tearDown) in these tests.

A simple struct would be fine if there is a way to avoid having to use mutating test methods or boxing the cancellable

Would keeping the cancellable local to the test and a simple defer { _ = cancellable } work?

2 Likes

Perhaps withExtendedLifetime might be worth considering as well? https://developer.apple.com/documentation/swift/withextendedlifetime(_:_:)-31nv4

Yes, local with defer is an option. It adds two lines of boilerplate to each test method though.

Using withExtendedLifetime could probably work but it would also create a lot of noise.

Perhaps using a class instead of a struct is the simplest solution after all, even though I don't need its deinit (since each test is executed within its own separate instance).

@MainActor
final class SomeSuite {
  typealias SUT = SomeViewModelConformingToObservedObject
  var cancellable: AnyCancellable?
    
  @Test("Verify Some Behavior")
  func verifySomeBehavior() async throws {
    // Given
    let sut = SUT(...)
    var observedStates = [SUT.State]()
    cancellable = sut.$state.sink { observedStates.append($0) }
    // When
    await sut.handle(event: .viewWillAppear)
    // Then
    #expect(observedStates == [.notStarted, .loading, .displaying(someExpectedContent)])
  }
  
  ...
}

it also avoids global variables and makes tests constrained to the body of the test. I would take that advantage over 2 lines.

1 Like

There are no global variables in any of the approaches discussed so far.

If you mean instance variables, then this shouldn't be as concerning when using Swift Testing, since each test method is run on its own instance of the test suite, so there's no risk of shared mutable state between tests.

I like the idea of keeping everything related to a test local to its method, but it needs to be balanced with a good signal to noise ratio. If many tests use the same scaffolding, I think it’s reasonable to move it out of test method, especially given that Swift Testing ensures isolated state between tests.

1 Like

I agree that a class is probably simplest, but it's just a single line of boilerplate, no?

Adding a “TestSubscriber” helps with the readability and usage of testing with Combine. A simple implementation is just a class holding the array of tracked values and the AnyCancellable.

I figured each test method would have these 2 "extra" lines:

  var cancellable: AnyCancellable?
  defer { _ = cancellable }

What I ended up doing in my particular case is in line with @swhitty's suggestion.

I already had a Log type, and I added cancellables to it, ending up with something like this:

@MainActor
struct SomeSuite {
  typealias SUT = SomeViewModelConformingToObservedObject
  // In Swift Testing, each test method runs in parallel with its own separate instance,
  // so each test has a newly created `log` instance.
  let log = Log()
    
  @Test("Verify Some Behavior")
  func verifySomeBehavior() async throws {
    // Given
    let sut = SUT(
      contentProvider: { ... },
      navigationHandler: { log.navigationActions.append($0) },
      analyticsHandler: { log.analyticsEvents.append($0) }    
    )
    sut.$state.sink { log.states.append($0) }.store(in: &log.cancellables)
    // When
    await sut.handle(event: .viewWillAppear)
    // Then
    #expect(observedStates == [.notStarted, .loading, .displaying(someExpectedContent)])
    #expect ...
  }
  
  ...
}

Perhaps not super nice to have something like cancellables in a thing called Log, but this is what I ended up with.

Are you sure that the AnyCancellable instance needs to be a stored property of the suite? Can it just be a local in the test function?

Edit: Shame on me for not reading the whole thread first.

You're already assigning cancellable = in the original version, the only difference is now you would prefix it with let.

Ah, yes of course 🤦
    let cancellable = sut.$state.sink { observedStates.append($0) }
    defer { _ = cancellable }

Thanks for providing information in details.