Reputation: 9
I tried writing unit test for requestAuthorization by generating a mock for HKHealthStore. But I got an error. Asynchronous wait failed: Exceeded timeout of 2 seconds, with unfulfilled expectations: "Successfully tested requestAuthorization by returning true.".
func requestAuthorization(completion: @escaping(Bool?, HealthError?) -> Void) {
self.healthStore?.requestAuthorization(toShare: self.allTypes as? Set<HKSampleType>, read: self.allTypes, completion: { authorized, error in
if error != nil {
print("ERROR: \(error)")
completion(nil, .unableToAuthorizeAccess)
}
completion(authorized, nil)
})
}
func testRequestAuthorization_CanReturnTrue() {
let expectation = expectation(description: "Successfully tested requestAuthorization by returning true.")
sut?.requestAuthorization { authorized, error in
if error != nil {
print(error!)
}
guard let authorized = authorized else { return }
XCTAssertTrue(authorized)
expectation.fulfill()
}
wait(for: [expectation], timeout: 2)
}
override func requestAuthorization(toShare typesToShare: Set<HKSampleType>?, read typesToRead: Set<HKObjectType>?, completion: @escaping (Bool, Error?) -> Void) {
invokedRequestAuthorization = true
invokedRequestAuthorizationCount += 1
invokedRequestAuthorizationParameters = (typesToShare, typesToRead)
invokedRequestAuthorizationParametersList.append((typesToShare, typesToRead))
if let result = stubbedRequestAuthorizationCompletionResult {
print("RESULT: \(result)")
completion(result.0, result.1)
}
}
Upvotes: 0
Views: 545
Reputation: 20980
Here is my recreation of the code excerpt you want to test (with some automated refactoring by AppCode):
import HealthKit
enum HealthError {
case unableToAuthorizeAccess
}
class MyClass {
var healthStore: HKHealthStore? = HKHealthStore()
var allTypes: Set<HKObjectType> = Set()
func requestAuthorization(completion: @escaping (Bool?, HealthError?) -> Void) {
self.healthStore?.requestAuthorization(toShare: self.allTypes as? Set<HKSampleType>, read: self.allTypes) { authorized, error in
if error != nil {
print("ERROR: \(String(describing: error))")
completion(nil, .unableToAuthorizeAccess)
}
completion(authorized, nil)
}
}
}
The challenge is that almost all of this method is a closure, called asynchronously. Writing tests for this is much like microtesting network communication:
The trick to testing a closure is to capture the closure. Then your test can call the closure with different inputs. To capture this closure, we don't want this to call the real HKHealthStore
. Instead, we want a Test Double that replaces the method. Let's continue your approach of using Subclass and Override. So in my test code, I write this spy. Its only job is to capture arguments. Using a tuple is a good idea — I add names to the tuple elements.
class HKHealthStoreSpy: HKHealthStore {
var requestAuthorizationArgs: [(typesToShare: Set<HKSampleType>?, typesToRead: Set<HKObjectType>?, completion: (Bool, Error?) -> Void)] = []
override func requestAuthorization(toShare typesToShare: Set<HKSampleType>?, read typesToRead: Set<HKObjectType>?, completion: @escaping (Bool, Error?) -> Void) {
requestAuthorizationArgs.append((typesToShare, typesToRead, completion))
}
}
To pretend that HealthKit got some kind of error, my tests define this type:
enum SomeError: Error {
case problem
}
Now we can begin defining our test suite.
final class MyClassTests: XCTestCase {
private var healthStoreSpy: HKHealthStoreSpy!
private var sut: MyClass!
private var authorizationCompletionArgs: [(requestShown: Bool?, error: HealthError?)] = []
override func setUpWithError() throws {
try super.setUpWithError()
healthStoreSpy = HKHealthStoreSpy()
sut = MyClass()
sut.healthStore = healthStoreSpy
}
override func tearDownWithError() throws {
authorizationCompletionArgs = []
healthStoreSpy = nil
sut = nil
try super.tearDownWithError()
}
Note: Your code assumes that the Bool argument to the completion handler means "authorized". But the Apple documentation says otherwise. So for authorizationCompletionArgs
tuple array, I named the first argument requestShown
instead of authorized
.
The first test is whether our method calls the HKHealthStore
method. No need for anything in the test closure. Just, "Are we calling it once? Are we passing the health categories to both toShare
and toRead
?"
func test_requestAuthorization_requestsToShareAndReadAllTypes() throws {
let healthCategories = Set([HKObjectType.workoutType()])
sut.allTypes = healthCategories
sut.requestAuthorization { _, _ in }
XCTAssertEqual(healthStoreSpy.requestAuthorizationArgs.count, 1, "count")
XCTAssertEqual(healthStoreSpy.requestAuthorizationArgs.first?.typesToRead, healthCategories, "typesToRead")
XCTAssertEqual(healthStoreSpy.requestAuthorizationArgs.first?.typesToShare, healthCategories, "typesToShare")
}
(Since we have a single test with multiple assertions, I add a description to each assertion. That way if there is a failure, the failure message will tell me which assertion it was.)
Now we want to test the closure. To do that, we first call the method under test. The spy captures the closure. This is still the Arrange part of the test. Then comes the Act part: call the captured closure with whatever arguments we want.
First, let's do the non-error case. It has two possibilities: success, or failure. Since this is represented by a Bool
, let's use two tests.
func test_requestAuthorization_successfullyShowedRequest() throws {
sut.requestAuthorization { [self] authorization, error in
authorizationCompletionArgs.append((authorization, error))
}
healthStoreSpy.requestAuthorizationArgs.first?.completion(true, nil)
XCTAssertEqual(authorizationCompletionArgs.count, 1, "count")
XCTAssertEqual(authorizationCompletionArgs.first?.requestShown, true, "requestShown")
XCTAssertNil(authorizationCompletionArgs.first?.error, "error")
}
func test_requestAuthorization_requestNotShownButMissingError() throws {
sut.requestAuthorization { [self] authorization, error in
authorizationCompletionArgs.append((authorization, error))
}
healthStoreSpy.requestAuthorizationArgs.first?.completion(false, nil)
XCTAssertEqual(authorizationCompletionArgs.count, 1, "count")
XCTAssertEqual(authorizationCompletionArgs.first?.requestShown, false, "requestShown")
XCTAssertNil(authorizationCompletionArgs.first?.error, "error")
}
(We are comparing optional Bool
values, so we can't use XCTAssertTrue
or XCTAssertFalse
. Instead, we can use XCTAssertEqual
comparing against true
or false
.)
These pass. It's still important to see them fail, so I temporarily comment out the production code call to completion(authorized, nil)
.
Now we can write the last test, for the error case.
func test_requestAuthorization_failedToShowRequest() throws {
sut.requestAuthorization { [self] authorization, error in
authorizationCompletionArgs.append((authorization, error))
}
healthStoreSpy.requestAuthorizationArgs.first?.completion(false, SomeError.problem)
XCTAssertEqual(authorizationCompletionArgs.count, 1, "count")
XCTAssertNil(authorizationCompletionArgs.first?.requestShown, "requestShown")
XCTAssertEqual(authorizationCompletionArgs.first?.error, .unableToAuthorizeAccess, "error")
}
This fails, revealing a bug in your code:
XCTAssertEqual failed: ("2") is not equal to ("1") - count
Oops, the completion handler is being called twice! (There is no return
, so it falls through.) This is why it's important for tests not to mimic production code.
So there you have it. We need 4 test cases to express all the details:
This is async code being tested synchronously by having the tests call the closure. It's super-fast, with no need for XCTest expectations.
Upvotes: 0
Reputation: 33967
The simple solution is that you need to assign something to stubbedRequestAuthorizationCompletionResult
before invoking the sut
's method.
But really, what are you testing here? It looks like the only thing that this tests is whether the SUT is properly connected to the Mock, which only happens in the test, not in production code. In other words, the test only tests itself.
This is a pointless test.
Upvotes: -1