Photo by Daniil Komov / Unsplash

Testing Anti-Patterns You Need to Stop Using

python May 24, 2026

We write tests to sleep better at night. We write them to catch regressions, document behavior, and confidently ship features to production - even on a Friday afternoon.

But what happens when your safety net is actually a trap?

Bad tests are worse than no tests. They give you a false sense of security, grind your CI/CD pipeline to a halt with flaky failures, and turn simple code refactors into week-long debugging nightmares. Over the course of my career, I've seen test suites slowly devolve from a developer's best friend into their worst enemy.

In this article, we'll explore 9 insidious testing anti-patterns that might be lurking in your codebase right now, why they are dangerous, and exactly how to fix them using modern Python and pytest.


Anti-Pattern 1: Coverage is everything

One of the most common anti-patterns is optimizing solely for test coverage. 100% test coverage does not mean anything if the proper functionality is not tested or if assertions are weak or non-existent. Good coverage does not ensure that the code is without any defects or bugs.

The Problem: Developers might write tests just to execute the code paths and satisfy a coverage threshold without actually asserting the expected outcomes or side effects.

How to Fix it: Focus on behavioral testing. Ensure every test has meaningful assertions. Test edge cases and negative scenarios, not just the happy path.

# Example of the Coverage Anti-Pattern
def calculate_discount(price, discount_percent):
    """Calculates final price after discount."""
    if discount_percent < 0 or discount_percent > 100:
        raise ValueError("Invalid discount percentage")

    discount_amount = price * (discount_percent / 100)
    final_price = price - discount_amount
    return final_price

# Anti-pattern test: Achieves 100% coverage but asserts nothing!
def test_calculate_discount_coverage_only():
    # Covers the happy path
    calculate_discount(100, 20)

    # Covers the error path
    try:
        calculate_discount(100, 150)
    except ValueError:
        pass

.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ .::test_calculate_discount_coverage_only

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

Even though the above test achieves 100% coverage for the calculate_discount function, it does absolutely zero validation. If we accidentally changed the logic to price + discount_amount, the tests would still pass! Here is how we should write meaningful tests:

import pytest


# Proper tests with meaningful assertions
def test_calculate_discount_happy_path():
    assert calculate_discount(100, 20) == 80.0
    assert calculate_discount(50, 10) == 45.0
    assert calculate_discount(100, 0) == 100.0


def test_calculate_discount_invalid_percentage():
    with pytest.raises(ValueError, match="Invalid discount percentage"):
        calculate_discount(100, 110)

    with pytest.raises(ValueError, match="Invalid discount percentage"):
        calculate_discount(100, -10)


Anti-Pattern 2: Not emphasizing response structures

When building APIs or services, the structure of the response is a critical contract with the consumers. A common anti-pattern is testing only the internal logic of a handler while neglecting to verify that the final output payload exactly matches the expected schema.

The Problem: Mocking internal database calls or dependencies and asserting they were called is good, but it doesn't guarantee the API will return the JSON structure the client expects. Refactoring internal code might accidentally change the response keys or types.

Take this example, for instance, where an intentional (or accidental) bug causes the response to always use V1Serializer regardless of the feature flag state.

class ConfigLoader:
    def get_config(self, config: str):
        """Get config from config.yaml
        """
        ...

class ModelManager:
    def get(self, *args, **kwargs):
        pass
    def filter(self, *args, **kwargs):
        pass

class Model:
    objects = ModelManager()

class V1Serializer:
    """v1 serializer"""
    def __init__(self, **kwargs):
        self.data = kwargs

class V2Serializer:
    """V2 Serializer"""
    def __init__(self, **kwargs):
        self.data = kwargs

def structured_response(*args, **kwargs):
    config_loader = ConfigLoader() 

    # boilerplate code to fetch data from a Django model
    try:
        data = Model.objects.get()
    except Exception as e:
        data = {}

    if config_loader.get_config("V2_FEATURE_FLAG"):
        serialized_data = V2Serializer(**data)
    serialized_data = V1Serializer(**data)

    return serialized_data

Did you notice the bug in the code above? serialized_data = V1Serializer(**data) will always execute and overwrite serialized_data, completely ignoring the feature flag's effect.

If our tests only mock Model.objects.filter() and ConfigLoader, they might miss this bug entirely if they don't explicitly assert the structure of the returned serialized_data.

Here is a typical anti-pattern test:

# Anti-pattern test: Mocks heavily but doesn't check the output structure
def test_structured_response_anti_pattern():
    from unittest.mock import patch

    with patch.object(ConfigLoader, "get_config") as mock_config,           
        patch.object(Model.objects, "get") as mock_get:
  
            mock_get.return_value = {"id": 1, "name": "Test"}
    
            # Setup mock to simulate V2 flag being true
            mock_config.return_value = True
    
            response = structured_response()
    
            # We assert dependencies are called, but we don't verify the structure!
            mock_get.assert_called_once()
            mock_config.assert_called_once_with("V2_FEATURE_FLAG")
            assert response is not None


.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_structured_response_anti_pattern

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

How to Fix it: Explicitly test the response schemas or structures. Validate the dictionary keys or use tools like Pydantic or Marshmallow schema validation in tests.

# Proper tests emphasizing response structures

def test_structured_response_v1():
    from unittest.mock import patch

    # Setup mock context...
    with patch.object(ConfigLoader, "get_config") as mock_config,          
        patch.object(Model.objects, "get") as mock_get:

            mock_get.return_value = {"id": 1, "name": "Test"}
    
            # Simulate flag OFF
            mock_config.return_value = False
    
            response = structured_response()
    
            # In a real app, you'd assert against the schema output
            assert isinstance(response, V1Serializer)

def test_structured_response_v2():
    from unittest.mock import patch

    with patch.object(ConfigLoader, "get_config") as mock_config,
        patch.object(Model.objects, "get") as mock_get:

            mock_get.return_value = {"id": 1, "name": "Test"}
    
            # Simulate flag ON
            mock_config.return_value = True
    
            response = structured_response()
    
            # This test will rightfully FAIL with our buggy code!
            # Because the code overwrites with V1Serializer
            assert isinstance(response, V2Serializer), "Response should be V2 format!"



.F                                                                       [100%]
=================================== FAILURES ===================================
_________________________ test_structured_response_v2 __________________________

    def test_structured_response_v2():
        from unittest.mock import patch

        with patch.object(ConfigLoader, "get_config") as mock_config,       
            patch.object(Model.objects, "get") as mock_get:

                mock_get.return_value = {"id": 1, "name": "Test"}
    
                # Simulate flag ON
                mock_config.return_value = True
    
                response = structured_response()
    
                # This test will rightfully FAIL with our buggy code!
                # Because the code overwrites with V1Serializer
>           assert isinstance(response, V2Serializer), "Response should be V2 format!"
E           AssertionError: Response should be V2 format!
E           assert False
E            +  where False = isinstance(<__main__.V1Serializer object at 0x2f241c0>, V2Serializer)

marimo://../../notebook.py#cell_id=Hstk:33: AssertionError
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_structured_response_v1

Summary:
Total: 2, Passed: 1, Failed: 1, Errors: 0, Skipped: 0
=========================== short test summary info ============================
FAILED notebook.py::test_structured_response_v2 - AssertionError: Response should be V2 format!

Anti-Pattern 3: Testing Implementation Details

Testing implementation details - such as verifying internal variable states or calling private methods directly—leads to brittle tests that break whenever the code is refactored, even if the external behavior remains exactly the same.

The Problem: When you mock internal function calls heavily or assert on the state of internal variables, your tests become tightly coupled to how the code is written, not what it achieves. This discourages refactoring and creates high maintenance overhead.

How to Fix it: Test the public interface. Send inputs and verify outputs or observable side effects (like database writes or external API calls). Treat the function under test as a black box as much as possible.

# Code Example: A class with private methods

class OrderProcessor:
    def process_order(self, order_id, amount):
        if self._validate_amount(amount):
            self._save_to_db(order_id, amount)
            return True
        return False

    def _validate_amount(self, amount):
        # Implementation detail: how validation happens
        return amount > 0

    def _save_to_db(self, order_id, amount):
        # Implementation detail: how it's saved
        pass
# Anti-pattern test: Testing implementation details (brittle)

def test_process_order_anti_pattern():
    from unittest.mock import patch

    processor = OrderProcessor()

    # We mock internal methods to test process_order!
    # If we rename _validate_amount to _is_valid, this test breaks.
    with patch.object(processor, '_validate_amount', return_value=True) as mock_val,          patch.object(processor, '_save_to_db') as mock_save:

        result = processor.process_order(123, 100)

        assert result is True
        mock_val.assert_called_once_with(100)
        mock_save.assert_called_once_with(123, 100)


.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ .::test_process_order_anti_pattern

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

Instead of mocking the internal _validate_amount method, we should test the public behavior based on inputs.

# Proper tests: Testing the public interface

def test_process_order_success():
    from unittest.mock import patch

    processor = OrderProcessor()

    # We only mock the boundary (the database interaction)
    with patch.object(processor, '_save_to_db') as mock_save:
        # Valid input should return True
        result = processor.process_order(123, 100)

        assert result is True
        mock_save.assert_called_once_with(123, 100)

def test_process_order_invalid_amount():
    from unittest.mock import patch

    processor = OrderProcessor()

    with patch.object(processor, '_save_to_db') as mock_save:
        # Invalid input should return False and NOT call DB
        result = processor.process_order(123, -50)

        assert result is False
        mock_save.assert_not_called()


..                                                                       [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_process_order_invalid_amount
✓ notebook.py::test_process_order_success

Summary:
Total: 2, Passed: 2, Failed: 0, Errors: 0, Skipped: 0

Anti-Pattern 4: The "God Test" or "Monolith Test"

A single test case that tests an entire workflow, makes dozens of assertions, and covers multiple distinct behaviors of a component.

The Problem: When a monolithic test fails, it is extremely difficult to isolate the root cause because the failure could be anywhere in the massive test block. Once an assertion fails, the remaining assertions are skipped, so you only get partial feedback about the system's state. These tests also tend to be long, hard to read, and difficult to maintain.

How to Fix it: Follow the "One concept per test" principle. Break down the large test into smaller, focused tests that each verify a single behavior or condition. Use pytest fixtures to share setup logic across these smaller tests.

# Example System: User Registration

class UserService:
    def register_user(self, email, password):
        if not email or "@" not in email:
            raise ValueError("Invalid email format")
        if len(password) < 8:
            raise ValueError("Password too short")

        user_data = {"email": email, "is_active": True}
        self._send_welcome_email(email)
        return user_data

    def _send_welcome_email(self, email):
        pass

# Anti-pattern test: The God Test
# Note: pytest is already imported

def test_user_registration_everything():
    service = UserService()

    # Test valid registration
    user = service.register_user("[email protected]", "securepass123")
    assert user["email"] == "[email protected]"
    assert user["is_active"] is True

    # Test invalid email
    with pytest.raises(ValueError):
        service.register_user("invalid_email", "securepass123")

    # Test invalid password
    with pytest.raises(ValueError):
        service.register_user("[email protected]", "short")

    # If the first assert fails, we never know if the exception tests would pass!


.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_user_registration_everything

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

By splitting this into multiple tests, we get clear, isolated feedback for each failure. We can use a fixture to provide the UserService instance.

# Proper tests: One concept per test

@pytest.fixture
def user_service():
    return UserService()

def test_register_user_success_returns_user_data(user_service):
    user = user_service.register_user("[email protected]", "securepass123")

    assert user["email"] == "[email protected]"
    assert user["is_active"] is True

def test_register_user_rejects_invalid_email(user_service):
    with pytest.raises(ValueError, match="Invalid email format"):
        user_service.register_user("invalid", "securepass123")

def test_register_user_rejects_short_password(user_service):
    with pytest.raises(ValueError, match="Password too short"):
        user_service.register_user("[email protected]", "short")

...                                                                      [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_register_user_rejects_invalid_email
✓ notebook.py::test_register_user_rejects_short_password
✓ notebook.py::test_register_user_success_returns_user_data

Summary:
Total: 3, Passed: 3, Failed: 0, Errors: 0, Skipped: 0

Anti-Pattern 5: State Leakage / The "Order-Dependent" Test

Tests that modify global state (like environment variables, singletons, or class-level variables) without restoring it can cause subsequent tests to fail mysteriously depending on the order they are run. This leads to flaky tests that are incredibly frustrating to debug.

The Problem: If Test A modifies a global variable and Test B relies on the default value of that variable, Test B will pass if run alone, but fail if run after Test A.

How to Fix it: Use pytest's built-in monkeypatch fixture, which automatically handles the teardown and restoration of environment variables and object attributes after the test completes.

import os

# Example System: Config reliant on Environment Variable
def get_database_url():
    return os.environ.get("DATABASE_URL", "sqlite:///default.db")

# Anti-pattern test: Modifying state without cleanup

def test_get_database_url_custom():
    # BAD: This changes the environment for ALL subsequent tests!
    os.environ["DATABASE_URL"] = "postgres://user:pass@localhost/db"
    assert get_database_url() == "postgres://user:pass@localhost/db"


def test_get_database_url_default():
    # If run after the above test, this will FAIL!
    # os.environ is still polluted.
    # assert get_database_url() == "sqlite:///default.db" # This would fail
    pass

..                                                                       [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_get_database_url_custom
✓ notebook.py::test_get_database_url_default

Summary:
Total: 2, Passed: 2, Failed: 0, Errors: 0, Skipped: 0

By using monkeypatch, we ensure that any mutations to os.environ are completely reverted the moment the test finishes.

# Proper tests: Using monkeypatch for automatic cleanup

def test_get_database_url_custom_proper(monkeypatch):
    # GOOD: monkeypatch safely sets the env var and restores it later
    monkeypatch.setenv("DATABASE_URL", "postgres://user:pass@localhost/db")
    assert get_database_url() == "postgres://user:pass@localhost/db"

def test_get_database_url_default_proper(monkeypatch):
    # GOOD: We can safely delete or assume default state here
    monkeypatch.delenv("DATABASE_URL", raising=False)
    assert get_database_url() == "sqlite:///default.db"


..                                                                       [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_get_database_url_custom_proper
✓ notebook.py::test_get_database_url_default_proper

Summary:
Total: 2, Passed: 2, Failed: 0, Errors: 0, Skipped: 0

Anti-Pattern 6: Testing the Mock (The Tautological Test)

This occurs when you heavily mock out dependencies, and your assertion simply verifies that the mock returned exactly what you told it to return.

The Problem: You end up testing your mocking framework, not your business logic. If the underlying logic is just a simple pass-through to a dependency, the test provides zero value and only adds maintenance overhead.

How to Fix it: Ensure there is actual business logic (like data transformations, conditional branching, or error handling) between the mock and your assertion. If the function is a pure pass-through, consider an integration test instead.

# Example System: A simple wrapper

class PaymentGateway:
    def charge(self, amount):
        return f"Charged {amount}"


def process_payment(gateway, amount):
    # Pure pass-through, no real business logic here
    return gateway.charge(amount)

# Anti-pattern test: The Tautological Test

def test_process_payment_tautology():
    from unittest.mock import MagicMock

    mock_gateway = MagicMock()
    # We tell the mock what to return
    mock_gateway.charge.return_value = "Charged 50"

    # We execute the function
    result = process_payment(mock_gateway, 50)

    # We assert it returned what we just told it to return!
    assert result == "Charged 50"
    mock_gateway.charge.assert_called_once_with(50)


.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_process_payment_tautology

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

A better approach is testing functions that actually contain logic, and ensuring the inputs to the mocks or the transformations are verified.

# Proper testing: Testing logic, not mocks

def process_payment_with_fee(gateway, amount):
    # Now there is logic: a 5% fee calculation
    total_amount = amount * 1.05
    return gateway.charge(total_amount)


def test_process_payment_with_fee():
    from unittest.mock import MagicMock

    mock_gateway = MagicMock()

    # We don't care about the return value as much as the input logic
    process_payment_with_fee(mock_gateway, 100)

    # Asserting the business logic (the 5% fee) was correctly applied before calling the dependency
    mock_gateway.charge.assert_called_once_with(105.0)


Anti-Pattern 7: Hardcoding Dynamic Values (The "Time Traveler" Test)

Tests that execute code involving datetime.now(), uuid.uuid4(), or random number generators make it impossible to write exact, deterministic assertions.

The Problem: Because the value changes every millisecond (or every run), developers often resort to loose assertions like assert type(result) == str or tests that fail intermittently on boundary conditions (like running exactly at midnight).

How to Fix it: Use dependency injection to pass the dynamic value into the function, or use libraries like freezegun or pytest plugins (pytest-freezegun) to mock the system clock.

from datetime import datetime, timedelta

# Example System: Expiration Check
def is_expired(expiration_date):
    # BAD: Hardcoded dynamic dependency inside the function
    return datetime.now() > expiration_date

# Anti-pattern test: Hard to assert accurately

def test_is_expired_anti_pattern():
    # We have to guess the timing, which can lead to race conditions in CI/CD
    future_date = datetime.now() + timedelta(seconds=1)

    # This might fail if the system is extremely slow
    assert is_expired(future_date) is False


.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_is_expired_anti_pattern

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

By refactoring to accept current_time as a parameter (Dependency Injection), the function becomes pure and easily testable.

# Proper tests: Dependency Injection

def is_expired_pure(expiration_date, current_time=None):
    if current_time is None:
        current_time = datetime.now()
    return current_time > expiration_date

def test_is_expired_pure():
    fixed_now = datetime(2025, 1, 1, 12, 0, 0)

    future_date = datetime(2025, 1, 1, 12, 0, 1)
    past_date = datetime(2025, 1, 1, 11, 59, 59)

    # Fully deterministic assertions!
    assert is_expired_pure(future_date, current_time=fixed_now) is False
    assert is_expired_pure(past_date, current_time=fixed_now) is True


Anti-Pattern 8: Over-Mocking External Dependencies

Directly mocking external libraries like requests.get or boto3 inside your unit tests.

The Problem: If the external library changes its API (e.g., they rename a parameter from url to endpoint), your test will still pass because you mocked it! But your application will crash in production.

How to Fix it: Write a thin wrapper/adapter class around external libraries. Test your adapter using tools designed for that specific boundary (like responses or vcrpy for HTTP), and then mock your adapter in the rest of your unit tests.

import requests

# Anti-pattern: Business logic tangled with external library

def fetch_user_data(user_id):
    response = requests.get(f"https://api.example.com/users/{user_id}")
    if response.status_code == 200:
        return response.json()
    return None

# Anti-pattern test: Mocking the third-party library directly

def test_fetch_user_data():
    from unittest.mock import patch

    with patch("requests.get") as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"id": 1, "name": "Alice"}

        # If `requests.get` changes how it handles the URL parameter in a future version,
        # this test will not catch it.
        result = fetch_user_data(1)
        assert result["name"] == "Alice"

.                                                                        [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_fetch_user_data

Summary:
Total: 1, Passed: 1, Failed: 0, Errors: 0, Skipped: 0

Instead, create a clearly defined adapter interface.

# Proper approach: Adapter Pattern

class HttpClient:
    def get(self, url):
        # We would test this class using a library like `responses` 
        # to mock the actual HTTP socket, not the library interface.
        import requests
        return requests.get(url)

def fetch_user_data_proper(user_id, http_client: HttpClient):
    response = http_client.get(f"https://api.example.com/users/{user_id}")
    if response.status_code == 200:
        return response.json()
    return None

def test_fetch_user_data_proper():
    from unittest.mock import MagicMock

    # We mock OUR interface, not the third-party library
    mock_client = MagicMock(spec=HttpClient)
    mock_client.get.return_value.status_code = 200
    mock_client.get.return_value.json.return_value = {"id": 1, "name": "Alice"}

    result = fetch_user_data_proper(1, mock_client)
    assert result["name"] == "Alice"


Anti-Pattern 9: Inappropriate Fixture Scoping (The "Slow Crawl")

Setting up expensive resources (like a database schema or a heavy object) for every single test by using the default scope="function".

The Problem: As your test suite grows, it becomes agonizingly slow. Developers stop running the tests locally because it takes 15 minutes, destroying the rapid feedback loop that makes TDD valuable.

How to Fix it: Leverage pytest fixture scopes (scope="session", scope="module", or scope="class") for expensive, read-only setup operations.

import time

# Simulating an expensive setup
def setup_database_schema():
    time.sleep(0.5) # Simulating a 500ms delay to build schema
    return "DB_CONNECTION"

# Anti-pattern: Function scope for an expensive operation

@pytest.fixture(scope="function") # This is the default if scope is omitted
def db_connection_slow():
    return setup_database_schema()

# Running 100 tests with this fixture will take 50 seconds just in setup time!
def test_user_read(db_connection_slow):
    assert db_connection_slow == "DB_CONNECTION"

def test_post_read(db_connection_slow):
    assert db_connection_slow == "DB_CONNECTION"

..                                                                       [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_post_read
✓ notebook.py::test_user_read

Summary:
Total: 2, Passed: 2, Failed: 0, Errors: 0, Skipped: 0

By changing the scope, the expensive setup runs only once per test session. Note: You must ensure tests do not mutate this shared state!

# Proper approach: Session scope for expensive read-only setups

@pytest.fixture(scope="session")
def db_connection_fast():
    # This runs exactly once for the entire test suite run!
    return setup_database_schema()

def test_user_read_fast(db_connection_fast):
    assert db_connection_fast == "DB_CONNECTION"

def test_post_read_fast(db_connection_fast):
    assert db_connection_fast == "DB_CONNECTION"

..                                                                       [100%]
=================================== Overview ===================================
Passed Tests:
✓ notebook.py::test_post_read_fast
✓ notebook.py::test_user_read_fast

Summary:
Total: 2, Passed: 2, Failed: 0, Errors: 0, Skipped: 0

Conclusion

By avoiding these common testing anti-patterns, you can build a test suite that is robust, meaningful, and easy to maintain. Writing tests is an investment, and focusing on behavior, response contracts, public interfaces, clean state, and isolated testing will yield the highest returns.

Tags