Skip to content

Unit Tests Guidelines

lmarceau edited this page Dec 12, 2024 · 12 revisions

Unit tests help ensure that individual pieces of code work correctly, improve reliability, and allow safe refactoring. The following guidelines cover common practices to follow while writing unit tests in our project. Please visit our other unit test wiki prior to reading this one as it gives a more foundational overview.

General checklist

When creating a PR or writing tests, here are some questions to ask yourself and guidelines to follow:

Created a View Controller class? At a minimum check for memory leaks

Created a Coordinator class? At a minimum check for router presentation calls

Created Redux architecture? Add state tests, middleware tests, dependencies that lives in the middleware

Fixed a bug? Write tests for it that fails without your change and passes with your change (depending on the urgency, case by case basis)

Have you used Xcode Tooling to confirm if you are merging in valuable tests? Run tests repeatedly, check code coverage (see more about xcode testing tools).

Guidelines

1. Setup/Teardown

Proper setup and teardown methods ensure that each test starts with a clean environment and doesn't affect other tests.

Do:

Use setup to initialize test data common to multiple tests. Use teardown to clean up or reset shared resources so that tests are independent. For teardown, ensure that we cleanup operations before calling the superclass’s cleanup method.

override func setUp() {
    super.setUp()
    // Initialize common resources, e.g., mock services, test data
    viewModel = ViewModel()
}

override func tearDown() {
   // Clean up after each test, e.g., release mocks, reset state
   viewModel = nil
   super.tearDown()
}

Avoid:

Sharing mutable state between tests without proper isolation. Complex setup methods that make it difficult to understand the test. Keep setup simple and focused and call super class teardown method last.

2. Choosing Assertions

Assertions verify that the result of the code under test is as expected. Choose more meaningful and specific assertions for readability.

Do:

Use specific assertions to make tests readable and add meaningful failure messages if it provides additional clarity.

XCTAssertEqual(user.age, 30)
XCTAssertTrue(isValid)

let unwrappedValue = try XCTUnwrap(optionalValue)
XCTAssertEqual(unwrappedValue, "expected value")

Avoid:

Using generic assertions like XCTAssertTrue without meaningful messages or context.

XCTAssertTrue(user.age == 30)
XCTAssertEqual(isValid, true)

let unwrappedValue = optionalValue!
XCTAssertEqual(unwrappedValue, "expected value")

3. Forced Unwrapping

Swift’s forced unwrapping (!) can lead to runtime crashes which can disturb our workflow when running test suites.

Do:

Use optional bindings (if let, guard let) to safely unwrap optionals.

guard let unwrappedValue = optionalValue else {
    XCTFail("Optional value should not be nil")
    return
}
XCTAssertEqual(unwrappedValue.description, "Hello")

Avoid:

Force-unwrapping optionals in tests, which can lead to tests crashing if the value is nil rather than failing gracefully.

XCTAssertNotNil(optionalVal)
XCTAssertEqual(optionalVal!.description, "Hello")

4. Mocking External Dependencies

When testing units that interact with external services, databases, or APIs, mocks or stubs should be used to simulate these interactions. See more in our other unit test wiki.

Do:

Mock external services or APIs to isolate the code under test.

class MockService: Service {
    func fetchData() -> Data {
        return Data()  // Return mock data
    }
}

func testViewModel_WithMockService() {
    let mockService = MockService()
    let viewModel = ViewModel(service: mockService)
    
    XCTAssertEqual(viewModel.data.count, 0)  // Test logic using mock service
}

Avoid:

Testing real APIs, databases, or other external dependencies in unit tests (this belongs in integration tests).

func testViewModel_WithRealService() {
    let realService = RealService()  // Avoid using real services in unit tests
    let viewModel = ViewModel(service: realService)
    
    XCTAssertEqual(viewModel.data.count, 0)  // Unreliable test depending on external service
}

5. Testing with DispatchQueue

Flaky unit tests can often result from asynchronous code. When testing asynchronous code, XCTestExpectation might take longer to be fulfilled than anticipated on the CI pipeline, leading to test failures. One way to make tests more reliable is by converting them to synchronous tests using a DispatchQueueInterface. This approach is mostly used in the case of calls to DispatchQueue, such as DispatchQueue.main.async, but it can also accommodate custom queues. Below are the steps to implement this method:

Step 1: Modify Production Code

In your production code, replace the concrete DispatchQueue type with a DispatchQueueInterface. Use dependency injection to pass in the appropriate queue. For example, you can define the queue as a parameter with a default value, like DispatchQueue.main, ensuring that the production code functionality remains unchanged.

Class Example {
    public func exampleFunction(queue: DispatchQueueInterface = DispatchQueue.main) {
        queue.async {
            // Do async work on the main queue
        }
    } 
}  

Step 2: Mock the Dispatch Queue in Tests

In your tests, pass a MockDispatchQueue to replace the default DispatchQueue.main. Since MockDispatchQueue does not execute asynchronous code, the test will run quickly and reliably.

    let mockQueue = MockDispatchQueue()
    func testExample() {
        let subject = Example()
        subject.exampleFunction(queue: mockQueue)
        // Assert test results
    }

6. Testing with the Store

When testing components that interact with the Redux global store, tests should ensure that the setup and teardown of the store are correctly handled.

Do:

The test class should conform to the StoreTestUtility protocol. Feel free to use the StoreTestUtilityHelper to help setup and teardown. See PR with example of using the store to test.

class MyTest: XCTestCase, StoreTestUtility {
    let storeUtilityHelper = StoreTestUtilityHelper()

    override func setUp() {
        super.setUp()
        setupTestingStore()
    }

    override func tearDown() {
        resetTestingStore()
        super.tearDown()
    }
}

Avoid:

Not using the protocol and not having the compile errors where we require the store to be reset. By not resetting the store, there may be side effects for other tests that rely on the store and other middlewares. Our tests should be isolated and therefore, cleanup the state of the store to be what it was originally.

Mocking the Store

When testing interactions with the store, you often don't need the actual store. If you just want to confirm that your code is calling the store as expected you can use MockStoreForMiddleware.

MockStoreForMiddleware allows you to test that your middleware is dispatching the actions you expect when you expect them.

class MockStoreForMiddleware<State: StateType>: DefaultDispatchStore {
    var dispatchedActions: [Redux.Action] = []
    var dispatchCalled: (()-> Void)?
    ...
    func dispatch(_ action: Redux.Action) {
        var dispatchActions = dispatchCalled.withActions
        dispatchActions.append(action)

        dispatchCalled = (dispatchCalled.numberOfTimes + 1, dispatchActions)
        dispatchCalledCompletion()
    }
}

dispatchedActions records all actions dispatched to the mock store. Check this property to ensure that your middleware correctly dispatches the right action(s), and the right count of actions, in response to a given action.

dispatchCalled is called every time an action is dispatched to the mock store. Used to confirm that a dispatched action completed. This is useful when the middleware is making an asynchronous call and we want to wait for an expectation to be fulfilled.

In StoreTestUtilityHelper you can use setupTestingStore(with mockStore: any DefaultDispatchStore<AppState>) to set up a mock store. You will still want to reset the testing store in the teardown by calling resetTestingStore

Example test using MockStoreForMiddleware:

    func exampleTest() {
        let subject = ExampleMiddleware()
        let action = ExampleAction(windowUUID: .XCTestDefaultUUID, actionType: ExampleActionType.typeUnderTest)
        let expectation = XCTestExpectation(description: "Example action typeUnderTest dispatched")

        mockStore.dispatchCalledCompletion = {
            expectation.fulfill()
        }

        subject.functionUnderTest()

        wait(for: [expectation])

        let actionCalled = try XCTUnwrap(mockStore.dispatchedActions.first as? ExampleAction)
        let actionType = try XCTUnwrap(actionCalled.actionType as? ExampleActionType)

        XCTAssertEqual(mockStore.dispatchedActions.count, 1)
        XCTAssertEqual(actionType, ExampleAction.ExampleAction) 
        XCTAssertEqual(actionCalled.typeUnderTest.count, 3)
    }

What else?

  • It doesn't end with testing dispatch! You can validate any store functionality you want! ✨
  • You can expand the existing mock if needed or add a new mock to fit your needs, just follow a similar pattern.
Clone this wiki locally