Wednesday, November 4, 2026

Pytest for TestNG users: every feature with examples

Pytest for TestNG users: every feature with examples

Each section shows the TestNG concept, the pytest equivalent, and an explanation.

Pytest for TestNG users: every feature with examples


0. Setup and project structure

pip install pytest pytest-dependency pytest-order pytest-timeout pytest-xdist pytest-check pytest-repeat pytest-html
project/
├── conftest.py        # shared fixtures (like a base test class)
├── pytest.ini         # config (like testng.xml)
└── tests/
    ├── test_basic.py
    └── test_login.py

Pytest auto-discovers files named test_*.py or *_test.py, and inside them, functions named test_* and classes named Test*. No annotation is needed for @Test.


Pytest plugins and their uses

Package

Purpose

pytest 

  Main Python testing framework

pytest-dependency

  Manage dependencies between test cases

pytest-order

  Control test execution order

pytest-timeout

  Set timeout for test execution

pytest-xdist

  Execute tests in parallel

pytest-check

  Perform multiple assertions in one test

pytest-repeat

  Repeat test cases multiple times

pytest-html

 Generate HTML test reports


1. @Test: basic test

# tests/test_basic.py
def test_addition():
    assert 1 + 1 == 2

def test_string():
    assert "hello".upper() == "HELLO"

Explanation:

  • Any function starting with test_ is a test.
  • Use the plain assert keyword instead of Assert.assertEquals. If it fails, pytest shows both values and the exact expression.
  • A test passes if no exception is raised.

2. Grouping tests in a class

class TestCalculator:
    def test_add(self):
        assert 2 + 3 == 5

    def test_multiply(self):
        assert 2 * 3 == 6

Explanation:

  • This is like a TestNG test class. The class name must start with Test and must not have an __init__.
  • Classes are optional. Use them to group related tests and to apply class-level fixtures or markers.

3. @BeforeMethod / @AfterMethod: function-scoped fixture

# conftest.py
import pytest

@pytest.fixture
def login():
    print("\n[Setup] Logging in")
    yield "user_session"          # test runs here
    print("[Teardown] Logging out")
# tests/test_login.py
def test_dashboard(login):
    print("Running dashboard test with", login)
    assert login == "user_session"

def test_profile(login):
    assert True

Explanation:

  • Code before yield is the setup (@BeforeMethod). Code after yield is the teardown (@AfterMethod).
  • A test requests a fixture by adding its name as a parameter. Pytest injects it automatically.
  • The value after yield is what the test receives.
  • Default scope is function, so this runs once per test, here twice.
  • Run with pytest -s to see the prints.

4. @BeforeClass / @AfterClass: class or module scope

# conftest.py
@pytest.fixture(scope="class")
def db_connection():
    print("\n[Setup] Connect DB")
    yield "db"
    print("[Teardown] Close DB")
import pytest

@pytest.mark.usefixtures("db_connection")
class TestUsers:
    def test_one(self):
        assert True

    def test_two(self):
        assert True

Explanation:

  • scope="class" runs setup once before the first test in the class and teardown after the last.
  • scope="module" does the same per file, which is like @BeforeTest in a way.
  • @pytest.mark.usefixtures(...) applies a fixture without passing it as an argument. Use it when the test doesn't need the returned value.

5. @BeforeSuite / @AfterSuite: session scope

# conftest.py
@pytest.fixture(scope="session")
def browser():
    print("\n[Suite Setup] Launch browser")
    yield "chrome"
    print("[Suite Teardown] Quit browser")
def test_a(browser):
    assert browser == "chrome"

def test_b(browser):
    assert browser == "chrome"

Explanation:

  • scope="session" runs once for the entire pytest run, however many files there are.
  • Put it in conftest.py so every test file can use it without importing. Pytest loads conftest.py automatically.
  • Here the browser launches once, both tests share it, and it quits at the end.

6. Auto-running fixtures (implicit @BeforeMethod)

# conftest.py
@pytest.fixture(autouse=True)
def log_test_name(request):
    print(f"\n>>> Starting {request.node.name}")
    yield
    print(f"<<< Finished {request.node.name}")

Explanation:

  • autouse=True makes the fixture run for every test in scope, with no need to request it. This is the closest match to TestNG's implicit @BeforeMethod.
  • request is a built-in fixture that gives info about the running test.

7. @DataProvider: parametrize

import pytest

@pytest.mark.parametrize("username, password, expected", [
    ("admin", "admin123", True),
    ("guest", "wrong", False),
    ("", "", False),
])
def test_login(username, password, expected):
    result = (username == "admin" and password == "admin123")
    assert result == expected

Explanation:

  • The first argument is a comma-separated string of parameter names. The second is a list of tuples, one per run.
  • This runs 3 separate tests, each reported on its own.
  • Give readable IDs with ids=["valid", "invalid", "empty"] inside the decorator.

Data from a function (like a @DataProvider method):

def get_data():
    return [(1, 2, 3), (2, 3, 5), (10, 5, 15)]

@pytest.mark.parametrize("a, b, total", get_data())
def test_sum(a, b, total):
    assert a + b == total

Stacking decorators gives a cross product:

@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_combo(x, y):      # runs 4 times: (1,10) (2,10) (1,20) (2,20)
    assert x * y > 0

8. groups: markers

import pytest

@pytest.mark.smoke
def test_homepage():
    assert True

@pytest.mark.regression
def test_checkout():
    assert True

@pytest.mark.smoke
@pytest.mark.regression
def test_login():
    assert True

Register markers in pytest.ini (otherwise you get warnings):

[pytest]
markers =
    smoke: quick sanity checks
    regression: full regression suite
pytest -m smoke
pytest -m "smoke and regression"
pytest -m "regression and not smoke"

Explanation:

  • A marker is a tag. One test can have many.
  • -m accepts boolean expressions (and, or, not), so it's more flexible than TestNG groups.
  • You can mark a whole class (@pytest.mark.smoke above class TestX) and every test inside inherits it.

9. enabled = false: skip and conditional skip

import pytest, sys

@pytest.mark.skip(reason="Feature not ready")
def test_new_feature():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="Linux only")
def test_linux_only():
    ...

def test_runtime_skip():
    if not some_condition():
        pytest.skip("Condition not met")   # skip from inside the test

Explanation:

  • skip always skips. skipif skips when the condition is true.
  • pytest.skip() skips dynamically at runtime.
  • Skipped tests show as s in the output, not as failures.

Bonus: xfail (expected failure, a known bug):

@pytest.mark.xfail(reason="Bug #123")
def test_known_bug():
    assert 1 == 2       # reported as XFAIL, not a failure

If it unexpectedly passes, it shows as XPASS. Add strict=True to turn that into a failure.

10. expectedExceptions: pytest.raises

import pytest

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0

def test_exception_message():
    with pytest.raises(ValueError, match="invalid literal"):
        int("abc")

def test_inspect_exception():
    with pytest.raises(KeyError) as exc_info:
        {}["missing"]
    assert "missing" in str(exc_info.value)

Explanation:

  • The test passes only if the expected exception is raised inside the with block.
  • match= checks the message against a regex, like TestNG's expectedExceptionsMessageRegExp.
  • exc_info lets you inspect the exception afterwards.

11. timeOut: pytest-timeout

import pytest, time

@pytest.mark.timeout(2)
def test_must_finish_fast():
    time.sleep(5)          # FAILS: exceeds 2 seconds

Or set a global timeout in pytest.ini:

[pytest]
timeout = 30

Explanation:

  • It needs pip install pytest-timeout.
  • The test fails if it runs longer than the limit.
  • A marker on a test overrides the global value.

12. priority: pytest-order

import pytest

@pytest.mark.order(2)
def test_second():
    print("second")

@pytest.mark.order(1)
def test_first():
    print("first")

@pytest.mark.order("last")
def test_last():
    print("last")

Explanation:

  • By default pytest runs tests in the order they appear in the file, not alphabetically as TestNG does.
  • pytest-order lets you set explicit order with numbers, "first", or "last".
  • Lower numbers run first, which matches TestNG's priority.
  • Good practice is to keep tests independent and avoid relying on order.

13. dependsOnMethods: pytest-dependency

import pytest

@pytest.mark.dependency()
def test_create_user():
    assert True

@pytest.mark.dependency(depends=["test_create_user"])
def test_update_user():
    assert True

@pytest.mark.dependency(depends=["test_update_user"])
def test_delete_user():
    assert True

Explanation:

  • test_update_user runs only if test_create_user passed. Otherwise it is skipped, as in TestNG.
  • Without pytest-dependency, you'd use fixtures to share state instead, which is the more "pytest-native" way.
  • Dependencies across classes or modules need a scope: @pytest.mark.dependency(depends=["..."], scope="module").

14. invocationCount: pytest-repeat

def test_flaky_check():
    assert True
pytest --count=5                  # run each test 5 times
pytest --count=5 -x               # stop at first failure

Or per-test:

import pytest

@pytest.mark.repeat(3)
def test_three_times():
    assert True

Explanation:

  • It needs pip install pytest-repeat.
  • It's useful for catching flaky tests.

15. parallel: pytest-xdist

pytest -n 4            # 4 worker processes
pytest -n auto         # one per CPU core
pytest -n 4 --dist loadscope   # keep tests of the same class/module on one worker

Explanation:

  • It needs pip install pytest-xdist.
  • It uses separate processes, not threads, so tests must be independent and not share mutable global state.
  • A session fixture runs once per worker, not once overall. Keep this in mind for things like database setup.
  • --dist loadscope keeps a class's tests together, similar to parallel="classes".

16. SoftAssert: pytest-check

from pytest_check import check

def test_soft_assertions():
    check.equal(1, 1, "first")
    check.equal(2, 3, "second fails")     # recorded, test continues
    check.is_true(False, "third fails")   # recorded, test continues
    # test fails at the end, reporting both failures

Or with the context manager:

from pytest_check import check

def test_with_context():
    with check:
        assert 1 == 2
    with check:
        assert 3 == 4

Explanation:

  • Normal assert stops the test at the first failure. check collects all failures and reports them together, like softAssert.assertAll().
  • You don't need to call assertAll(). Pytest does it at the end of the test.

17. Sharing data between tests and fixtures with parameters

@pytest.fixture(params=["chrome", "firefox", "edge"])
def browser(request):
    return request.param

def test_open_site(browser):
    print("Testing on", browser)

Explanation:

  • This is like TestNG's @Parameters combined with a data provider at the fixture level.
  • The test runs once per param (3 times here).
  • It's ideal for cross-browser testing.

18. testng.xml: configuration

pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
markers =
    smoke: quick sanity checks
    regression: full regression suite
addopts = -v --tb=short --html=report.html --self-contained-html
timeout = 60

Explanation:

  • testpaths sets where to look for tests.
  • addopts holds default CLI flags, so you don't retype them.
  • You can define "suites" with commands, folders, or markers:
pytest tests/api            # an "API suite" by folder
pytest -m smoke             # a "smoke suite" by marker
pytest tests/test_login.py tests/test_cart.py   # a custom list

19. Reporting

pytest --html=report.html --self-contained-html    # pytest-html
pytest --junitxml=results.xml                      # JUnit XML (CI tools)

For Allure (similar to rich TestNG reports):

pip install allure-pytest
pytest --alluredir=allure-results
allure serve allure-results

20. Useful run commands

CommandMeaning
pytestRun all tests
pytest -vVerbose output
pytest -sShow print() output
pytest -xStop at first failure
pytest --maxfail=3Stop after 3 failures
pytest --lfRe-run only last failed tests
pytest -k "login and not admin"Select by name expression
pytest file.py::test_nameRun one test
pytest file.py::TestClass::test_nameRun one test in a class
pytest --collect-onlyList tests without running them

21. Complete mini example combining everything

# conftest.py
import pytest

@pytest.fixture(scope="session")
def browser():
    print("\n[Suite] start browser")
    yield "chrome"
    print("[Suite] quit browser")

@pytest.fixture
def login(browser):
    print("[Method] login")
    yield browser
    print("[Method] logout")
# tests/test_shop.py
import pytest
from pytest_check import check

@pytest.mark.smoke
@pytest.mark.order(1)
@pytest.mark.dependency()
def test_login(login):
    assert login == "chrome"

@pytest.mark.regression
@pytest.mark.dependency(depends=["test_login"])
@pytest.mark.parametrize("item, qty", [("book", 1), ("pen", 5)])
def test_add_to_cart(login, item, qty):
    assert qty > 0

@pytest.mark.timeout(3)
def test_checkout(login):
    with pytest.raises(ValueError):
        int("not a number")

def test_validations(login):
    check.equal(1, 1)
    check.is_true(True)
pytest -m smoke -v -s
pytest -n 2 -m regression

Key mindset shifts from TestNG

  1. Fixtures replace annotations. Setup and teardown are functions you request by name, not hooks tied to a class.
  2. conftest.py replaces base classes. Shared setup lives there, with no inheritance needed.
  3. Plain assert. No assertion library is needed.
  4. Markers replace groups and enabled. They are flexible tags filtered from the command line.
  5. Plugins fill the gaps. Dependency, ordering, timeout, repeat, parallel, and soft asserts each come from a small plugin.

No comments:

Post a Comment