6 Common Mistakes When Writing Unit Tests in Python

Unit tests are essential for Python code quality, but many developers make the same mistakes. Learn the six most common errors and how to fix them.

Python code on a laptop screen with unit test file open

Unit tests are the safety net of your Python code. They catch regressions, document behavior, and give you confidence to refactor. But writing good unit tests is harder than it looks. Many developers, even experienced ones, fall into the same traps. Let’s look at the six most common mistakes when writing unit tests in Python and how to avoid them.

1. Testing Implementation Details Instead of Behavior

This is probably the biggest mistake you can make. You write tests that check how something works internally—what private methods are called, what order something happens, or which specific data structures are used. These tests are brittle. They break whenever you refactor, even if the external behavior stays the same.

Instead, test the behavior: what goes in and what comes out. Your test should verify that a function returns the expected result for a given input. If you change the implementation, the test should still pass as long as the contract holds. This makes refactoring safe and your tests more valuable.

For example, if you have a function that calculates total price with tax, don’t test that it calls calculate_tax internally. Test that the total price is correct for known inputs. Your test should not know or care about internal helpers.

“Test the observable behavior, not the hidden internals.”

Programmer debugging code on dual monitors
Debugging test failures — Photo: Boskampi / Pixabay

2. Poor Test Isolation: Tests That Depend on Each Other

When tests share state or run in a specific order, you get a brittle test suite. Test A modifies a global variable, and Test B depends on that modification. Run them separately, and Test B fails. Run them together, and they pass—until you change Test A. This is a nightmare for debugging.

Each test should be completely independent. It should set up its own data, run its own assertions, and clean up after itself. Use fixtures or setUp/tearDown methods to ensure a fresh state. Never rely on the side effects of another test.

In pytest, use @pytest.fixture to create reusable, isolated test data. Each test that uses the fixture gets a fresh copy. This avoids accidental coupling and makes your tests predictable.

A common mistake here is using global variables or module-level state. Instead, pass data explicitly or use dependency injection. For example, if a function reads from a config object, let the test inject a mock config rather than relying on a global one. This makes the test self-contained and easier to understand.

3. Overusing Mocks (or Using Them Wrong)

Mocks are powerful tools for isolating the code under test from external dependencies. But many developers go overboard. They mock everything—database calls, file reads, even simple utility functions. This leads to tests that test the mock, not the real code.

Mock only what you must: external services, network calls, or slow I/O operations. For internal functions, let them run. If a helper function is buggy, that’s a separate problem. Testing with real code gives you more confidence.

Another common mistake is mocking incorrectly, like creating a MagicMock object that doesn’t match the real interface. Your mock needs to return values that make sense. Otherwise, you get false positives. For example, if your mock returns a string when the real function returns an int, your test might pass but the real code will fail.

Use spec-based mocks: unittest.mock.create_autospec or pytest’s mocker fixture with spec=True. This ensures the mock has the same methods and attributes as the real object. Also, verify that the mock is called with the right arguments using assert_called_with or similar methods.

4. Writing Tests That Are Too Large or Too Small

Finding the right scope for a unit test is tricky. Some developers write tests that cover too much: they test an entire workflow end-to-end in one test. When that test fails, you have no idea which part is broken. Others write tests that are too granular: one test for each line of code, resulting in thousands of tests that add maintenance overhead.

A good rule of thumb: a unit test should test one logical behavior—a single function or method. If a function does one thing, one test should cover it. If it has multiple scenarios (e.g., happy path, error case, edge case), write separate tests for each scenario. This way, when a test fails, you know exactly what scenario broke.

Consider breaking large functions into smaller ones. That naturally leads to better test coverage and simpler tests. This approach also aligns with principles from Event-Driven Architecture: What It Is and When to Use It, where loose coupling and single responsibility improve testability.

5. Not Testing Edge Cases and Error Conditions

It’s easy to test the happy path—the inputs that work as expected. But bugs often live in the dark corners: empty lists, invalid data, network timeouts, boundary conditions. If you only test the typical case, you’re leaving a lot of risk uncovered.

Think about every possible input your code could receive. Test with None, empty strings, negative numbers, very large numbers, special characters, and malformed data. Also test error-handling paths: do your functions raise the right exceptions? Do they handle exceptions gracefully?

A good practice is to use property-based testing with libraries like Hypothesis. Instead of writing individual test cases, you define properties that must hold for all inputs. The library generates random inputs and checks your properties. It often finds edge cases you never thought of.

For example, if you have a function that sorts a list, a property might be that the output is sorted and contains the same elements as the input. Hypothesis will try many lists, including empty ones, single-element lists, and lists with duplicates, and report any failures.

6. Skipping Test Maintenance as Code Evolves

You write tests at the start of a project, but as the codebase grows, tests become outdated. New features get added without corresponding tests. Old tests start failing because of changes in behavior, and instead of fixing them, you comment them out or mark them as skipped. Soon, your test suite is a wasteland of broken or irrelevant tests that nobody trusts.

Treat tests as first-class citizens. They are code just like your production code. Refactor tests when you refactor the implementation. When adding a new feature, add tests first (or at least concurrently). Run your test suite often—ideally as part of your CI/CD Pipeline Checklist: Every DevOps Engineer Needs This. A failing build should be a priority to fix, not something to ignore.

Set up code coverage tools like pytest-cov to see which parts of your code are untested. But beware: high coverage doesn’t mean good tests. Focus on meaningful coverage of behaviors, not arbitrary percentages.

A checklist and notebook on a desk next to a laptop
Testing checklist — Photo: JessBaileyDesign / Pixabay

How to Spot These Mistakes in Your Own Tests

It’s one thing to know the mistakes, another to catch them in your own code. Here are some red flags:

  • Your test fails after a refactor that didn’t change external behavior. ➡️ You’re likely testing implementation details.
  • Tests must be run in a specific order to pass. ➡️ Poor isolation.
  • You mock a function but never assert it was called. ➡️ The mock might not be doing anything useful.
  • A single test has multiple assert statements checking unrelated things. ➡️ Test is too large; split it.
  • You never see tests with empty input or invalid data. ➡️ Edge cases missing.
  • Old tests are failing but nobody bothers to fix them. ➡️ Maintenance debt.

When you spot these, fix them immediately. The longer you wait, the more debt accumulates.

How to Fix These Mistakes: A Quick Reference

Here’s a summary of actionable steps you can take today to improve your unit tests.

Do This Instead

  • Test behavior, not implementation
  • Keep tests isolated and independent
  • Mock sparingly and correctly
  • Write one test per scenario
  • Include edge cases and errors
  • Maintain tests as code evolves

Comparison: Good vs. Bad Unit Tests

Bad Unit Test Good Unit Test
Tests internal method calls Tests output for given input
Depends on other tests or global state Fully isolated with fresh fixtures
Mocks everything, including simple functions Mocks only external dependencies
Covers multiple behaviors in one test One behavior per test
Only tests happy path Includes edge cases and error paths
Ignored or skipped when it fails Fixed immediately

Putting It All Together: A Step-by-Step Checklist

Before you write your next test, go through this checklist:

  1. Identify the function or method you’re testing.
  2. Define the contract: What are the valid inputs and expected outputs?
  3. List all scenarios: happy path, erroneous inputs, edge cases.
  4. Write a test for each scenario, one at a time.
  5. Use fixtures or factory functions to create test data.
  6. Mock only external services; let internal functions run.
  7. After writing, review: would this test break if I refactor internals?
  8. Add the test to your test suite and run it.
  9. As your code changes, update the test accordingly.

This systematic approach reduces the chance of making the mistakes discussed above.

Avoiding these six mistakes will make your Python unit tests more reliable, easier to maintain, and vastly more useful. Start by reviewing your current test suite. Look for tests that depend on internal behavior, share state, mock too much, or skip edge cases. Fix them one by one. Your future self—and your team—will thank you.

If you’re preparing for technical interviews, you might also find 5 Common Mistakes in System Design Interviews and How to Avoid Them helpful for improving your design skills.

Frequently asked questions

What is the most common mistake in Python unit testing?

The most common mistake is testing implementation details instead of behavior. Developers write tests that check internal method calls or data structures, making tests brittle and failing on refactoring. Focus on input and output.

How many mocks should I use in a unit test?

Use mocks sparingly—only for external dependencies like network calls or databases. Avoid mocking internal functions; let them run naturally. Too many mocks make tests superficial and less reliable.

Should unit tests be independent of each other?

Yes, every unit test must be completely independent. Tests should not share state or rely on execution order. Use fixtures or setUp methods to ensure each test starts fresh.

How can I ensure I test edge cases?

Think about boundary values, empty inputs, invalid data, and error scenarios. Use techniques like equivalence partitioning and boundary value analysis. Property-based testing libraries like Hypothesis can automatically generate edge cases.

What should I do when unit tests start failing after refactoring?

First, determine if the failure is due to a correct behavior change or a test that needs updating. If the implementation changed but the contract remains, the test might be too tightly coupled. Refactor the test to focus on behavior.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *