Test-Driven Development (TDD) offers a robust approach to building reliable and error-free Android apps by integrating testing into the development process.
Overview
What is Android Testing?
Android testing ensures that apps function correctly and provide a seamless user experience by identifying and resolving bugs before release.
What is Test-Driven Development (TDD)?
TDD is a development approach where tests are written before the actual code, ensuring functionality and guiding the design of reliable and maintainable applications.
Importance of TDD in Android
- Easier Maintenance: TDD ensures readable and manageable code, simplifying project transfers and updates.
- Modular Design: Focuses on one feature at a time, making debugging easier and improving reusability and design.
- Simplified Refactoring: Optimizes existing code confidently after passing initial tests.
- Less Dependency on Documentation: Unit tests act as functional documentation, reducing the need for detailed manual records.
- Reduced Debugging Efforts: Fewer errors and quicker detection save time on fixing issues.
This tutorial explores TDD in Android, highlighting how it helps developers write efficient, maintainable code while ensuring top-notch app performance.
What is Android Testing?
Android testing involves evaluating the functionality, performance, and reliability of Android applications across various devices, operating systems, and configurations. It ensures that the application operates as intended and provides a seamless user experience.
Types of Android testing include:
The Android testing framework plays a vital role in facilitating these processes. Built on JUnit, it offers tools to test every aspect of an app, from units to frameworks.
Developers can use plain JUnit for classes that don’t interact with Android APIs and JUnit extensions for testing Android components. Eclipse-integrated SDK tools also assist in creating and executing tests effectively.
Why is Android Testing Important?
Thorough Android testing ensures that apps are reliable, bug-free, and optimized for various devices.
Here’s why:
- Enhanced User Experience: Testing ensures that apps function smoothly across diverse environments, reducing the chances of crashes or glitches.
- Device Fragmentation Challenges: Android operates on thousands of device variations; testing on real devices helps maintain compatibility and performance.
- Bug Prevention: Early detection and resolution of bugs during development saves time and resources.
- Improved App Credibility: A well-tested app reflects quality and reliability, increasing user trust and retention.
- Cost Efficiency: Identifying and fixing issues pre-release avoids expensive post-launch patches and potential revenue loss.
Read More: What is Android UI Testing?
What is Test Driven Development
Prior to writing the actual code, Test-Driven Development (TDD) emphasises the production of unit test cases. It iteratively combines development, the creation of unit tests, and refactoring.
The origins of the TDD methodology are the Agile manifesto and Extreme programming. As its name implies, software development is driven by testing. In addition, it is an approach for structuring code that enables developers and testers to produce code that is both efficient and resilient over time.
Based on their initial comprehension, engineers begin writing small test cases for each feature using TDD. This method aims to only modify or develop new code if tests fail. This avoids multiple scripts for testing.
TDD can be represented by the Red-Green-Refactor Cycle.
It comprises three essential steps:
- Develop a test that will not pass (Red)
- Develop code that can pass a test (Green)
- Refactorize your code to attain high code quality (Refactor)
So what exactly does it mean? Before writing any code for a new project, you should be able to write a test that fails, then write the code necessary for the test to pass, then rewrite the code if necessary and begin the cycle again with another test.
Obviously, it is not always required to test every component of your application, especially when developing with Flutter; for example you will rarely need to test your entire UI and verify that each AppBar is displayed correctly,. Nevertheless, it may be beneficial to unit test some API calls if your application is consuming data from an external service or to do database-related tests if your application makes extensive use of the database. TDD will ultimately improve the stability and quality of your code greatly, especially if you maintain or contribute open-source code.
Also Read: How to test Flutter Apps on Android Devices
Why is TDD important in Android?
Here are the reasons why TDD is important:
1. Easier code maintenance: With TDD, developers write code that is more readable, manageable, and maintainable. Also, it requires less effort to concentrate on smaller, more digestible code bits. When transferring a project to a different individual or group, it is advantageous to have clean code.
2. Allows for Modular Design: The focus is placed on a particular feature at a time until the test has been passed. These iterations make finding bugs and reusing code in a project simple. In addition, solution architecture is improved by adherence to certain design principles.
3. Facilitates Code Refactoring: Refactoring is the process of optimising existing code in order to make it easier to implement. If the code for a modest update or improvement passes the preliminary tests, it can be refactored to acceptable standards. This is a necessary TDD process step.
4. Reduces the dependency on code documentation: The TDD methodology eliminates the need for time-consuming and detailed documentation. TDD entails a large number of simple unit tests that can function as documentation. Also, these unit tests illustrate how the code should operate.
5. Decreases the necessity for debugging: When there are fewer problems in the code, developers spend less time correcting them. In addition, errors are easier to detect, and developers are notified faster when anything breaks. This is one of the major benefits of the TDD process.
Also Read: How to perform Debugging in Appium
How To Perform TDD in Android
In order to demonstrate TDD in Android a sample task is considered as given below in the Given-When-Then format used in BDD.
This example describes an application using BDD. As can be seen, it does not define an Android, iOS, or Web application, but rather focuses on the desired behaviour. TDD is often used to address an issue because we do not initially describe the behaviour in a technology-agnostic manner.
TDD is most effective when the architecture helps to isolate technical specifics (such as the GUI, DBMS, HTTP, Bluetooth, etc.), because testing these aspects automatically is time-consuming and error-prone.
Also Read: How to test .aab file on Android device
However, developers can easily become preoccupied with technology and lose sight of the application’s added commercial value. The aforementioned scenarios will drive the development of the tests, as will be seen next.
Add Task Action Given I see the Task List screen When I click Add Task button Then I see Save Task screen
Save Task Given I see Save Task screen And I write call mum in the description When I click Save button Then I see Task List screen
The Save Task scenario provides the most value to the user, and once it has been completed, the story will be concluded. We have a greenfield application. Therefore, beginning with Save Task will lengthen the feedback loop too much.
Add Task Action and Empty Task List are significantly easier circumstances. As the initial state of the application, it is more natural to implement the Empty Task List scenario before the Add Task Action scenario. Empty Task List is a corner case that provides no benefit to the user, so I will test its implementation without a graphical user interface. In addition, we should keep edge case situations at the bottom of the test pyramid and place happy path scenarios at the top.
@Test fun `Given I have no tasks yet When I open task application Then I see Task List screen And I see no tasks`() { // Given val myTaskApplication = MyTaskApplication() // When myTaskApplication.open() // Then myTaskApplication.withScreenCallback { screen -> assertEquals(emptyList<String>(), screen.taskList) } }
Since this test excludes the user interface, certain design decisions must be made while designing it. MyTaskApplication registers a callback to receive MyTaskListScreen containing the list of tasks.
/** * This class represents a simple task application. * It allows navigating between different screens. */ class MyTaskApplication { // Current screen displayed in the application. private lateinit var myScreen: MyScreen /** * Opens the task application and sets the current screen to the task list screen. */ fun open() { myScreen = MyTaskListScreen(emptyList()) } /** * Simulates adding a task, which changes the current screen to the save task screen. */ fun addTask() { myScreen = MySaveTaskScreen() } /** * Allows interaction with the current screen by providing a callback function. * * @param callback The callback function to interact with the current screen. */ fun withScreenCallback(callback: (MyScreen) -> Unit) { callback.invoke(myScreen) } } /** * Represents the task list screen, which displays a list of tasks. * * @property taskList The list of tasks displayed on this screen. */ data class MyTaskListScreen( val taskList: List<String> ) /** * Represents the save task screen, where the user can add a new task. */ class MySaveTaskScreen : MyScreen /** * Interface for different screens in the application. */ interface MyScreen
To develop this test, we have to add a Screen interface in order to reuse the withScreenCallback function for MySaveTaskScreen.
interface MyScreen /** * Represents the task list screen, which displays a list of tasks. * * @property taskList The list of tasks displayed on this screen. */ data class MyTaskListScreen( val taskList: List<String> ) : MyScreen /** * Represents the save task screen, where the user can add a new task. */ class MySaveTaskScreen : MyScreen /** * This class represents a simple task application. * It allows navigating between different screens. */ class MyTaskApplication { // Current screen displayed in the application. private lateinit var myScreen: MyScreen /** * Opens the task application and sets the current screen to the task list screen. */ fun open() { myScreen = MyTaskListScreen(emptyList()) } /** * Simulates adding a task, which changes the current screen to the save task screen. */ fun addTask() { myScreen = MySaveTaskScreen() } /** * Allows interaction with the current screen by providing a callback function. * * @param callback The callback function to interact with the current screen. */ fun withScreenCallback(callback: (MyScreen) -> Unit) { callback.invoke(myScreen) } }
Certain refactors can be applied to improve the code structure. The screenCallback variable can have a default value of a no-op function, eliminating the need for the ? syntax and simplifying its usage. Additionally, MyScreen, MyTaskListScreen, and MySaveTaskScreen can be organized into their respective files for better maintainability.
Key refactors include:
- Assigning a default no-op function to screenCallback.
- Relocating each class to its appropriate file.
MyScreen.kt:
interface MyScreen
MyTaskListScreen.kt:
data class MyTaskListScreen( val taskList: List<String> ) : MyScreen
MySaveTaskScreen.kt:
class MySaveTaskScreen : MyScreen
MyTaskApplication.kt:
class MyTaskApplication { private lateinit var myScreen: MyScreen private var screenCallback: (MyScreen) -> Unit = {} fun open() { myScreen = MyTaskListScreen(emptyList()) } fun addTask() { myScreen = MySaveTaskScreen() } fun withScreenCallback(callback: (MyScreen) -> Unit = {}) { screenCallback = callback screenCallback.invoke(myScreen) // Reset the screenCallback to no action screenCallback = {} } }
With these changes, a default value has been provided for screenCallback, enabling bypassing the ? syntax when calling the function. Additionally, each class has been moved to its respective file to enhance code organization.
Finally, the Save Task scenario can be addressed by adding the user interface to the test, making it more comprehensive.
@RunWith(AndroidJUnit4::class) class TaskManagerTest { @Test fun `Given I have no tasks And I see Save Task screen And I fill description When I tap Save button Then I see Task List screen with description`() { // Launch the activity val activityScenario = ActivityScenario.launch(MainActivity::class.java) // Define view IDs val addTaskButton = withId(R.id.view_task_list_add_task_button) val descriptionInput = withId(R.id.view_add_task_description_input_field) val saveButton = withId(R.id.view_add_task_save_task_button) // Perform actions onView(addTaskButton).perform(click()) onView(descriptionInput).perform(replaceText("My new task description")) onView(saveButton).perform(click()) // Verify the result onView(withText("My new task description")).check(matches(isDisplayed())) // Close the activity scenario activityScenario.close() } }
This test fails because there is no MainActivity, and it has not been declared in the AndroidManifest. Additionally, the referenced IDs do not exist. To resolve this, certain design considerations must be made. Since the application will have two displays, they can be represented using Fragments, Activities, or Views. Standard practice suggests using a single activity along with views for this purpose
// Application class for managing tasks class MainApplication : Application() { val taskApplication by lazy { TaskApplication() } override fun onCreate() { super.onCreate() taskApplication.open() } } // Main Activity for displaying different screens class MainActivity : AppCompatActivity() { private val taskApplication by lazy { (application as MainApplication).taskApplication } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val frame = findViewById<FrameLayout>(R.id.activity_main_frame) taskApplication.withScreenCallback { screen -> frame.removeAllViews() when (screen) { is TaskListScreen -> { val view = TaskListView(this) view.application = taskApplication frame.addView(view) view.updateScreen(screen) } is SaveTaskScreen -> { val view = SaveTaskView(this) view.application = taskApplication frame.addView(view) } } } } } // View for displaying the list of tasks class TaskListView : ConstraintLayout { lateinit var application: TaskApplication private lateinit var listView: RecyclerView constructor(context: Context) : super(context) { init() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init() } constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init() } fun updateScreen(screen: TaskListScreen) { (listView.adapter as TaskListAdapter).updateTasks(screen.tasks) } private fun init() { inflate(context, R.layout.view_task_list, this) findViewById<Button>(R.id.view_task_list_add_task_button).setOnClickListener { application.addTask() } listView = findViewById(R.id.view_task_list_view) listView.layoutManager = LinearLayoutManager(context); listView.adapter = TaskListAdapter(LayoutInflater.from(context)) } } // View for saving a new task class SaveTaskView : ConstraintLayout { lateinit var application: TaskApplication private lateinit var saveTaskButton: Button private lateinit var descriptionInputField: EditText constructor(context: Context) : super(context) { init() } constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { init() } constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init() } private fun init() { inflate(context, R.layout.view_add_task, this) saveTaskButton = findViewById(R.id.view_add_task_save_task_button) descriptionInputField = findViewById(R.id.view_add_task_description_input_field) saveTaskButton.setOnClickListener { application.saveTask(descriptionInputField.text.toString()) } } }
To pass the test, numerous classes were built (excluding the XML files and the list adapter implementation). While the feedback loop time was not optimal, future stories will require less code since the foundational elements are already in place. In greenfield systems, keeping the first user story minimal yet valuable is essential to avoid a prolonged feedback loop.
Also Read: Android vs iOS Mobile App Testing
Common mistakes to avoid while performing TDD in Android
Here are the common mistakes to avoid in Test-Driven Development (TDD):
- Overdeveloping the Code: Avoid writing unnecessary code to pass the test. TDD focuses on incremental development, solving the immediate task without preemptively addressing future functionalities.
- Overlooking Test Failures: If a test doesn’t fail, it may indicate missing cases. As development progresses, expect failures to catch overlooked issues and avoid missing critical bugs.
- Skipping Refactoring: Always refactor after making a test pass to keep the code clean and maintainable. Neglecting this step can lead to unmanageable code over time.
- Writing Large Tests: Keep tests focused and specific. Overly complex tests make debugging and maintenance harder.
- Neglecting Edge Cases: Test the happy paths and edge cases to catch rare or boundary conditions early.
- Over-reliance on Mocks: Avoid excessive use of mock objects, as they can make tests fragile and tightly coupled to implementations.
- Not Updating Tests: Ensure test cases are updated alongside code changes to prevent them from becoming irrelevant or unreliable.
Best Practices For TDD in Android
Here are some best practices for Test-driven development in Android
- Frequent testing: the sooner teams begin testing, the better. Test the code shortly after writing it. This will allow teams to spot bugs sooner and avoid debugging already-functioning code.
- Keep your tests succinct and focused: Each test should evaluate exactly one notion. This will expedite the detection and correction of faults.
- Make readable assessments: The tests should be straightforward to read and comprehend. This will expedite bug noticing and correction of faults.
- Automate Tests: Automate tests to ensure consistency and reduce human error. This also speeds up the testing process, especially during frequent updates.
- Maintain Test Isolation: Each test should be independent, ensuring that failures in one test don’t affect others. This makes troubleshooting and fixes easier.
- Refactor Tests Alongside Code: Just as production code is refactored, tests should also be updated and improved as the application evolves. This ensures tests remain relevant and efficient.
Conclusion
TDD involves traversing the test pyramid and determining at which tier a certain behaviour should be implemented. To accomplish this, the behaviour must initially be crystal apparent. You should prioritise putting a new behaviour at the apex of the test pyramid so that all happy pathways are covered by merging all non-slow application components.
By adhering to this methodology, you may prevent inadvertent complication and maintain our focus on bringing value to the user. In addition, as the size of the source code increases, the development rate remains constant because coverage gives developers the confidence to modify the application.
Even with the most severe TDD testing in Android, many device-OS combinations must be permitted for UI Testing to assure platform compatibility. Setting up and maintaining platforms becomes a time- and labor-intensive task as the number of platforms to support increases.
Yet, with BrowserStack App Live (Manual Testing) and BrowserStack App Automate (Automation Testing), you’ll have access to hundreds of popular mobile device-operating system combinations such as Samsung Galaxy (S4 -S22), Google Pixel, and Xiaomi devices for testing their application and script automation instances.
This means that you are not responsible for purchasing hardware or implementing software updates or patches, yet you get the facility to test on real devices under real user conditions. You merely need to Sign-up for free, choose the needed device-operating system combination, and start testing their application.
Try BrowserStack App Automate for Free
Useful Resources for Test Driven Development
- What is Test Driven Development (TDD)?
- Test Driven Development – Making the most out of your testing team
- Test Driven Development (TDD) in Java
- TDD in Android : Test Driven Development Tutorial with Android
- TDD in Flutter: How To Use Test Driven Development in Flutter
- TDD vs BDD vs ATDD : Key Differences