In the previous episode, we wrote the first unit test for the analytics library we built. In this episode, we write a few more unit tests and take advantage of the compiler to find gaps in the test suite.

Taking Advantage of the Compiler

Open JourneyTests.swift and navigate to the testEventNames() method, the unit test that tests the computed event property of the Journey enum. Manually creating a Journey object becomes tedious as we add more cases to the Journey enum. We can simplify the implementation of the testEventNames() method by conforming the Journey enum to the CaseIterable protocol.

Open Journey.swift in the assistant editor on the right and make sure code coverage is enabled in the source editor. Conform the Journey enum to the CaseIterable protocol.

import Swinject
import Foundation

internal enum Journey: CaseIterable {
	
	...

}

By conforming the Journey enum to the CaseIterable protocol, the enum receives a type property with name allCases. As the name suggests, the allCases type property returns an array that contains all the cases the enum defines. This is convenient in the unit test we are writing. Let's update the unit test for the computed event property.

We create an array of all the cases the Journey enum defines with the help of the allCases type property. We use the forEach(_:) method to iterate through the collection. In the closure we pass to the forEach(_:) method, we create a mock analytics service. It is important that we create the mock analytics service in the closure because we want to start with a clean slate every time the closure is executed.

func testEventNames() throws {
    Journey.allCases.forEach { journey in
        let analyticsService = MockAnalyticsService()
    }
}

The remainder of the unit test is similar to the implementation we started with. We invoke the properties(_:) method on the Journey object to create an Event object. We send the Event object to the mock analytics service by invoking its send(to:) method. This should look familiar.

func testEventNames() throws {
    Journey.allCases.forEach { journey in
        let analyticsService = MockAnalyticsService()

        journey
            .properties()
            .send(to: analyticsService)
    }
}

We access the array of events the mock analytics service was asked to send through its events property and assert that the mock analytics service received one event. The next section is a bit different. We store the name of the first event in a constant, name, and use a switch statement to assert that the name of the first event is equal to the name we expect.

func testEventNames() throws {
    Journey.allCases.forEach { journey in
        let analyticsService = MockAnalyticsService()

        journey
            .properties()
            .send(to: analyticsService)

        let events = analyticsService.events

        XCTAssertEqual(events.count, 1)

        let name = events.first?.name

        switch journey {
        case .createNote:
            XCTAssertEqual(name, "create-note")
        case .updateNote:
            XCTAssertEqual(name, "update-note")
        case .deleteNote:
            XCTAssertEqual(name, "delete-note")
        }
    }
}

This approach may seem tedious, but it has an important benefit that I talk about in a moment. Run the test suite to make sure the unit test passes. Notice that the unit test we wrote covers the computed event property. Let's now extend the Journey enum by adding a case with name duplicateNote.

internal enum Journey: CaseIterable {

    // MARK: - Cases

    case createNote
    case updateNote
    case deleteNote
    case duplicateNote

	...

}

The compiler throws an error because we need to update the implementation of the computed event property. Let's do that to resolve the error.

private var event: String {
    switch self {
    case .createNote:
        return "create-note"
    case .updateNote:
        return "update-note"
    case .deleteNote:
        return "delete-note"
    case .duplicateNote:
        return "duplicate-note"
    }
}

Run the test suite to make sure the changes we made didn't break anything. Notice that the compiler throws another error, this time the testEventNames() method is the offender. This is good, though. The compiler notifies us that we need to update the unit test for the computed event property. This is convenient because we can take advantage of the compiler to find gaps in the test suite. Let's extend the switch statement to resolve the error. Run the test suite one more time.

func testEventNames() throws {
    Journey.allCases.forEach { journey in
        let analyticsService = MockAnalyticsService()

        journey
            .properties()
            .send(to: analyticsService)

        let events = analyticsService.events

        XCTAssertEqual(events.count, 1)

        let name = events.first?.name

        switch journey {
        case .createNote:
            XCTAssertEqual(name, "create-note")
        case .updateNote:
            XCTAssertEqual(name, "update-note")
        case .deleteNote:
            XCTAssertEqual(name, "delete-note")
        case .duplicateNote:
            XCTAssertEqual(name, "duplicate-note")
        }
    }
}

Writing More Unit Tests

If we look at the code coverage of Journey.swift, we need to focus on the Property enum next. The good news is that the unit tests for the Property enum require less setup. The not so good news is that we won't be able to leverage the CaseIterable protocol for the unit tests of the Property enum. Enums that conform to the CaseIterable protocol usually don't define cases with associated values.

We write a unit test for each case of the Property enum. Let's start with the kind case. Add a unit test with name testPropertyKind().

func testPropertyKind() throws {
	
}

We declare a constant, properties, of type [Journey.Property]. The array contains two items. That is sufficient in this scenario since the Kind enum defines two cases, blank and template. Because the template case defines an associated value, we don't take advantage of the CaseIterable protocol.

func testPropertyKind() throws {
    let properties: [Journey.Property] = [
        .kind(.blank),
        .kind(.template("test"))
    ]
}

We use the forEach(_:) method to iterate through the array of properties. In the closure we pass to the forEach(_:) method, we use a guard statement to exit early. The guard statement has two functions. First, we bind the associated value of the property to a constant with name kind. Second, we make the unit test fail if the property's value isn't kind.

func testPropertyKind() throws {
    let properties: [Journey.Property] = [
        .kind(.blank),
        .kind(.template("test"))
    ]

    properties.forEach { property in
        guard case .kind(let kind) = property else {
            XCTFail("Invalid Property")
            return
        }
    }
}

Now that we know the property's value is kind, we can unit test the computed name and value properties. We assert that the name of the property is equal to kind.

func testPropertyKind() throws {
    let properties: [Journey.Property] = [
        .kind(.blank),
        .kind(.template("test"))
    ]

    properties.forEach { property in
        guard case .kind(let kind) = property else {
            XCTFail("Invalid Property")
            return
        }

        XCTAssertEqual(property.name, "kind")
    }
}

To unit test the computed value property, we switch on the value of the kind constant. Remember that it stores the associated value. Because the computed value property is of type Any, we cast its value to String. Because we use a switch statement, the compiler throws an error if the switch statement isn't exhaustive. This ensures we don't overlook edge cases in the unit test. Run the test suite to make sure the newly added unit test passes.

func testPropertyKind() throws {
    let properties: [Journey.Property] = [
        .kind(.blank),
        .kind(.template("test"))
    ]

    properties.forEach { property in
        guard case .kind(let kind) = property else {
            XCTFail("Invalid Property")
            return
        }

        XCTAssertEqual(property.name, "kind")

        switch kind {
        case .blank:
            XCTAssertEqual(property.value as? String, "blank")
        case .template(let name):
            XCTAssertEqual(property.value as? String, "template " + name)
        }
    }
}

The unit tests for the source and wordCount cases of the Property enum are similar. Let's focus on the source case next. Because the cases of the Source enum don't define associated values, we can conform the enum to the CaseIterable protocol. Let's do that before we write the unit test for the source case of the Property enum.

import Foundation

enum Source: String, CaseIterable {

    // MARK: - Cases

    case home
    case siri

}

Add a unit test to the JourneyTests class and name it testPropertySource(). The implementation is similar to that of testPropertyKind(). We create an array of Property objects with the help of the allCases type property of the Source enum. We invoke the map(_:) method on the array of Source objects and use the Source object to create a Property object.

func testPropertySource() throws {
    let properties: [Journey.Property] = Source.allCases.map { source in
        Journey.Property.source(source)
    }
}

The next step should look familiar. We use the forEach(_:) method to iterate through the array of properties. In the closure we pass to the forEach(_:) method, we use a guard statement to exit early. We bind the associated value of the property to a constant with name source and we make the unit test fail if the property's value isn't source.

func testPropertySource() throws {
    let properties: [Journey.Property] = Source.allCases.map { source in
        Journey.Property.source(source)
    }

    properties.forEach { property in
        guard case .source(let source) = property else {
            XCTFail("Invalid Property")
            return
        }
    }
}

We assert that the property's name is equal to source and the property's value is equal to the raw value of the Source object. That's it.

func testPropertySource() throws {
    let properties: [Journey.Property] = Source.allCases.map { source in
        Journey.Property.source(source)
    }

    properties.forEach { property in
        guard case .source(let source) = property else {
            XCTFail("Invalid Property")
            return
        }

        XCTAssertEqual(property.name, "source")
        XCTAssertEqual(property.value as? String, source.rawValue)
    }
}

The unit test for the wordCount case of the Property enum is even simpler. Add a unit test with name testPropertyWordCount() to the JourneyTests class.

func testPropertyWordCount() throws {

}

In the body of the unit test, we declare a constant with name wordCount and value 123. We use the wordCount constant to create a Property object. The value of the Property object is wordCount.

func testPropertyWordCount() throws {
    let wordCount = 123
    let property = Journey.Property.wordCount(wordCount)
}

The remainder of the unit test is quite simple. We assert that the name of the property is word_count and that the value of the property is equal to the value stored in the wordCount property. That's it.

func testPropertyWordCount() throws {
    let wordCount = 123
    let property = Journey.Property.wordCount(wordCount)

    XCTAssertEqual(property.name, "word_count")
    XCTAssertEqual(property.value as? Int, wordCount)
}

Run the test suite one more time. Take a look at the code coverage of Journey.swift. The unit tests we wrote in this and the previous episodes cover the Journey enum, the Event struct, and the Property enum. That wasn't too difficult. Right?

What's Next?

How easy it is to write unit tests largely depends on the code under test. Spending time considering the testability of the code you write is therefore time well spent. In the next episode, we look at an important shortcoming of code coverage. In that episode, you learn that you shouldn't blindly trust the tools you use.