Test-Driven Development: A Guide from CHI Software Engineers

CHI Software
8 min readJul 26, 2023

--

A guide on test-driven development (TDD)

Read this article in full on our blog.

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. Anton Panteleimenko, iOS Developer at CHI Software, exaplains the workflow in detail.

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.

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:

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.

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 are some helpful tips from our team that 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.

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

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.

--

--

CHI Software

We solve real-life challenges with innovative, tech-savvy solutions. https://chisw.com/