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.
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
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)])
}
...
}
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.
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.