Md Mominul Islam | Software and Data Enginnering | SQL Server, .NET, Power BI, Azure Blog

while(!(succeed=try()));

LinkedIn Portfolio Banner

Latest

Home Top Ad

Responsive Ads Here

Tuesday, August 26, 2025

Master Android App Development: Chapter 9 - Testing and Debugging for Robust Android Apps

 

Table of Contents

  1. Introduction to Testing and Debugging

    • 1.1 Why Testing and Debugging Matter

    • 1.2 Types of Testing in Android Development

    • 1.3 Overview of Tools and Techniques

  2. Unit Testing with JUnit

    • 2.1 What is Unit Testing?

    • 2.2 Setting Up JUnit in Android Studio

    • 2.3 Writing Your First Unit Test

    • 2.4 Real-Life Example: Testing a Shopping Cart Calculator

    • 2.5 Best Practices for Unit Testing

    • 2.6 Exception Handling in Unit Tests

    • 2.7 Pros, Cons, and Alternatives

  3. UI Testing with Espresso

    • 3.1 Introduction to UI Testing

    • 3.2 Setting Up Espresso in Android Studio

    • 3.3 Writing UI Tests with Espresso

    • 3.4 Real-Life Example: Testing a Login Screen

    • 3.5 Best Practices for UI Testing

    • 3.6 Exception Handling in UI Tests

    • 3.7 Pros, Cons, and Alternatives

  4. Debugging Tools in Android Studio

    • 4.1 Overview of Debugging Tools

    • 4.2 Using Breakpoints and Watches

    • 4.3 Analyzing Stack Traces

    • 4.4 Real-Life Example: Debugging a Crash in a Food Delivery App

    • 4.5 Best Practices for Debugging

    • 4.6 Exception Handling During Debugging

    • 4.7 Pros, Cons, and Alternatives

  5. Logging and Error Tracking

    • 5.1 Importance of Logging

    • 5.2 Implementing Logging in Android

    • 5.3 Using Third-Party Error Tracking Tools

    • 5.4 Real-Life Example: Tracking Errors in a Fitness App

    • 5.5 Best Practices for Logging

    • 5.6 Exception Handling in Logging

    • 5.7 Pros, Cons, and Alternatives

  6. Performance Optimization Techniques

    • 6.1 Why Optimize App Performance?

    • 6.2 Profiling Tools in Android Studio

    • 6.3 Optimizing CPU, Memory, and Network Usage

    • 6.4 Real-Life Example: Optimizing a Social Media App

    • 6.5 Best Practices for Performance Optimization

    • 6.6 Exception Handling in Optimization

    • 6.7 Pros, Cons, and Alternatives

  7. Practical Exercise: Building and Testing an App

    • 7.1 Project Setup: A To-Do List App

    • 7.2 Adding Unit Tests

    • 7.3 Adding UI Tests

    • 7.4 Debugging a Performance Issue

    • 7.5 Implementing Logging

    • 7.6 Optimizing the App

  8. Conclusion

    • 8.1 Recap of Key Concepts

    • 8.2 Next Steps in Android Development


1. Introduction to Testing and Debugging

1.1 Why Testing and Debugging Matter

Testing and debugging are critical to delivering high-quality Android apps. Testing ensures your app behaves as expected under various conditions, while debugging helps identify and fix issues that cause crashes or poor performance. In real-world scenarios, such as a food delivery app, untested code could lead to incorrect order calculations, while poor debugging could leave users frustrated with crashes.

1.2 Types of Testing in Android Development

  • Unit Testing: Tests individual components (e.g., functions or classes) in isolation.

  • UI Testing: Tests user interactions with the app’s interface.

  • Integration Testing: Tests how components work together.

  • Manual Testing: Human testers interact with the app to find issues.

  • Performance Testing: Ensures the app runs efficiently.

1.3 Overview of Tools and Techniques

  • JUnit: For unit testing.

  • Espresso: For UI testing.

  • Android Studio Debugging Tools: Breakpoints, watches, and profilers.

  • Logging: Using Logcat and third-party tools like Firebase Crashlytics.

  • Performance Tools: Android Studio’s CPU, memory, and network profilers.


2. Unit Testing with JUnit

2.1 What is Unit Testing?

Unit testing verifies that individual units of code (e.g., methods or classes) work as expected. In Android, JUnit is the go-to framework for unit testing.

2.2 Setting Up JUnit in Android Studio

  1. Add Dependencies: Ensure JUnit is included in your build.gradle file.

dependencies {
    testImplementation 'junit:junit:4.13.2'
}
  1. Sync Project: Click "Sync Project with Gradle Files" in Android Studio.

  2. Create Test Directory: Ensure the test directory exists under app/src.

2.3 Writing Your First Unit Test

Let’s write a simple test for a method that calculates the total price in a shopping cart.

// ShoppingCart.java
public class ShoppingCart {
    public double calculateTotalPrice(double[] prices) {
        double total = 0;
        for (double price : prices) {
            total += price;
        }
        return total;
    }
}
// ShoppingCartTest.java (in app/src/test)
import org.junit.Test;
import static org.junit.Assert.assertEquals;

public class ShoppingCartTest {
    @Test
    public void testCalculateTotalPrice() {
        ShoppingCart cart = new ShoppingCart();
        double[] prices = {10.0, 20.0, 30.0};
        double expected = 60.0;
        double actual = cart.calculateTotalPrice(prices);
        assertEquals(expected, actual, 0.01);
    }
}

Run the test by right-clicking the test file in Android Studio and selecting "Run".

2.4 Real-Life Example: Testing a Shopping Cart Calculator

Imagine you’re building an e-commerce app. The ShoppingCart class calculates the total price of items. You need to test edge cases like empty carts or negative prices.

// Enhanced ShoppingCartTest.java
import org.junit.Test;
import static org.junit.Assert.assertEquals;

public class ShoppingCartTest {
    @Test
    public void testCalculateTotalPrice() {
        ShoppingCart cart = new ShoppingCart();
        double[] prices = {10.0, 20.0, 30.0};
        assertEquals(60.0, cart.calculateTotalPrice(prices), 0.01);
    }

    @Test
    public void testEmptyCart() {
        ShoppingCart cart = new ShoppingCart();
        double[] prices = {};
        assertEquals(0.0, cart.calculateTotalPrice(prices), 0.01);
    }

    @Test(expected = IllegalArgumentException.class)
    public void testNegativePrice() {
        ShoppingCart cart = new ShoppingCart();
        double[] prices = {10.0, -20.0};
        cart.calculateTotalPrice(prices);
    }
}

Modify ShoppingCart.java to handle the negative price case:

public class ShoppingCart {
    public double calculateTotalPrice(double[] prices) {
        double total = 0;
        for (double price : prices) {
            if (price < 0) {
                throw new IllegalArgumentException("Price cannot be negative");
            }
            total += price;
        }
        return total;
    }
}

2.5 Best Practices for Unit Testing

  • Test One Thing at a Time: Each test should focus on a single behavior.

  • Use Descriptive Test Names: E.g., testEmptyCart instead of test1.

  • Mock Dependencies: Use libraries like Mockito for mocking.

  • Run Tests Frequently: Integrate with CI/CD pipelines.

2.6 Exception Handling in Unit Tests

Use @Test(expected = ExceptionType.class) to test for expected exceptions, as shown in the negative price test above.

2.7 Pros, Cons, and Alternatives

  • Pros:

    • Fast execution.

    • Isolates issues in specific code units.

  • Cons:

    • Doesn’t test UI or integration.

    • Requires mocking for complex dependencies.

  • Alternatives:

    • Robolectric: For testing Android-specific components.

    • Mockito: For mocking dependencies.


3. UI Testing with Espresso

3.1 Introduction to UI Testing

UI testing ensures your app’s user interface behaves correctly. Espresso is Android’s preferred framework for UI testing.

3.2 Setting Up Espresso in Android Studio

  1. Add Dependencies:

dependencies {
    androidTestImplementation 'androidx.test:runner:1.5.2'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
}
  1. Grant Permissions: Ensure your app has necessary permissions for testing.

  2. Create Test Directory: Use androidTest under app/src.

3.3 Writing UI Tests with Espresso

Let’s test a login screen with a username, password, and login button.

<!-- activity_login.xml -->
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <EditText
        android:id="@+id/username"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Username" />
    <EditText
        android:id="@+id/password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Password"
        android:inputType="textPassword" />
    <Button
        android:id="@+id/loginButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Login" />
</LinearLayout>
// LoginActivityTest.java (in app/src/androidTest)
import androidx.test.ext.junit.rules.ActivityScenarioRule;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.typeText;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static androidx.test.espresso.matcher.ViewMatchers.withText;

@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {
    @Rule
    public ActivityScenarioRule<LoginActivity> activityRule = new ActivityScenarioRule<>(LoginActivity.class);

    @Test
    public void testLoginSuccess() {
        onView(withId(R.id.username)).perform(typeText("user"));
        onView(withId(R.id.password)).perform(typeText("pass123"));
        onView(withId(R.id.loginButton)).perform(click());
        onView(withId(R.id.statusText)).check(matches(withText("Login Successful")));
    }
}

3.4 Real-Life Example: Testing a Login Screen

For a banking app, you need to test login scenarios, including invalid inputs.

// Enhanced LoginActivityTest.java
@RunWith(AndroidJUnit4.class)
public class LoginActivityTest {
    @Rule
    public ActivityScenarioRule<LoginActivity> activityRule = new ActivityScenarioRule<>(LoginActivity.class);

    @Test
    public void testLoginSuccess() {
        onView(withId(R.id.username)).perform(typeText("user"));
        onView(withId(R.id.password)).perform(typeText("pass123"));
        onView(withId(R.id.loginButton)).perform(click());
        onView(withId(R.id.statusText)).check(matches(withText("Login Successful")));
    }

    @Test
    public void testInvalidPassword() {
        onView(withId(R.id.username)).perform(typeText("user"));
        onView(withId(R.id.password)).perform(typeText("wrong"));
        onView(withId(R.id.loginButton)).perform(click());
        onView(withId(R.id.statusText)).check(matches(withText("Invalid Credentials")));
    }
}

Modify LoginActivity.java to handle these cases:

public class LoginActivity extends AppCompatActivity {
    private EditText username, password;
    private TextView statusText;
    private Button loginButton;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_login);
        username = findViewById(R.id.username);
        password = findViewById(R.id.password);
        statusText = findViewById(R.id.statusText);
        loginButton = findViewById(R.id.loginButton);

        loginButton.setOnClickListener(v -> {
            String user = username.getText().toString();
            String pass = password.getText().toString();
            if (user.equals("user") && pass.equals("pass123")) {
                statusText.setText("Login Successful");
            } else {
                statusText.setText("Invalid Credentials");
            }
        });
    }
}

3.5 Best Practices for UI Testing

  • Test User Flows: Simulate real user interactions.

  • Use Idling Resources: Handle asynchronous operations.

  • Keep Tests Focused: Test one UI component at a time.

  • Run on Multiple Devices: Use emulators or cloud testing services.

3.6 Exception Handling in UI Tests

Handle flaky tests by using IdlingResource for asynchronous tasks like network calls.

3.7 Pros, Cons, and Alternatives

  • Pros:

    • Simulates real user interactions.

    • Integrates with Android Studio.

  • Cons:

    • Slower than unit tests.

    • Requires device/emulator.

  • Alternatives:

    • UI Automator: For cross-app testing.

    • Appium: For cross-platform testing.


4. Debugging Tools in Android Studio

4.1 Overview of Debugging Tools

Android Studio provides:

  • Breakpoints: Pause execution to inspect code.

  • Watches: Monitor variable values.

  • Logcat: View system logs.

  • Profiler: Analyze CPU, memory, and network usage.

4.2 Using Breakpoints and Watches

  1. Set a Breakpoint: Click the gutter next to a line of code.

  2. Run in Debug Mode: Click the "Debug" button.

  3. Add Watches: Right-click a variable and select "Add to Watches".

4.3 Analyzing Stack Traces

When an app crashes, check the stack trace in Logcat to identify the cause.

4.4 Real-Life Example: Debugging a Crash in a Food Delivery App

Suppose your food delivery app crashes when calculating delivery fees.

// DeliveryCalculator.java
public class DeliveryCalculator {
    public double calculateDeliveryFee(Order order) {
        return order.getDistance() * 0.5 / order.getItems().size(); // Potential crash if items is empty
    }
}

Debug the crash:

  1. Set a breakpoint in calculateDeliveryFee.

  2. Run in Debug mode.

  3. Inspect order.getItems().size() to find it’s zero, causing a division-by-zero error.

Fix the code:

public class DeliveryCalculator {
    public double calculateDeliveryFee(Order order) {
        int itemCount = order.getItems().size();
        if (itemCount == 0) {
            throw new IllegalArgumentException("Order cannot be empty");
        }
        return order.getDistance() * 0.5 / itemCount;
    }
}

4.5 Best Practices for Debugging

  • Reproduce the Issue: Ensure you can replicate the bug.

  • Use Conditional Breakpoints: Break only when specific conditions are met.

  • Log Intermediate States: Add temporary logs to trace execution.

4.6 Exception Handling During Debugging

Wrap risky operations in try-catch blocks to prevent crashes:

try {
    double fee = calculateDeliveryFee(order);
} catch (IllegalArgumentException e) {
    Log.e("DeliveryCalculator", "Error: " + e.getMessage());
}

4.7 Pros, Cons, and Alternatives

  • Pros:

    • Precise issue identification.

    • Integrated with Android Studio.

  • Cons:

    • Time-consuming for complex issues.

    • Requires understanding of app flow.

  • Alternatives:

    • Remote Debugging: For devices not connected to your machine.

    • Third-Party Tools: Like Stetho for network debugging.


5. Logging and Error Tracking

5.1 Importance of Logging

Logging helps track app behavior and diagnose issues in production.

5.2 Implementing Logging in Android

Use Android’s Log class:

import android.util.Log;

public class FitnessActivity {
    private static final String TAG = "FitnessActivity";

    public void recordWorkout(String workout) {
        Log.d(TAG, "Recording workout: " + workout);
    }
}

5.3 Using Third-Party Error Tracking Tools

Integrate Firebase Crashlytics:

  1. Add Dependency:

dependencies {
    implementation 'com.google.firebase:firebase-crashlytics:18.3.5'
}
  1. Initialize Crashlytics: Follow Firebase setup instructions.

5.4 Real-Life Example: Tracking Errors in a Fitness App

Track crashes when a user logs a workout with invalid data.

public class FitnessActivity {
    private static final String TAG = "FitnessActivity";

    public void recordWorkout(String workout) {
        try {
            if (workout == null || workout.isEmpty()) {
                throw new IllegalArgumentException("Workout cannot be empty");
            }
            Log.d(TAG, "Recording workout: " + workout);
        } catch (IllegalArgumentException e) {
            Log.e(TAG, "Error: " + e.getMessage());
            FirebaseCrashlytics.getInstance().recordException(e);
        }
    }
}

5.5 Best Practices for Logging

  • Use Appropriate Log Levels: Log.d for debug, Log.e for errors.

  • Avoid Sensitive Data: Don’t log user information.

  • Clear Log Messages: Include context for easier debugging.

5.6 Exception Handling in Logging

Always catch and log exceptions to prevent crashes.

5.7 Pros, Cons, and Alternatives

  • Pros:

    • Helps diagnose issues in production.

    • Integrates with analytics platforms.

  • Cons:

    • Overhead in large apps.

    • Privacy concerns with sensitive data.

  • Alternatives:

    • Sentry: For advanced error tracking.

    • Timber: For simplified logging.


6. Performance Optimization Techniques

6.1 Why Optimize App Performance?

Poor performance leads to slow apps, high battery usage, and user frustration.

6.2 Profiling Tools in Android Studio

  • CPU Profiler: Tracks method execution time.

  • Memory Profiler: Identifies memory leaks.

  • Network Profiler: Monitors network requests.

6.3 Optimizing CPU, Memory, and Network Usage

  • CPU: Avoid heavy computations on the main thread.

  • Memory: Use WeakReference for large objects.

  • Network: Cache responses and compress data.

6.4 Real-Life Example: Optimizing a Social Media App

Optimize image loading in a social media feed:

// ImageLoader.java
public class ImageLoader {
    public void loadImage(ImageView imageView, String url) {
        // Inefficient: Loads full-size image
        Bitmap bitmap = BitmapFactory.decodeStream(new URL(url).openStream());
        imageView.setImageBitmap(bitmap);
    }
}

Optimized version using Glide:

// Add Glide dependency
dependencies {
    implementation 'com.github.bumptech.glide:glide:4.12.0'
}

// Optimized ImageLoader.java
import com.bumptech.glide.Glide;

public class ImageLoader {
    public void loadImage(ImageView imageView, String url) {
        Glide.with(imageView.getContext())
             .load(url)
             .thumbnail(0.25f)
             .diskCacheStrategy(DiskCacheStrategy.ALL)
             .into(imageView);
    }
}

6.5 Best Practices for Performance Optimization

  • Use Background Threads: For heavy tasks.

  • Profile Regularly: Catch issues early.

  • Optimize Layouts: Avoid nested layouts.

6.6 Exception Handling in Optimization

Handle network failures gracefully:

try {
    loadImage(imageView, url);
} catch (IOException e) {
    Log.e("ImageLoader", "Failed to load image: " + e.getMessage());
    imageView.setImageResource(R.drawable.placeholder);
}

6.7 Pros, Cons, and Alternatives

  • Pros:

    • Improves user experience.

    • Reduces resource usage.

  • Cons:

    • Time-consuming to optimize.

    • May introduce complexity.

  • Alternatives:

    • Coil: Alternative to Glide for image loading.

    • OkHttp: For efficient network requests.


7. Practical Exercise: Building and Testing an App

7.1 Project Setup: A To-Do List App

Create a simple to-do list app with a single activity.

<!-- activity_main.xml -->
<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <EditText
        android:id="@+id/taskInput"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Enter task" />
    <Button
        android:id="@+id/addButton"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Add Task" />
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/taskList"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>
// MainActivity.java
public class MainActivity extends AppCompatActivity {
    private EditText taskInput;
    private RecyclerView taskList;
    private TaskAdapter adapter;
    private List<String> tasks = new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        taskInput = findViewById(R.id.taskInput);
        taskList = findViewById(R.id.taskList);
        taskList.setLayoutManager(new LinearLayoutManager(this));
        adapter = new TaskAdapter(tasks);
        taskList.setAdapter(adapter);

        findViewById(R.id.addButton).setOnClickListener(v -> {
            String task = taskInput.getText().toString();
            if (!task.isEmpty()) {
                tasks.add(task);
                adapter.notifyDataSetChanged();
                taskInput.setText("");
            }
        });
    }
}
// TaskAdapter.java
public class TaskAdapter extends RecyclerView.Adapter<TaskAdapter.ViewHolder> {
    private List<String> tasks;

    public TaskAdapter(List<String> tasks) {
        this.tasks = tasks;
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext()).inflate(android.R.layout.simple_list_item_1, parent, false);
        return new ViewHolder(view);
    }

    @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        holder.textView.setText(tasks.get(position));
    }

    @Override
    public int getItemCount() {
        return tasks.size();
    }

    static class ViewHolder extends RecyclerView.ViewHolder {
        TextView textView;

        ViewHolder(View itemView) {
            super(itemView);
            textView = itemView.findViewById(android.R.id.text1);
        }
    }
}

7.2 Adding Unit Tests

Test the task addition logic.

// TaskManager.java
public class TaskManager {
    private List<String> tasks = new ArrayList<>();

    public void addTask(String task) {
        if (task == null || task.isEmpty()) {
            throw new IllegalArgumentException("Task cannot be empty");
        }
        tasks.add(task);
    }

    public List<String> getTasks() {
        return tasks;
    }
}
// TaskManagerTest.java
import org.junit.Test;
import static org.junit.Assert.assertEquals;

public class TaskManagerTest {
    @Test
    public void testAddTask() {
        TaskManager manager = new TaskManager();
        manager.addTask("Buy groceries");
        assertEquals(1, manager.getTasks().size());
        assertEquals("Buy groceries", manager.getTasks().get(0));
    }

    @Test(expected = IllegalArgumentException.class)
    public void testAddEmptyTask() {
        TaskManager manager = new TaskManager();
        manager.addTask("");
    }
}

7.3 Adding UI Tests

Test adding a task via the UI.

// MainActivityTest.java
@RunWith(AndroidJUnit4.class)
public class MainActivityTest {
    @Rule
    public ActivityScenarioRule<MainActivity> activityRule = new ActivityScenarioRule<>(MainActivity.class);

    @Test
    public void testAddTask() {
        onView(withId(R.id.taskInput)).perform(typeText("Buy groceries"));
        onView(withId(R.id.addButton)).perform(click());
        onView(withText("Buy groceries")).check(matches(isDisplayed()));
    }
}

7.4 Debugging a Performance Issue

Suppose the RecyclerView lags when adding many tasks. Use the CPU Profiler to identify the issue (e.g., notifyDataSetChanged is inefficient). Optimize by using notifyItemInserted:

// MainActivity.java (optimized)
findViewById(R.id.addButton).setOnClickListener(v -> {
    String task = taskInput.getText().toString();
    if (!task.isEmpty()) {
        tasks.add(task);
        adapter.notifyItemInserted(tasks.size() - 1);
        taskInput.setText("");
    }
});

7.5 Implementing Logging

Add logging to track task additions.

// MainActivity.java
public class MainActivity extends AppCompatActivity {
    private static final String TAG = "MainActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ... existing code ...
        findViewById(R.id.addButton).setOnClickListener(v -> {
            String task = taskInput.getText().toString();
            try {
                if (!task.isEmpty()) {
                    tasks.add(task);
                    adapter.notifyItemInserted(tasks.size() - 1);
                    taskInput.setText("");
                    Log.d(TAG, "Task added: " + task);
                } else {
                    throw new IllegalArgumentException("Task cannot be empty");
                }
            } catch (IllegalArgumentException e) {
                Log.e(TAG, "Error: " + e.getMessage());
                FirebaseCrashlytics.getInstance().recordException(e);
            }
        });
    }
}

7.6 Optimizing the App

Use the Memory Profiler to check for memory leaks in the RecyclerView. Ensure TaskAdapter doesn’t hold references to old views.


8. Conclusion

8.1 Recap of Key Concepts

This chapter covered:

  • Writing unit tests with JUnit.

  • Creating UI tests with Espresso.

  • Debugging with Android Studio tools.

  • Implementing logging and error tracking.

  • Optimizing app performance.

8.2 Next Steps in Android Development

Explore advanced topics like:

  • Integration testing with Robolectric.

  • Continuous integration with Jenkins or GitHub Actions.

  • Advanced performance optimization with LeakCanary.

By mastering testing and debugging, you’ll build robust, high-performing Android apps ready for real-world use.

No comments:

Post a Comment

Thanks for your valuable comment...........
Md. Mominul Islam