Why is it Challenging to Achieve 100% Test Coverage?

Category
Stack Overflow
Author
Julie NovakJulie Novak

Testing is an essential part of modern software development. It helps developers ensure the application behaves as expected and meets the required quality standards. However, the complexity of modern software applications makes it challenging to achieve 100% test coverage.

What is Test Coverage?

The test coverage is a metric applied in software testing to determine how much of the code is tested. Test coverage can be measured through different methods, such as considering the number of code lines tested during the testing phase, the number of functions called, or some conditional statements within the code. Higher test coverage makes the application very reliable since it has undergone rigorous testing before being exposed to the production environment.

Test Coverage

There are several tools available that can help measure test coverage. Some of the most popular include:

However, achieving 100% test coverage is challenging since we need to cover every possible scenario. For example, consider the function below, which adds two numbers and returns the result.

def add_numbers(a, b):
    return a + b

To fully test this simple addition function, we need several tests to ensure it handles scenarios like adding positive numbers, negative numbers, and zero:

import unittest

class TestAddNumbers(unittest.TestCase):
    def test_add_positive_numbers(self):
        self.assertEqual(add_numbers(2, 3), 5)

    def test_add_negative_numbers(self):
        self.assertEqual(add_numbers(-1, -1), -2)

    def test_add_mixed_numbers(self):
        self.assertEqual(add_numbers(-1, 2), 1)

    def test_add_zero(self):
        self.assertEqual(add_numbers(0, 0), 0)
        self.assertEqual(add_numbers(0, 5), 5)

# Running the tests
if name == 'main':
    unittest.main()

Challenges in Achieving 100% Test Coverage

Achieving 100% test coverage for the above add_numbers function was straightforward since it is a basic function. But it won’t be this easy in real-world applications. Here are some common challenges you will face when trying to achieve 100% test coverage:

1. Complexity of Software

Modern software applications are very complex. As a result, interactions between components become more complicated, making it harder to test every scenario. The number of paths to test grows exponentially due to conditional logic, input combinations, and dependencies.

For example, consider the function below, which processes user data and stores it in a database. Testing this function 100% can be complex since it interacts with an external database service.

def process_user_data(user_data, db):
    if validate_user_data(user_data):
        transformed_data = transform_user_data(user_data)
        db.save(transformed_data)
        return "Success"
    else:
        return "Validation Error"

def validate_user_data(user_data):
    return True

def transform_user_data(user_data):
    return user_data

In such situations, you can use mocking or stubbing techniques to isolate the function and test its behavior.

import unittest
from unittest.mock import MagicMock

class TestProcessUserData(unittest.TestCase):
    def test_process_user_data_success(self):
        mock_db = MagicMock()
        user_data = {"name": "Micheal Knight", "age": 35}
        result = process_user_data(user_data, mock_db)
        self.assertEqual(result, "Success")
        mock_db.save.assert_called_once_with(user_data)
    
    def test_process_user_data_validation_error(self):
        mock_db = MagicMock()
        invalid_user_data = {}
        with unittest.mock.patch('your_module.validate_user_data', return_value=False):
            result = process_user_data(invalid_user_data, mock_db)
        self.assertEqual(result, "Validation Error")
        mock_db.save.assert_not_called()

if name == 'main':
    unittest.main()

2. Diminishing Returns

In most cases, increasing the test coverage from 0% to 90% is much easier than improving it from 90% to 100%. The last few percentages often involve rare or hypothetical edge cases that may never occur. The benefits of covering such cases often do not justify the time and effort required to achieve them.

Diminishing Returns

As a best practice, focus on testing the most critical parts based on risk assessment and business impact. Prioritize areas that are more likely to fail or cause significant issues.

3. Dynamic and Evolving Codebases

Most development teams now use Agile methodologies to continuously deliver new features to the market. Although this is very good for meeting customer needs and staying competitive, these rapid changes make it impossible to maintain 100% test coverage. Achieving 100% test coverage in one cycle can be nullified by the next set of changes.

For example, assume you have initially implemented a user login with email and password with 100% test coverage. Now, the customer wants to modify the login to allow users to log in with SSO. This update can significantly reduce test coverage by introducing new code lines while making existing code obsolete.

4. False Sense of Security

Getting to 100% test coverage does not necessarily mean your application has been tested 100%. You need to ensure that test cases are meaningful and cover all the critical functionalities rather than writing test cases to improve the percentage.

For example, a project might have 100% test coverage with many tests checking trivial aspects like getters and setters while leaving out complex business logic. For instance, consider the following code:

class Calculator:
    def init(self):
        self.result = 0

    def add(self, a, b):
        return a + b

    def subtract(self, a, b):
        return a - b

    def get_result(self):
        return self.result

Here, you can easily achieve 100% coverage by using three simple tests to cover the get_result(), add(), and subtract() functions. However, these tests don’t consider edge cases or more complex scenarios, such as handling large numbers or negative inputs.

import unittest

class TestCalculator(unittest.TestCase):
    def test_get_result(self):
        calc = Calculator()
        self.assertEqual(calc.get_result(), 0)  # Trivial test

    def test_add(self):
        calc = Calculator()
        self.assertEqual(calc.add(2, 3), 5)  # Simple test

    def test_subtract(self):
        calc = Calculator()
        self.assertEqual(calc.subtract(5, 3), 2)  # Simple test

if name == 'main':
    unittest.main()

5. Limitations of Testing Tools and Techniques

Like any other tools and techniques, testing tools and techniques have certain limitations. For example, they may not be able to fully capture certain kinds of behavior, especially when dealing with asynchronous operations, multithreading, or interactions with external services.

Consider a function in a web application that processes user data and sends it to an external API asynchronously.

import requests
import threading

def process_and_send_data(data):
    processed_data = process_data(data)
    thread = threading.Thread(target=send_data_to_api, args=(processed_data,))
    thread.start()

def process_data(data):
    # Assume this function processes the data
    return data

def send_data_to_api(data):
    response = requests.post('https://api.example.com/data', json=data)
    return response.status_code

Even if you use mocking, standard testing tools might struggle to test the behavior of a function accurately under various network conditions or handle the timing issues inherent in asynchronous operations. So, you need to use advanced testing techniques in such situations:

  • Combine multiple test types like unit, integration, and end-to-end tests.
  • Use tools that support asynchronous and concurrent testing, such as pytest-asyncio for Python.
  • Use mocking frameworks to simulate interactions with external services.