Python Unit Testing Best Practices Guide

What is unit testing?

Unit testing is the independent verification of the smallest testable unit of code (usually a function, class method, or module) to ensure that the output and behavior of the unit are exactly as expected given the inputs, preconditions, and execution paths.

If you write tests first and then implement functions during development, it is often called Test Driven Development (TDD). The rhythm of TDD is: Red → Green → Refactor - first write a failing test (red), then make it pass with the least amount of code (green), and finally optimize the code structure under the protection of the test. This approach can force modular code with high cohesion and low coupling from the root.


Why do we need unit testing?

Many developers feel that "writing tests is a waste of time in functional development", but if you look at the entire project life cycle, the benefits of unit testing far outweigh the investment:

  1. Quickly verify functional correctness: After writing the core logic, run the test within a few seconds to catch most low-level errors.
  2. Prevent regression bugs: When refactoring, optimizing, or adding new features, old tests will tell you where they are broken immediately.
  3. Forcing code quality: If you want to write testable code, you must avoid strong coupling, global dependencies and overly complex functions, which will naturally dismantle the module more clearly.
  4. Replace part of the documentation: The test case itself is the most vivid and never-failed example of "how to use the code and how not to use it."

Core Practices of Modern Unit Testing

1. Prefer pytest over unittest

Python standard libraryunittesthas a long history, but nowpytesthas become the de facto standard in the community. Its obvious advantages are:

  • Minimalist syntax: No need for class inheritance, no need to remember various thingsassertEqualassertTrueWait for redundant assertion methods, use them directlyassert
  • Powerful fixture system: Conveniently reuse test data and dependencies.
  • Parameterized Testing: A single decorator can cover a large number of boundary conditions.
  • Massive plug-ins: Coverage, Mock, concurrent testing, Benchmark... all have ready-made solutions.
  • Seamless compatibility: can be run directlyunittestThe migration cost for old tests written is almost zero.

Let’s look at a simple comparison:

# ===== unittest 风格 =====
import unittest
from mydict import Dict

class TestDict(unittest.TestCase):
    def test_init(self):
        d = Dict(a=1, b='test')
        self.assertEqual(d.a, 1)
        self.assertEqual(d.b, 'test')
        self.assertIsInstance(d, dict)

# ===== pytest 风格 =====
import pytest
from mydict import Dict

def test_dict_init():
    d = Dict(a=1, b='test')
    assert d.a == 1
    assert d.b == 'test'
    assert isinstance(d, dict)

The simplicity is clear at a glance.

2. Follow the FIRST principle

A good test case should meet these five characteristics, and none of them can be missing:

  • Fast (fast): single test in milliseconds, full test in seconds/minutes, otherwise no one would want to run it frequently.
  • Isolated: Each test does not depend on the status of other tests, nor does it affect the external environment (such as databases, files, etc.).
  • Repeatable (repeatable): Run repeatedly in the same environment, the results will always be consistent.
  • Self-validating (self-validating): The test result can only be "passed" or "failed", and there is no need to manually check the log.
  • Timely (timely): It is best to write simultaneously with the functional code, and no later than the function is online.

3. Pay reasonable attention to test coverage

usepytest-covThe plug-in can check the proportion of code covered by tests with one click:

# 安装插件
pip install pytest-cov

# 只统计核心模块的覆盖率
pytest --cov=mymodule tests/

# 生成带高亮的 HTML 报告(更直观)
pytest --cov=mymodule --cov-report=html tests/
# 报告在 htmlcov/index.html,浏览器打开即可

💡 The higher the coverage rate, the better! Core business logic should be covered 100% as much as possible, and the general tool library can be kept above 80%. Don't write tests for illogical code such as getters/setters just to increase coverage. The cost-effectiveness is too low.


Complete example of modern testing

Let's first write a small tool library to be tested - a dictionary class that supports attribute access and a student class with fractional verification:

# mytools.py
class Dict(dict):
    """像访问对象属性一样访问字典键值的工具类"""
    def __init__(self, **kw):
        super().__init__(**kw)

    def __getattr__(self, key):
        try:
            return self[key]
        except KeyError:
            # 将 KeyError 转成 AttributeError,保持对象属性访问的一致性
            raise AttributeError(f"'Dict' object has no attribute '{key}'")

    def __setattr__(self, key, value):
        # 属性赋值转成字典赋值,但要避免覆盖内置的特殊属性
        if key.startswith('__'):
            super().__setattr__(key, value)
        else:
            self[key] = value


class Student:
    """带分数验证的学生类"""
    def __init__(self, name, score):
        self.name = name
        if not 0 <= score <= 100:
            raise ValueError("Score must be between 0 and 100")
        self.score = score
        
    def get_grade(self):
        """根据分数返回等级:80+ A, 60~79 B, 0~59 C"""
        if self.score >= 80:
            return 'A'
        if self.score >= 60:
            return 'B'
        return 'C'

1. Basic function and exception testing

The test code directly corresponds to edge cases, normal usage and exception throwing:

# test_mytools.py
import pytest
from mytools import Dict, Student

class TestDict:
    """测试支持属性访问的字典"""

    def test_init_with_kv(self):
        d = Dict(a=1, b='test_str', c=None)
        assert d.a == 1
        assert d.b == 'test_str'
        assert d.c is None
        assert isinstance(d, dict)

    def test_key_to_attr(self):
        """字典键赋值后,可以用属性访问"""
        d = Dict()
        d['user_id'] = 1001
        d['is_vip'] = True
        assert d.user_id == 1001
        assert d.is_vip is True

    def test_attr_to_key(self):
        """对象属性赋值后,可以用字典键访问"""
        d = Dict()
        d.username = 'alice'
        d.age = 25
        assert 'username' in d
        assert d['username'] == 'alice'
        assert d['age'] == 25

    def test_attr_error_for_missing(self):
        """访问不存在的属性时抛出 AttributeError"""
        d = Dict()
        with pytest.raises(AttributeError, match=r"'Dict' object has no attribute 'empty_attr'"):
            _ = d.empty_attr

    def test_special_attr_not_overwritten(self):
        """内置特殊属性(如 __len__)不会变成字典键"""
        d = Dict()
        # 直接调用 __len__ 方法不会报错
        assert len(d) == 0
        # 字典里不存在键 '__len__'
        assert '__len__' not in d

2. Use fixtures to reuse test data

When multiple tests require the same initialization data, you can use@pytest.fixtureExtract the creation logic to avoid duplication of code:

# test_mytools.py 继续追加
@pytest.fixture
def sample_dict():
    """返回一个预填充的 Dict 实例"""
    return Dict(a=1, b='test_str', nested=Dict(c=2))

@pytest.fixture
def grade_boundary_students():
    """返回分数边界的学生实例列表"""
    return [
        Student('Alice', 100),  # A 的上限
        Student('Bob', 80),     # A 的下限
        Student('Charlie', 79), # B 的上限
        Student('Diana', 60),   # B 的下限
        Student('Eve', 59),     # C 的上限
        Student('Frank', 0),    # C 的下限
    ]

def test_fixture_dict_usage(sample_dict):
    assert sample_dict.a == 1
    assert sample_dict.nested.c == 2

3. Cover boundary conditions with parametric tests

Manually listing various boundary inputs is too tedious.@pytest.mark.parametrizeIt can be done in one go:

# test_mytools.py 继续追加
@pytest.mark.parametrize(
    "score, expected_grade",
    [(100, 'A'), (80, 'A'), (79, 'B'), (60, 'B'), (59, 'C'), (0, 'C')]
)
def test_grade_boundaries_param(score, expected_grade):
    s = Student('TestStudent', score)
    assert s.get_grade() == expected_grade

@pytest.mark.parametrize(
    "invalid_score",
    [-1, 101, 100.1, None, '80']
)
def test_student_invalid_score(invalid_score):
    with pytest.raises((ValueError, TypeError)):
        _ = Student('InvalidStudent', invalid_score)

Commonly used pytest execution commands

# 基础命令
pytest                     # 发现并运行当前目录下所有 test_*.py 或 *_test.py 文件
pytest test_mytools.py     # 只运行指定文件
pytest test_mytools.py::TestDict  # 只运行指定测试类
pytest test_mytools.py::TestDict::test_init_with_kv  # 只运行指定测试方法

# 增强选项
pytest -v                  # 显示每个测试的详细结果(通过/失败的名字)
pytest -s                  # 展示测试中的 print 输出(默认会被 pytest 截获)
pytest -k "init or boundary"  # 按名称关键字过滤测试
pytest -x                  # 遇到第一个失败就立即停止
pytest --lf                # 只重新运行上一次失败过的测试
pytest --tb=short          # 失败时仅显示简短的堆栈信息

It is recommended to integrate these commands into your daily development and CI processes and press the "Check" shortcut key at any time.


Best practice suggestions for avoiding pitfalls

  1. Test naming should be self-explanatory: Recommendedtest_功能_场景_预期The format allows people to understand at a glance what is being measured, what the situation is, and what to expect.
  2. Tests must be independent: Each test creates and destroys data by itself, and does not rely on global state or side effects of the previous test.
  3. Avoid testing implementation details: Test "what the code should do" rather than "how the code is implemented internally". For example, determine whether the sorting results are in order instead of worrying about whether to use bubble or quick sort.
  4. Mock external dependencies: If the code depends on the database, API or file system, usepytest-mockorunittest.mockReplace to ensure the speed and stability of the test.
  5. Integrate tests into CI/CD: Automatically run a full set of tests for each submission. If the test fails, it will be repaired immediately to avoid continuing development with problems.

Summarize

The core of modern Python unit testing has changed from "simple functional verification" to "using the pytest ecosystem to build efficient and maintainable test suites." Remember these key actions:

  • Prefer using pytest, embracing concise syntax and rich ecology.
  • Adhere to the FIRST principle and make testing a trustworthy safety net.
  • The core logic is fully covered, and the edge code is appropriately relaxed.
  • Treat testing as part of the development process rather than as an afterthought.

When you integrate these practices into your daily life, not only will the code be more robust, but the later maintenance costs will also be significantly reduced, and the entire development experience will be much smoother!