Case Study: When Tests Lie — False Confidence from Bad Tests
The Scenario
Jordan Chen was proud of their test suite. Their student project — a grade management system for a tutoring center — had 94% code coverage and 38 passing tests. All green. Every commit. Jordan felt confident submitting the project for their software engineering course.
Then the professor ran the system with real data. It crashed in five different ways within the first three minutes.
How can a project with 38 passing tests and 94% coverage be so broken? Because the tests were bad. Not wrong — bad. They tested the wrong things, made incorrect assumptions, and gave Jordan a dangerous sense of confidence in code that didn't work.
This case study examines five testing antipatterns — common mistakes that make tests worse than useless, because they create the illusion of reliability.
Antipattern 1: The Test That Tests Nothing
Jordan's most common mistake was writing tests that executed code without verifying results:
def test_calculate_gpa():
students = load_students("test_data.json")
for student in students:
calculate_gpa(student)
# Look, no assertion!
This test runs calculate_gpa() for every student. If the function doesn't crash, the test passes. But it never checks whether the GPAs are correct. The function could return 0.0 for every student and this test would still pass.
Coverage report: 100% of calculate_gpa() is covered. The metric says everything is tested. The reality says nothing is verified.
The Fix
Every test needs at least one meaningful assertion:
def test_calculate_gpa_straight_a():
student = {"name": "Alice", "grades": ["A", "A", "A", "A"]}
assert calculate_gpa(student) == pytest.approx(4.0)
def test_calculate_gpa_mixed():
student = {"name": "Bob", "grades": ["A", "B", "C"]}
assert calculate_gpa(student) == pytest.approx(3.0)
Rule of thumb: If your test has no assert statement, it's not a test — it's a smoke test at best.
Antipattern 2: Testing the Implementation, Not the Behavior
Jordan wrote tests that were tightly coupled to how the code worked internally rather than what it was supposed to do:
def test_sort_students():
students = [
{"name": "Charlie", "gpa": 3.2},
{"name": "Alice", "gpa": 3.8},
{"name": "Bob", "gpa": 3.5},
]
result = sort_students_by_gpa(students)
# Testing the exact internal order of operations
assert result[0] == {"name": "Alice", "gpa": 3.8}
assert result[1] == {"name": "Bob", "gpa": 3.5}
assert result[2] == {"name": "Charlie", "gpa": 3.2}
This test seems fine until Jordan refactored sort_students_by_gpa() to also include a "rank" field in each dict. Suddenly the test fails — not because the sorting is wrong, but because the dict now has an extra key the test didn't expect.
The Fix
Test the behavior (output is sorted by GPA, descending) rather than the exact output structure:
def test_sort_students_by_gpa_descending():
students = [
{"name": "Charlie", "gpa": 3.2},
{"name": "Alice", "gpa": 3.8},
{"name": "Bob", "gpa": 3.5},
]
result = sort_students_by_gpa(students)
gpas = [s["gpa"] for s in result]
assert gpas == sorted(gpas, reverse=True) # Verify descending order
assert len(result) == len(students) # Verify nothing was lost
Now the test verifies the contract (sorted descending, same number of students) without being brittle about internal details.
Antipattern 3: The Tautological Test
A tautological test is one that can never fail because it tests the code against itself:
def test_student_average():
scores = [85, 90, 78, 92]
expected = sum(scores) / len(scores) # Same calculation as the function!
assert calculate_average(scores) == expected
If calculate_average() has a bug — say, it accidentally uses len(scores) - 1 — this test would still pass, because expected is computed using the same (correct) formula, not the (buggy) function. The test is comparing the function to independent logic, which is good — but only if the "independent logic" is actually independent.
The problem is subtler here: sum(scores) / len(scores) happens to be the correct formula. But the test would be more resilient if it used a hardcoded expected value:
The Fix
def test_student_average():
scores = [85, 90, 78, 92]
assert calculate_average(scores) == pytest.approx(86.25) # Hardcoded expected value
Hardcoded expected values are computed by hand (or with a calculator) — they're truly independent of the code being tested.
Antipattern 4: Tests That Depend on Each Other
Jordan's test file had tests that relied on state left behind by previous tests:
students_db = [] # Module-level shared state
def test_add_student():
students_db.append({"name": "Alice", "gpa": 3.8})
assert len(students_db) == 1
def test_find_student():
# Assumes test_add_student ran first and added Alice
result = find_in_list(students_db, "Alice")
assert result["name"] == "Alice"
def test_remove_student():
# Assumes both previous tests ran and Alice is in the list
students_db.remove({"name": "Alice", "gpa": 3.8})
assert len(students_db) == 0
These tests pass when run together in order. But:
- Run test_find_student alone? It fails (empty list).
- Run tests in random order (which pytest can do)? Failures everywhere.
- test_add_student fails? Both subsequent tests fail too, but for the wrong reason.
The Fix
Each test creates its own data using fixtures:
@pytest.fixture
def students_db():
return [{"name": "Alice", "gpa": 3.8}]
def test_add_student():
db = []
db.append({"name": "Alice", "gpa": 3.8})
assert len(db) == 1
def test_find_student(students_db):
result = find_in_list(students_db, "Alice")
assert result["name"] == "Alice"
def test_remove_student(students_db):
students_db.remove({"name": "Alice", "gpa": 3.8})
assert len(students_db) == 0
Rule of thumb: Every test should pass regardless of which other tests have run (or haven't run) before it.
Antipattern 5: Testing Only the Happy Path
Jordan's test suite had 38 tests. Thirty-five tested normal inputs with expected data. Only three tested anything unusual. None tested: - Empty input - Invalid data types - Extremely large datasets - Missing required fields - Concurrent modifications - Unicode characters in names
The first real dataset the professor used had a student named "María" (with an accent) and another with the last name "O'Brien." Both caused crashes because the code assumed ASCII-only names. A student with no grades caused a division-by-zero error. A student record missing the "name" field caused a KeyError.
The Fix
For every test of normal behavior, write at least one test of abnormal behavior:
# Happy path
def test_gpa_normal():
assert calculate_gpa({"name": "Alice", "grades": ["A", "B"]}) == pytest.approx(3.5)
# Edge cases
def test_gpa_no_grades():
with pytest.raises(ValueError):
calculate_gpa({"name": "Alice", "grades": []})
def test_gpa_unicode_name():
result = calculate_gpa({"name": "María", "grades": ["A"]})
assert result == pytest.approx(4.0)
def test_gpa_missing_name():
with pytest.raises(KeyError):
calculate_gpa({"grades": ["A", "B"]})
The Lesson
Jordan rewrote their test suite. The new version had 52 tests, but they were fundamentally different: - Every test had meaningful assertions - Tests verified behavior, not implementation details - Expected values were hardcoded, not computed - Each test was independent - Edge cases outnumbered happy-path tests
Coverage dropped from 94% to 87%. But the new suite caught real bugs. The old suite just made Jordan feel safe.
The most dangerous test suite is one that passes when it shouldn't. It's worse than having no tests at all, because no tests at least leave you aware of your uncertainty. Bad tests replace uncertainty with false confidence.
Antipattern Summary Table
| Antipattern | What It Looks Like | Why It's Dangerous | The Fix |
|---|---|---|---|
| No assertions | function_call() with no assert |
Code runs but results aren't checked | Add meaningful assertions |
| Testing implementation | Checking exact internal structure | Breaks when you refactor | Test behavior and contracts |
| Tautological tests | Computing expected value with same logic | Can't detect formula bugs | Use hardcoded expected values |
| Dependent tests | Tests share mutable state | Order-dependent, fragile | Use fixtures, test in isolation |
| Happy path only | Only test "normal" inputs | Edge cases crash in production | Test edges, errors, boundaries |
Discussion Questions
-
Jordan's test suite had 94% coverage and 38 passing tests. Why didn't these metrics reveal the quality problems? What additional metrics or practices could have caught the issues earlier?
-
"The most dangerous test suite is one that passes when it shouldn't." Explain why false confidence from bad tests can be worse than having no tests at all. What real-world situations have similar dynamics (feeling safe when you're not)?
-
Pick one of the five antipatterns and describe a situation where you might accidentally fall into it. What habit or practice would help you avoid it?
-
Connection to Section 13.5 (TDD): How would TDD have prevented Antipattern 1 (no assertions)? Think about the Red-Green-Refactor cycle — at which step would the problem become obvious?
-
Review your own code from a previous chapter. Do any of these antipatterns apply to how you tested it (even manually)? What would a proper test suite look like?
Mini-Project
Take Jordan's original calculate_gpa() scenario and build it properly:
- Write
calculate_gpa(student)where a student dict has"name"(str) and"grades"(list of letter grades: A=4.0, B=3.0, C=2.0, D=1.0, F=0.0) - Write a test suite that avoids all five antipatterns
- Include at least 3 happy-path tests and at least 5 edge/error case tests
- Aim for 90%+ coverage, but explain which lines you chose not to cover and why
- Have a classmate review your tests and try to find a case your tests miss