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.
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:
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.
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.
Enhanced product architecture. By breaking down functionality into small units with corresponding tests, TDD promotes clean code architecture.
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.
Better software documentation. Tests act as documentation specifying the expected code behavior.
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:
Open Xcode and go to File | New | Project.
Navigate to iOS | Application | Single View App and click on Next.
Type a product’s name.
Select Swift as a language.
Check “Include Unit Tests”
Uncheck “Use Core Data” and click on Next.
These are the options on the Xcode panel:
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.
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.
Use XCTAssert only for specific checks and avoid repetitions, as theycan lead to confusion and poorly readable code. The provided code example demonstrates this issue:
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.
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
}
}
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.
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.
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.
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...
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...
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...
Just one call, and you have the best QA team in the industry
About cookies on this site
We use cookies to give you a more personalised and efficient online experience.
Read more. Cookies allow us to monitor site usage and performance, provide more relevant content, and develop new products. You can accept these cookies by clicking “Accept” or reject them by clicking “Reject”. For more information, please visit our Privacy Notice