简体   繁体   中英

Unit Test requestAuthorization for HealthKit

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

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:

  • Are we sending the right request?
  • What happens different responses come?

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:

  1. Does it request HealthKit authorization with the arguments we want?
  2. What happens if the request was successfully shown to the user?
  3. What happens if the request was not shown, but there is no error? From Apple's documentation , it's not clear that this will ever happen — it's a strange API.
  4. What happens if we get an error?

This is async code being tested synchronously by having the tests call the closure. It's super-fast, with no need for XCTest expectations.

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM