- Goal
-
-
For testing a subscriber, or something that includes a subscriber, we can emulate the publishing source with PassthroughSubject to provide explicit control of what data gets sent and when.
-
- References
- See also
- Code and explanation
-
When you are testing a subscriber in isolation, you can get more fine-grained control of your tests by emulating the publisher with a passthroughSubject and using the associated
.send()
method to trigger updates.
This pattern relies on the subscriber setting up the initial part of the publisher-subscriber lifecycle upon construction, and leaving the code to stand waiting until data is provided.
With a PassthroughSubject
, sending the data to trigger the pipeline and subscriber closures, or following state changes that can be verified, is at the control of the test code itself.
This kind of testing pattern also works well when you are testing the response of the subscriber to a failure, which might otherwise terminate a subscription.
A general pattern for using this kind of test construct is:
-
Set up your subscriber and any pipeline leading to it that you want to include within the test.
-
Create a
PassthroughSubject
in the test that produces an output type and failure type to match with your subscriber. -
Assert any initial values or preconditions.
-
Send the data through the subject.
-
Test the results of having sent the data - either directly or asserting on state changes that were expected.
-
Send additional data if desired.
-
Test further evolution of state or other changes.
An example of this pattern follows:
func testSinkReceiveDataThenError() {
// setup - preconditions (1)
let expectedValues = ["firstStringValue", "secondStringValue"]
enum TestFailureCondition: Error {
case anErrorExample
}
var countValuesReceived = 0
var countCompletionsReceived = 0
// setup
let simplePublisher = PassthroughSubject<String, Error>() (2)
let cancellable = simplePublisher (3)
.sink(receiveCompletion: { completion in
countCompletionsReceived += 1
switch completion { (4)
case .finished:
print(".sink() received the completion:", String(describing: completion))
// no associated data, but you can react to knowing the
// request has been completed
XCTFail("We should never receive the completion, the error should happen first")
break
case .failure(let anError):
// do what you want with the error details, presenting,
// logging, or hiding as appropriate
print("received the error: ", anError)
XCTAssertEqual(anError.localizedDescription,
TestFailureCondition.anErrorExample.localizedDescription) (5)
break
}
}, receiveValue: { someValue in (6)
// do what you want with the resulting value passed down
// be aware that depending on the data type being returned,
// you may get this closure invoked multiple times.
XCTAssertNotNil(someValue)
XCTAssertTrue(expectedValues.contains(someValue))
countValuesReceived += 1
print(".sink() received \(someValue)")
})
// validate
XCTAssertEqual(countValuesReceived, 0) (7)
XCTAssertEqual(countCompletionsReceived, 0)
simplePublisher.send("firstStringValue") (8)
XCTAssertEqual(countValuesReceived, 1)
XCTAssertEqual(countCompletionsReceived, 0)
simplePublisher.send("secondStringValue")
XCTAssertEqual(countValuesReceived, 2)
XCTAssertEqual(countCompletionsReceived, 0)
simplePublisher.send(completion: Subscribers.Completion.failure(TestFailureCondition.anErrorExample)) (9)
XCTAssertEqual(countValuesReceived, 2)
XCTAssertEqual(countCompletionsReceived, 1)
// this data will never be seen by anything in the pipeline above because
// we have already sent a completion
simplePublisher.send(completion: Subscribers.Completion.finished) (10)
XCTAssertEqual(countValuesReceived, 2)
XCTAssertEqual(countCompletionsReceived, 1)
}
-
This test sets up some variables to capture and modify during test execution that we use to validate when and how the sink code operates. Additionally, we have an error defined here because it’s not coming from other code elsewhere.
-
The setup for this code uses the passthroughSubject to drive the test, but the code we are interested in testing is the subscriber.
-
The subscriber setup under test (in this case, a standard sink). We have code paths that trigger on receiving data and completions.
-
Within the completion path, we switch on the type of completion, adding an assertion that will fail the test if a finish is called, as we expect to only generate a
.failure
completion. -
Testing error equality in Swift can be awkward, but if the error is code you are controlling, you can sometimes use the
localizedDescription
as a convenient way to test the type of error received. -
The
receiveValue
closure is more complex in how it asserts against received values. Since we are receiving multiple values in the process of this test, we have some additional logic to check that the values are within the set that we send. Like the completion handler, We also increment test specific variables that we will assert on later to validate state and order of operation. -
The count variables are validated as preconditions before we send any data to double check our assumptions.
-
In the test, the
send()
triggers the actions, and immediately after we can test the side effects through the test variables we are updating. In your own code, you may not be able to (or want to) modify your subscriber, but you may be able to provide private/testable properties or windows into the objects to validate them in a similar fashion. -
We also use
send()
to trigger a completion, in this case a failure completion. -
And the final
send()
is validating the operation of the failure that just happened - that it was not processed, and no further state updates happened.