Pytest-xdist: Chạy Test Song Song trong Python, Tăng Tốc Test Suite Lên Gấp 4 Lần

Python tutorial - IT technology blog
Python tutorial - IT technology blog

Mình dùng Python làm automation tool cho hầu hết task hàng ngày, từ deploy script đến monitoring alert. Khi project lớn dần, test suite từ 50 test nhảy lên 300+ test, và mỗi lần git push là ngồi nhìn CI/CD quay vòng gần 10 phút chỉ để chờ test chạy xong. Không phải vì code lỗi — mà vì test chậm thật sự.

Vấn đề thực tế: Test suite 300 bài ngốn 8 phút mỗi lần push

Project cụ thể: một bộ tool Python gồm nhiều module — xử lý file, gọi API bên thứ ba, query database. Test suite khoảng 320 bài, trong đó 60% là integration test kiểm tra logic phức tạp với mock HTTP và file I/O.

Kết quả khi chạy đầy đủ:

$ pytest --tb=no -q
320 passed in 487.23s (8 minutes 7 seconds)

8 phút. Nhân lên 10–15 lần push mỗi ngày là hơn 1 tiếng đồng hồ chỉ để chờ test. Trên CI/CD còn tệ hơn vì máy CI thường ít core hơn máy dev.

Phân tích nguyên nhân: Pytest chạy tuần tự theo thiết kế

Pytest mặc định chạy từng test một — test này xong mới tới test kế tiếp. Đây là thiết kế có chủ đích để đảm bảo tính nhất quán và dễ debug, nhưng không tận dụng được CPU đa nhân.

Nhìn vào resource monitor khi test đang chạy trên máy 8 core:

  • CPU tổng: 12–15% (1 core đang bận, 7 core nhàn rỗi)
  • Disk I/O: spike theo từng test đọc/ghi file
  • Network: thỉnh thoảng gọi mock HTTP response

Vấn đề rõ ràng: test không tốn CPU nhiều, chủ yếu tốn I/O wait time — chờ mock response, chờ file write, chờ DB query trả về. Trong khoảng chờ đó, 7 CPU core còn lại không làm gì cả. Đây là lãng phí tài nguyên điển hình.

Các cách giải quyết

Cách 1: Tối ưu từng test riêng lẻ

Bước đầu tiên nên làm bất kể có dùng xdist hay không. Các trick phổ biến:

import pytest

# Đánh dấu test chậm để có thể skip khi cần
@pytest.mark.slow
def test_heavy_integration():
    result = call_real_api()  # tốn 2-3 giây
    assert result["status"] == "ok"
# Dev local: bỏ qua test slow
pytest -m "not slow"

# CI full: chạy tất cả
pytest

Tối ưu xong thì giảm từ 8 phút xuống còn ~6 phút. Vẫn còn chậm, chưa đủ.

Cách 2: Chạy một phần test liên quan đến thay đổi

# Chạy chỉ test trong module vừa sửa
pytest tests/test_api_client.py

# Chạy theo tên keyword
pytest -k "upload or download"

# Chạy test thất bại lần trước trước tiên
pytest --lf  # last failed
pytest --lf --nf  # last failed, sau đó new files

Hữu ích khi dev local nhưng CI/CD vẫn cần chạy full suite. Không giải quyết được vấn đề gốc.

Cách 3: Chạy song song với pytest-xdist

pytest-xdist spawn nhiều worker process, mỗi worker nhận một tập con của test suite và chạy song song với nhau. Đây là cách giải quyết đúng chỗ — thay vì chờ tuần tự, tất cả core CPU cùng làm việc.

Cách tốt nhất: Pytest-xdist từ cài đặt đến thực chiến

Cài đặt

pip install pytest-xdist

# Hoặc thêm vào requirements-dev.txt
pytest-xdist>=3.0

Sử dụng cơ bản

# Auto detect số CPU core — đây là lệnh mình dùng nhất
pytest -n auto

# Chỉ định số worker cố định
pytest -n 4

# Kết hợp với verbose output
pytest -n auto -v --tb=short

Kết quả ngay sau khi bật:

320 passed in 127.41s (2 minutes 7 seconds)
# Từ 8 phút → 2 phút, nhanh hơn ~4 lần

Chiến lược phân phối test với –dist

Đây là thứ mình mất thời gian nhất để tìm hiểu. pytest-xdist có nhiều chiến lược phân phối:

# load (mặc định): worker nào rảnh nhận test tiếp theo
pytest -n 4 --dist=load

# loadscope: group theo class hoặc module — tốt khi dùng class-scoped fixture
pytest -n 4 --dist=loadscope

# loadfile: tất cả test trong cùng file chạy trên cùng 1 worker
pytest -n 4 --dist=loadfile

Mình dùng --dist=loadfile cho project có nhiều class fixture phức tạp, vì nó đảm bảo test cùng file không bị tách ra worker khác nhau — tránh được khá nhiều race condition.

Fixture và xdist: điểm cần chú ý nhất

Đây là chỗ hay gây bug nhất khi mới dùng xdist. Fixture scope="session" sẽ chạy độc lập trên mỗi worker, không phải shared toàn bộ session.

# Cần port riêng cho mỗi worker để tránh conflict
@pytest.fixture(scope="session")
def server_port(worker_id):
    # worker_id: "gw0", "gw1", "gw2"... hoặc "master" khi không dùng xdist
    base_port = 8000
    if worker_id == "master":
        return base_port
    worker_num = int(worker_id.replace("gw", ""))
    return base_port + worker_num  # gw0=8000, gw1=8001, gw2=8002...

Với database test, mình dùng tmp_path để mỗi test có DB riêng:

@pytest.fixture
def db(tmp_path):
    db_path = tmp_path / "test.db"
    conn = sqlite3.connect(str(db_path))
    yield conn
    conn.close()
    # tmp_path tự xóa sau khi test xong

Kết hợp với pytest-cov

pip install pytest-cov

File .coveragerc:

[run]
concurrency = multiprocessing
parallel = true

[report]
omit =
    */tests/*
    */venv/*
# Chạy test với coverage
pytest -n auto --cov=src --cov-report=html

# Combine data từ nhiều worker rồi xuất report
coverage combine
coverage report -m

Tips thực chiến

Test phải hoàn toàn stateless — đây là điều kiện tiên quyết:

# SAI: global state bị share giữa các worker
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1  # Fail khi chạy song song!

# ĐÚNG: mỗi test tự quản lý state riêng
def test_increment():
    counter = 0
    counter += 1
    assert counter == 1

Tránh hardcode port hoặc file path cố định:

import socket

def get_free_port() -> int:
    with socket.socket() as s:
        s.bind(('', 0))
        return s.getsockname()[1]

@pytest.fixture
def app_port():
    return get_free_port()  # mỗi worker nhận port ngẫu nhiên, không conflict

Dùng tmp_path thay vì tạo file cố định:

def test_file_processing(tmp_path):
    input_file = tmp_path / "input.csv"
    input_file.write_text("col1,col2\n1,2\n3,4")

    result = process_csv(input_file)
    assert len(result) == 2  # 2 hàng data

Profiling để biết test nào chậm nhất:

# Hiển thị 20 test chậm nhất
pytest --durations=20

# Cài pytest-randomly để phát hiện test phụ thuộc thứ tự
pip install pytest-randomly
pytest -n auto  # test sẽ được shuffle ngẫu nhiên

Kết quả thực tế và checklist trước khi bật xdist

Sau khi áp dụng pytest -n auto --dist=loadfile, test suite của mình giảm từ 8 phút → khoảng 2 phút trên máy 8 core. CI pipeline từ 30 phút xuống còn ~12 phút (phần còn lại là build Docker và deploy).

Trước khi bật xdist, check nhanh các điểm này:

  • Mỗi test không dùng shared mutable state (global variable, class attribute)
  • Không hardcode port hay file path cố định trong test
  • Fixture scope="session" không có side effect ảnh hưởng test khác
  • Test database dùng transaction rollback hoặc in-memory DB / tmp_path riêng biệt
  • Không có test nào phụ thuộc vào thứ tự chạy của test khác

Nếu có test break khi chạy song song, debug bằng cách chạy lại với -n 1 để xác nhận test pass khi tuần tự — nếu -n 1 pass mà -n auto fail thì chắc chắn là do shared state hoặc port/file conflict.

Share: