Test-Driven Development One Recipe for High-Quality iOS Coding

Test-Driven Development: One Recipe for High-Quality iOS Coding

Contact Us
00:00
00:00
1x
  • 0.25
  • 0.5
  • 0.75
  • 1
  • 1.25
  • 1.5
  • 1.75
  • 2
Anton Panteleimenko iOS Developer

Imagine working on a new feature. Suddenly you encounter a failure in existing functionality though you had written unit tests. So instead of coding, you dive into a frustrating cycle of bug fixing. 

This problem is common. The study shows that software engineers spend 35% of their time on code maintenance, testing, and security issues. Another research states that an average developer wastes 17.3 hours weekly on bad code issues, debugging, and other code maintenance.

Test-driven development (TDD) can be a solution. The methodology focuses on developing high-quality code by combining unit testing, programming, and code refactoring.  

Test-Driven Development Cycle 

The flow of TDD is usually described as the red-green-refactor cycle, which encompasses the following stages:

  • Red. A developer starts by writing a test. At this point, the test fails since no code has been written to fulfill its requirements.
  • Green. The developer writes the necessary code to make the test pass. The goal is to implement the minimum code required to achieve the desired outcome according to the test case.
  • Refactor. Once the code is functional and the test successfully passes, the developer proceeds to code refactoring. It involves improving the code’s structure, organization, and overall quality while ensuring the test coverage remains intact.
  • Repeat. The cycle continues iteratively until all test cases are addressed and the developer is satisfied with the code’s performance.

Test-driven development cycle

Though the flow can be seen as backward, the TDD technique provides a proactive approach to identifying and addressing issues. It perfectly fits into agile practices and brings multiple benefits to the software development process.

The 5 Advantages of Test-Driven Development to Help You Succeed

Test-driven development can be tricky for beginner developers, as it requires much experience and time first. However, the approach brings numerous advantages to those who master it:

  1. Improved code quality. As TDD promotes writing tests before development starts, it ensures the code meets established requirements and behaves as expected. By continuously running tests, the approach identifies and addresses defects at the earliest stage and, though, improves the code quality.
  2. Faster debugging. Failed unit tests pinpoint a problem in the code, reducing the time spent on troubleshooting and investigation. Thus, developers quickly detect and debug emerging issues. 
  3. Enhanced product architecture. By breaking down functionality into small units with corresponding tests, TDD promotes clean code architecture.
  4. Regression prevention. TDD provides a suite of tests that run automatically whenever the code changes. It ensures existing functionality remains intact and unaffected by new modifications. 
  5. Better software documentation. Tests act as documentation specifying the expected code behavior.
cta-arrow
We are the team to help you achieve the highest quality for your iOS product Just drop us a line, and let's talk!

How Does TDD Fit Into Agile Software Development?

Though the TDD flow differs from the standard software development life cycle, it effectively supports the key principles and best practices of Agile:

  • Iterative software development. The approach aligns with Agile’s iterative approach by breaking tasks into small, testable units; 
  • Collaboration. TDD ensures close collaboration between developers and stakeholders and facilitates a better understanding of requirements;
  • Stable code. Automated tests catch regressions and unintended side effects, enabling frequent integration and deployment;
  • Adapting to changing requirements. TDD allows changing pieces of code without impacting the unchanged parts, which mitigates the risks of code regression. Hence, changes in requirements are no longer seen as complicated and endangering.

Developer’s Guide on How to Start TDD for iOS

The test-driven development process starts with unit test development. For iOS, in particular, Xcode is the best option:

  1. Open Xcode and go to File | New | Project
  2. Navigate to iOS | Application | Single View App and click on Next
  3. Type a product’s name.
  4. Select Swift as a language.
  5. Check “Include Unit Tests”
  6. Uncheck “Use Core Data” and click on Next

These are the options on the Xcode panel:

Creating a new project in Xcode

When starting a project, you usually check the “Include Tests” checkbox, and the following test templates are created automatically:

  • Unit Tests provide automated code testing that identifies errors occurring due to changes in the latest product version.
  • UI Tests provide automated user interface testing. XCUITest is a part of XCode, which simplifies the UI tests development by displaying the app’s user interface.
cta-arrow
How to solve UI security challenges in iOS mobile development Read more

Unit Tests in TDD 

Unit tests are small and focused automated tests that verify the behavior and correctness of individual code units, such as functions, methods, or classes. These tests play a major role in the development process within the TDD approach.

Each test focuses on specific functionality, which makes unit tests small and typically quick to write. Engineers provide input to their code under test and expect a specific output.

func testCodeChecker_WithCodeFromInput_WillFailValidation() {
  // Arrange
  let model = codeInputField.getCodeStub()
  // Act
  sut.processCodeCheck(model: model)
  // Assert
  XCTAssertFalse(!mockCodeCheckerValidator.isCodeValid,"Code entry was validated")
  XCTAssertFalse(!mockCodeCheckerValidator.isCountryValid, "Country name was validated")
  XCTAssertFalse(!mockCodeCheckerValidator.isLangValid, "Language format was validated")
  XCTAssertFalse(!mockCodeCheckerValidator.isDomainValid, "Domain was validated")
    }

In Xcode, unit tests have a specific target and are written using the XCTest framework. To build unit tests, create a subclass of XCTestCase that contains test methods. Only methods starting with “test” are recognized by the system and available for execution in Xcode. The environment parses these test methods, and they can be run individually or as a suite of tests.

/// A struct that contains a list of codes.
struct CodeCheckerViewModel {
    let codes: [Code]

    var myCode: Code? {
        return codes.first {$0.domain == Const.myDomain}
    }
}

/// A test case to validate our stored codes.
final class CodeCheckerTests: XCTestCase {
    /// It should show if it includes the needed domain.
    func testIncludesMyCode() {
	  let code1 = Code(code: 123, country: .usa, lang: .en, domain: .us)
	  …
        let viewModel = CodeCheckerViewModel(codes: [code1, code2, code3])
        XCTAssertTrue(viewModel.myCode != nil)
    }
}

These helpful tips from our team will help you build unit tests more efficiently:

  • Test critical aspects. Striving for 100% test coverage can be time-consuming, and potential benefits may not always justify the extra effort. Instead, focus on testing the business logic’s critical aspects to build a solid foundation for your further testing efforts.
  • Prioritize test design, not coverage metrics. Relying exclusively on code coverage metrics can be misleading. Tests covering all methods do not guarantee all possible scenarios and edge cases are tested. It is better to prioritize quality test design instead, including checking critical functionality and covering a variety of scenarios, both expected and unexpected.

Xcode interface

  • Use XCTAssert only for specific checks and avoid repetitions, as they can lead to confusion and poorly readable code. The provided code example demonstrates this issue:
func testCodeArrayContainsSpecificValue() {
    let code1 = Code(code: 123, country: .usa, lang: .en, domain: .us)
    ...
    let viewModel = CodeCheckerViewModel(codes: [code1, code2, code3])
    XCTAssert(viewModel.codes.filter { $0.country == .usa } == code1)
    XCTAssertTrue(viewModel.codes.filter { $0.country == .usa } == code1)
}

The purpose of unit testing is to verify conditions and expected outcomes in your code. In the  case above, the task  is to test an empty list of users. However, the code mistakenly uses assertions that check for a count of zero on a non-empty list, resulting in failed assertions.

Test Doubles

In unit testing, it is common to substitute a real object with its simplified version to minimize code dependencies. This object, referred to as a test double, helps simplify test cases and provide greater control over the expected outcomes. There are five types of test doubles for different purposes:

  • dummies,
  • fakes,
  • stubs.
  • mocks,
  • spies.

Dummies

Dummies serve as placeholders in a test scenario to satisfy method parameters or fulfill dependencies. Creating a dummy object is straightforward, as it does not require any specific behavior or functionality.

cta-arrow
How can your iOS app make money? Learn from our guide

For example, we need to create a CodeChecker that requires a CodesValidatorProtocol variable to be passed in its initializer. In this case, we can utilize a dummy object as a placeholder for the CodesValidatorProtocol, as it will not be used during test execution.

A dummy object helps ensure the required parameter is satisfied, allowing us to focus on testing other aspects of the CodeChecker without involving the actual CodesValidator implementation.

final class DummyCodesFieldValidatorModel: CodesValidatorProtocol {
    func isCodeValid(code: Int) -> Bool { return true }
    func isCountryValid(country: Country) -> Bool { return true }
    func isLangValid(lang: Language) -> Bool { return true }
    func isDomainValid(domain: Domain) -> Bool { return true }
}


final class CodeChecker {
	let codesFieldValidator: CodesValidatorProtocol
      init(codesFieldValidator: CodesValidatorProtocol) {
          self.codesFieldValidator = codesFieldValidator
      }
}


let codeValidator = CodeChecker(codesFieldValidator: DummyCodesFieldValidatorModel())

Fakes

A fake is an object that provides working implementations mimicking the production system’s behavior but may be not identical to it. Fakes are often used when a production system or dependencies are not suitable for testing purposes.

Let us consider an example with a protocol that includes a method for sending a code to a server and retrieving a response. However, we only want to use the actual server implementation in production, not during testing. In this case, we create a fake object replicating the server’s behavior for testing.

final class FakeSendCodeNetworkService: SendCodeNetworkProtocol {

    var isSendFuncCalled: Bool = false
    var shouldReturnError: Bool = false

    func sendCode(
        with model: CodeFieldModel,
        completionHandler: @escaping (CodeNetworkResponseModel?, CodeErrorHandler?) -> Void
    ) {
        isSendFuncCalled = true

        if shouldReturnError {
            completionHandler(nil, CodeErrorHandler.failedRequest(description: "Error sending code"))
        } else {
            let responseStatus = CodeNetworkResponseModel(status: "Valid")
            completionHandler(responseStatus, nil)
        }
    }
}

Stubs

A stub is an object always returning a predefined set of data (a “canned response”) when its methods are called. It can simulate certain behavior or provide consistent responses during testing.

In the example, we have a protocol CodeModelProtocol that includes a method returnEmptyStub for retrieving an empty model. We create a stub object StubCodeModel that conforms to the CodeModelProtocol protocol and provides predefined codes as a stub.

struct StubCodeModel: CodeModelProtocol, Encodable {
    var code: Int? = 321
    var country: Country? = .uk
    var lang: Language? = .en
    var domain: Domain? = .uk

    func returnEmptyStub() -> StubCodeModel {
        let emptyModel = StubCodeModel(
            code: nil,
            country: nil,
            lang: nil,
            domain: nil
        )
        return emptyModel
    }
}
cta-arrow
10+ mobile startup ideas based on AI features Read more

Mocks

Mocks are objects that provide predefined behavior and record the calls they receive. They track which methods are called and how many times. Mock objects play a crucial role in test-driven development and help ensure the desired behavior is achieved through the precise verification of method calls and interactions.

When using mock objects, you can set expectations on specific methods and their parameters. It allows you to verify if the expected methods are called with the correct arguments and in the right order. By calling specific methods of a mock object, you ensure that all fields were validated during the code check.

final class MockCodesFieldValidatorModel: CodesValidatorProtocol {

    private var isCodeValidated: Bool = false
    private var isCountryValidated: Bool = false
    private var isLangValidated: Bool = false
    private var isDomainValidated: Bool = false

    func isCodeValid(code: Int) -> Bool {
        isCodeValidated = true
        return isCodeValidated
    }

    func isCountryValid(country: Country) -> Bool {
        isCountryValidated = true
        return isCountryValidated
    }

    func isLangValid(lang: Language) -> Bool {
        isLangValidated = true
        return isLangValidated
    }

    func isDomainValid(domain: Domain) -> Bool {
        isDomainValidated = true
        return isDomainValidated
    }
}

Spies

A spy is an object recording its interactions with other objects without altering their behavior. It allows you to observe and verify the effects of method calls on dependencies or collaborators.

In the context of testing, when your code has side effects or relies on certain dependencies to perform certain actions, you can use a spy to capture and record those interactions. 

Spies are particularly useful when you want to verify if specific methods were called with the correct arguments or examine side effects produced by those method calls.

Final Thoughts

Test-driven development is an effective methodology for building high-quality and bug-free code. It is not a silver bullet, though. 

We advise examining the case closely. Consider integrating TDD into your development process if your workflow is stable, quick changes in requirements are rare, and your project has zero tolerance for bugs. 

Otherwise, if you have to react immediately to fast-changing product requirements and bugs in your code are permissible, TDD can be overly complicated for your situation. 

To evaluate how your project can benefit from the implementation of TDD, contact our mobile development team for more information. CHI Software portfolio covers mobile solutions for a number of industries. Let us find out how our testing expertise can help you stand out.

About the author
Anton Panteleimenko iOS Developer

Anton is a skilled iOS developer with a knack for building user-friendly and modern mobile apps. Always eager to explore new technologies, he stays up-to-date with the latest trends in iOS development and remains at the forefront of mobile engineering.

Rate this article
24 ratings, average: 4.5 out of 5

What's New on Our blog

20 Dec

AI Chatbots and CRM: A Smarter Integration

Can you imagine the changes an AI chatbot CRM integration can bring to your business? As your company grows, customer expectations rise – and managing both the volume and complexity of interactions can become challenging. Without automation, your team might become overwhelmed by calls, emails and chats, all while trying to maintain a high level of service. Now, picture that...

Read more
25 Nov

Transforming Building Automation with AI Technology

AI in building automation is changing how commercial and residential properties are managed. If you already use a building automation system (BAS) to control heating, cooling, lighting and security, you're already halfway there. Now imagine taking that system to the next level by adding artificial intelligence. But here's the thing: integrating AI into an existing system requires planning. It's not...

Read more
22 Nov

Comprehensive Guide to Voice Recognition App Development

Voice recognition has come a long way from a futuristic idea to something we use daily. In fact, the speech and voice recognition market is expected to hit USD 84.97 billion by 2032, up from USD 12.62 billion in 2023. That’s why voice application development is becoming a must for businesses that want to stay competitive. If you plan to...

Read more

Just one call, and you have the best QA team in the industry

    Successfully applied!