How does Swift's in-depth parsing avoid forced parsing in unit testing

1, Foreword

  • Forced parsing (using!) is an indispensable feature of Swift language (especially when mixed with Objective-C interface). It avoids some other problems and makes Swift language better.
  • Like on my blog Swift's in-depth parsing how to handle non Optional Optional types In, using forced parsing to handle optional types when required by project logic will lead to some strange situations and crashes.
  • Therefore, avoiding forced parsing as much as possible will help build more stable applications and provide better error reporting information in case of errors. So what happens when writing tests? Dealing with optional and unknown types safely requires a lot of code. The question is whether we are willing to do all the extra work for writing tests, which is what we need to explore.

2, Test code vs product code

  • When writing test code, we often clearly distinguish between test code and product code. Although it is very important to keep the separation of these two parts of code (we don't want to accidentally make the simulated test object part of the App Store), there is no need to make a clear distinction in terms of code quality.
  • If you think about it, why do you want to make high standards for the code handed over to users?
    • Want App to run stably and smoothly for users;
    • Want App to be easy to maintain and modify in the future;
    • Want to make it easier for newcomers to integrate into our team.
  • Now, if we think about our test in turn, what do you want to avoid?
    • The test is unstable, fragile and difficult to debug;
    • When new functions are added to our App, it takes a lot of time to maintain and upgrade the test code;
    • The test code is difficult for newcomers to the team to understand.
  • For a long time, I thought that test code was just some code I piled up quickly because someone told me I had to write tests. I don't care so much about their quality because I regard it as a trivial matter and don't put it in the first place. However, once I found out how fast to verify my code and how confident I was in myself because of writing tests, my attitude towards testing began to change.
  • Now I believe that it is very important to have the same high standard requirements for the test code and the product code to be handed over, because our supporting tests need to be used, expanded and mastered for a long time, which should be easier to complete.

3, Forced resolution problem

  • So what does all this have to do with forced parsing in Swift? Sometimes forced parsing is necessary. It is easy to write a "go to solution" test. Take an example to test whether the login mechanism implemented by UserService works normally:
class UserServiceTests: XCTestCase {
    func testLoggingIn() {
        // To log in to the terminal
        // Build a mock object that always returns success
        let networkManager = NetworkManagerMock()
        networkManager.mockResponse(forEndpoint: .login, with: [
            "name": "John",
            "age": 30
        ])

        // Build service object and login
        let service = UserService(networkManager: networkManager)
        service.login(withUsername: "john", password: "password")

        // Now we want to assert based on the logged in user,
        // This is an optional type, so we force it to resolve
        let user = service.loggedInUser!
        XCTAssertEqual(user.name, "John")
        XCTAssertEqual(user.age, 30)
    }
}
  • As you can see, we forcibly resolved the loggeinuser property of the service object before asserting. This is not an absolute mistake, but if the test starts to fail for some reasons, it may lead to some problems.
  • Suppose someone ("someone" may be "ourselves in the future") changes the code of the network part, causing the above test to crash. If this happens, the error message may only look like the following:
Fatal error: Unexpectedly found nil while unwrapping an Optional value
  • Although this is not a big problem when running locally with Xcode (because errors are displayed in association, at least most of the time), it can be problematic when running the entire project continuously as a whole. The above error information may appear in the huge "text wall", making it difficult to see the source of the error. More seriously, it will prevent subsequent tests from being executed (because the test process will crash), which will lead to slow and annoying repair work.

4, Guard and XCTFail

  • A potential way to solve the above problems is to simply use the guard declaration to gracefully resolve the optional types in the problem. If the resolution fails, just call XCTFail, as follows:
guard let user = service.loggedInUser else {
    XCTFail("Expected a user to be logged in at this point")
    return
}
  • Although the above approach is correct in some cases, in fact, I recommend avoiding it because it adds control flow to the test. For stability and predictability, it is usually expected that the test simply follows the given, when, then structure, and adding control flow will make the test code difficult to understand.

5, Keep optional types

  • Another way is to keep optional types optional, which is fully available in some use cases, including the example of UserManager. Because the assertion is used for the name and age attributes of the logged in user, if either attribute is nil, we will automatically get an error prompt. At the same time, if you use the additional XCTAssertNotNil check for the user, you can get a very complete diagnostic information:
let user = service.loggedInUser
XCTAssertNotNil(user, "Expected a user to be logged in at this point")
XCTAssertEqual(user?.name, "John")
XCTAssertEqual(user?.age, 30)
  • Now if the test starts to go wrong, you can get the following information:
XCTAssertNotNil failed - Expected a user to be logged in at this point
XCTAssertEqual failed: ("nil") is not equal to ("Optional("John")")
XCTAssertEqual failed: ("nil") is not equal to ("Optional(30)")
  • This makes it easier for us to know where the error occurred and where to start debugging and solving the error.

6, Test using throw

  • The third option, which is useful in some cases, is to replace the API that returns optional types with the throwing API. The elegance of the throwing API in Swift is that it can be easily used as an optional type when needed. Therefore, the throwing method is often used without sacrificing any usability. For example, suppose there is an EndpointURLFactory class used to generate the URL of a specific terminal in the App, which will obviously return optional types:
class EndpointURLFactory {
    func makeURL(for endpoint: Endpoint) -> URL? {
        ...
    }
}
  • Now convert it to using the throwing API, like this:
class EndpointURLFactory {
    func makeURL(for endpoint: Endpoint) throws -> URL {
        ...
    }
}
  • When we still want an optional type of URL, we just need to use try? Command to call it:
let loginEndpoint = try? urlFactory.makeURL(for: .login)
  • As far as testing is concerned, the biggest advantage of this approach is that you can easily use try in testing, and you can handle invalid values at no cost with XCTest runner. This is little known, but in fact, the Swift test can be the throwing function. Take a look at this:
class EndpointURLFactoryTests: XCTestCase {
    func testSearchURLContainsQuery() throws {
        let factory = EndpointURLFactory()
        let query = "Swift"

        // Because our test function is throwing, we can simply use 'try' here
        let url = try factory.makeURL(for: .search(query))
        XCTAssertTrue(url.absoluteString.contains(query))
    }
}
  • There are no optional types, no forced parsing, and some errors can be diagnosed perfectly.

7, Use the optional type of require

  • However, not all APIs that return optional types can be replaced with throwing, but when writing tests that contain optional types, there is an equally good method as throwing API.
  • Returning to the initial example of UserManager, if you neither force the resolution of loggeinuser nor treat it as an optional type, you can simply do this:
let user = try require(service.loggedInUser)
XCTAssertEqual(user.name, "John")
XCTAssertEqual(user.age, 30)
  • This is really cool, so you can get rid of a lot of forced parsing and avoid making the test code difficult to write and start. So what should be done to achieve the above effect? This is very simple. We only need to add an extension to XCTestCase. Let's analyze any optional type expression and return a non optional value or throw an error, like this:
extension XCTestCase {
    // In order to output elegant error messages
    // We follow the localized errow
    private struct RequireError<T>: LocalizedError {
        let file: StaticString
        let line: UInt

        // It is important to implement this property
        // Otherwise, when the test fails, we cannot output error messages gracefully in the record
        var errorDescription: String? {
            return "Required value of type \(T.self) was nil at line \(line) in file \(file)."
        }
    }

    // Using file and line enables us to automatically capture
    // The corresponding expression that appears in the source code
    func require<T>(_ expression: @autoclosure () -> T?,
                    file: StaticString = #file,
                    line: UInt = #line) throws -> T {
        guard let value = expression() else {
            throw RequireError<T>(file: file, line: line)
        }

        return value
    }
}
  • Now with the above content, if the UserManager login test fails, we can also get a very elegant error message to tell us the exact location of the error:
[UserServiceTests testLoggingIn] : failed: caught error: Required value of type User was nil at line 97 in file UserServiceTests.swift.
  • It adds a require() method to all optional types to improve the diagnosis of unavoidable forced parsing. Please refer to: Require.

8, Summary

  • Treating application code and test code with the same cautious attitude may not be suitable at the beginning, but it can make long-term maintenance testing easier. Whether it is developed independently or in a team, good error diagnosis and error information are a particularly important part. Using some techniques in this article may enable you to avoid many strange problems in the future.
  • The only time I use forced parsing in test code is when building the properties of test cases. Because these are always created in setup and destroyed in tearDown, I don't treat them as real optional types. As before, you also need to look at your own code and weigh decisions according to your own preferences.

Posted by chatmaster on Sat, 09 Oct 2021 12:55:47 -0700