Testing and Debugging Cheat Sheet

Testing and Debugging Cheat Sheet

Welcome back, Python enthusiast! Whether you're just starting your testing journey or looking to sharpen your debugging skills, this comprehensive cheat sheet is here to guide you. Testing and debugging are essential parts of the development process—they help you write robust, reliable code and catch those pesky bugs before they reach production. Let's dive right in!

Writing Tests with unittest

Python's built-in unittest module is a powerful tool for writing and running tests. It follows the xUnit style, which might be familiar if you've used testing frameworks in other languages. Here's a quick example to get you started:

import unittest

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

class TestMathOperations(unittest.TestCase):
    def test_add_positive_numbers(self):
        self.assertEqual(add(2, 3), 5)

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

if __name__ == '__main__':
    unittest.main()

Run this script, and you'll see whether your tests pass or fail. The unittest.TestCase class provides many assertion methods like assertEqual, assertTrue, assertRaises, and more. Always structure your tests to cover both typical use cases and edge cases. This helps ensure your code behaves correctly under various conditions.

When organizing tests, it's common to have one test class per module or functionality you're testing. Each method in the class should test a specific aspect of your code. For instance, if you're testing a function that processes user input, you might have separate tests for valid input, invalid input, and boundary cases.

Assertion Method Purpose
assertEqual(a, b) Checks if a == b
assertTrue(x) Checks if x is True
assertFalse(x) Checks if x is False
assertRaises(Exc, func) Checks if func raises exception Exc
assertIn(a, b) Checks if a is in b
assertIsNone(x) Checks if x is None

Remember to keep your tests isolated and independent. Each test should set up its own data and not rely on the state from previous tests. This prevents unexpected interactions and makes it easier to pinpoint failures.

pytest: A More Pythonic Approach

While unittest is great, many developers prefer pytest for its simplicity and powerful features. You can install it via pip (pip install pytest), and it requires less boilerplate code. Here's the same test written with pytest:

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

def test_add_positive_numbers():
    assert add(2, 3) == 5

def test_add_negative_numbers():
    assert add(-1, -1) == -2

Notice how there's no need to create a class—just write functions that start with test_. pytest will automatically discover and run these tests. You can run them by simply executing pytest in your terminal from the project directory.

pytest also supports fixtures, which are functions that run before your tests to set up resources. For example:

import pytest

@pytest.fixture
def sample_list():
    return [1, 2, 3]

def test_list_length(sample_list):
    assert len(sample_list) == 3

The sample_list fixture provides a fresh list to any test that requests it as an argument. Fixtures help you avoid repetitive setup code and keep your tests clean and maintainable.

Another powerful feature of pytest is parameterized tests, which allow you to run the same test with multiple inputs:

import pytest

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, -1, -2),
    (0, 0, 0),
])
def test_add_various_inputs(a, b, expected):
    assert add(a, b) == expected

This runs test_add_various_inputs three times with different sets of parameters, reducing duplication and making it easy to add new test cases.

Debugging with pdb

When tests fail, it's time to debug. Python's built-in debugger, pdb, is your best friend for interactive debugging. You can insert a breakpoint in your code by adding import pdb; pdb.set_trace() at the point where you want to start debugging. When the code reaches that line, it will pause and drop you into an interactive session.

For example:

def tricky_function(x):
    result = x * 2
    import pdb; pdb.set_trace()  # Breakpoint here
    return result + 1

print(tricky_function(5))

When you run this script, it will stop at the breakpoint, and you can inspect variables, step through code, and more. Here are some useful pdb commands:

  • n (next): Execute the current line and move to the next one.
  • s (step): Step into a function call.
  • c (continue): Continue execution until the next breakpoint.
  • l (list): Show the code around the current line.
  • p (print): Print the value of an expression.
  • q (quit): Quit the debugger.

In Python 3.7 and above, you can use the built-in breakpoint() function instead of importing pdb. It does the same thing but is cleaner:

def tricky_function(x):
    result = x * 2
    breakpoint()  # Modern breakpoint
    return result + 1

Using pdb effectively can save you hours of frustration by letting you inspect your program's state at critical points.

Common Testing Scenarios

Let's look at some common scenarios you'll encounter when writing tests and how to handle them.

Testing functions that raise exceptions is important to ensure error conditions are handled properly. With unittest, use assertRaises:

import unittest

def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

class TestDivide(unittest.TestCase):
    def test_divide_by_zero(self):
        with self.assertRaises(ValueError):
            divide(10, 0)

With pytest, you can use pytest.raises:

import pytest

def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(10, 0)

Another common scenario is testing code that interacts with external resources, like databases or web APIs. Instead of hitting the real resource, which can be slow and unreliable, use mocking. The unittest.mock module (or pytest-mock for pytest) lets you replace parts of your system with mock objects.

Here's an example using unittest.mock:

from unittest.mock import patch
import unittest

def get_data_from_api():
    # Imagine this calls an external API
    return "real data"

class TestApiCall(unittest.TestCase):
    @patch('__main__.get_data_from_api')
    def test_mock_api(self, mock_get):
        mock_get.return_value = "mocked data"
        result = get_data_from_api()
        self.assertEqual(result, "mocked data")

The @patch decorator replaces get_data_from_api with a mock object for the duration of the test. You can set its return value or side effects to simulate different responses.

Mocking Method Purpose
return_value Set the value returned by the mock
side_effect Set exception or dynamic return values
assert_called_once Check if mock was called exactly once
assert_called_with Check arguments of the last call

Mocking is especially useful for isolating the code under test from its dependencies, making your tests faster and more reliable.

Logging for Debugging

Sometimes, debugging with pdb isn't practical, especially in production environments. That's where logging comes in. Python's logging module lets you add log messages to your code that can be turned on or off depending on the severity level.

Configure logging at the start of your application:

import logging

logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')

Then, add log statements throughout your code:

def complex_calculation(x, y):
    logging.debug(f"Starting calculation with x={x}, y={y}")
    result = x * y
    if result < 0:
        logging.warning("Result is negative")
    logging.debug(f"Calculation result: {result}")
    return result

By setting the log level to DEBUG, you'll see all messages. In production, you might set it to WARNING or higher to reduce noise. Logging provides a non-intrusive way to track your program's behavior without stopping execution.

You can also direct logs to different outputs, like files:

logging.basicConfig(filename='app.log', level=logging.DEBUG)

This is invaluable for post-mortem debugging when you need to understand what happened after a crash.

Handling Flaky Tests

Flaky tests are tests that sometimes pass and sometimes fail without any changes to the code. They erode trust in your test suite and should be fixed ASAP. Common causes include reliance on external services, timing issues, or shared state.

To avoid flaky tests:

  • Isolate tests: Ensure each test sets up its own data and doesn't depend on others.
  • Use mocking: Replace unreliable dependencies with predictable mocks.
  • Avoid sleep calls: Instead of time.sleep(), use polling or events if waiting for a condition.
  • Run tests multiple times: Tools like pytest-flakefinder can run tests repeatedly to expose flakiness.

If you encounter a flaky test, try to reproduce it consistently. Add more logging around the failing area to understand what's happening. Sometimes, increasing timeouts or adding retries can help, but be cautious—this might mask underlying issues.

Debugging in IDEs

If you're using an IDE like PyCharm or VSCode, you have powerful graphical debugging tools at your disposal. These allow you to set breakpoints by clicking in the margin, inspect variables in a panel, and step through code with buttons instead of typing commands.

In PyCharm, for example, you can right-click and select "Debug" instead of "Run" to start a debugging session. You can then hover over variables to see their values or use the debug console to execute expressions.

IDE debuggers often provide a more intuitive experience than command-line pdb, especially for complex codebases. They also integrate with your test runner, letting you debug failing tests directly.

Testing Best Practices

To wrap up, here are some best practices to keep in mind as you write and maintain your tests:

  • Write tests before or alongside code (Test-Driven Development can be very effective).
  • Keep tests small and focused on one behavior.
  • Use descriptive test names that indicate what is being tested.
  • Run your tests frequently to catch regressions early.
  • Aim for high code coverage but don't sacrifice quality for quantity.
  • Refactor tests when you refactor code to keep them maintainable.
  • Treat test code with the same care as production code.

Remember, the goal of testing is not just to find bugs but to prevent them. A good test suite gives you confidence to make changes and add features without breaking existing functionality.

Happy testing and debugging! May your code be ever bug-free and your tests always green.