Skip to content

Unit Tests Guidelines

Cyndi Chin edited this page Sep 27, 2024 · 19 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.

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 and dependencies common to multiple tests. Use teardown to clean up or reset shared resources. Ensure tests are independent by resetting any shared state.

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. For teardown, ensure that we cleanup operations before calling the superclass’s cleanup method.

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 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.

Clone this wiki locally