Mathew K Analytics

Lesson 6 · Pytest deep dive

pytest Tutorial #6: Assertions Deep Dive

On testing real Python code with pytest. pytest.approx, pytest.raises, and pytest.warns. Let's get into it. Plain == on floats is unreliable due to real…

⬇ Download notebook

📓 Full notebook

Download .ipynb

pytest Deep-Dive, Video 6: Assertions Deep-Dive#

  • Video six of an eighteen-part series on testing real Python code with pytest.
  • pytest.approx, pytest.raises, and pytest.warns.
  • Let's get into it.

Part 1: The Helper From Last Time#

import pytest, tempfile, os
def run_pytest(code_str, args=None):
    with tempfile.NamedTemporaryFile(mode='w', suffix='_test.py', delete=False) as f:
        f.write(code_str)
        path = f.name
    result = pytest.main([path, *(args or [])])
    os.unlink(path)
    return result

Part 2: The Floating-Point Equality Problem#

result = 0.1 + 0.2
print(result)
print(result == 0.3)
0.30000000000000004
False

Part 3: pytest.approx - Tolerant Float Comparison#

code = '''
import pytest

def test_float_addition():
    result = 0.1 + 0.2
    assert result == pytest.approx(0.3)

def test_float_addition_fails_without_approx():
    result = 0.1 + 0.2
    assert result == 0.3
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp_iv1egxr_test.py::test_float_addition PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp_iv1egxr_test.py::test_float_addition_fails_without_approx FAILED [100%]

================================== FAILURES ===================================
__________________ test_float_addition_fails_without_approx ___________________

    def test_float_addition_fails_without_approx():
        result = 0.1 + 0.2
>       assert result == 0.3
E       assert 0.30000000000000004 == 0.3

C:\Users\makmw\AppData\Local\Temp\tmp_iv1egxr_test.py:10: AssertionError
=========================== short test summary info ===========================
FAILED ..\..\..\Users\makmw\AppData\Local\Temp\tmp_iv1egxr_test.py::test_float_addition_fails_without_approx - assert 0.30000000000000004 == 0.3
========================= 1 failed, 1 passed in 0.42s =========================
1

Part 4: approx With Custom Tolerance#

code = '''
import pytest

def test_approx_with_relative_tolerance():
    measured = 101.5
    expected = 100.0
    assert measured == pytest.approx(expected, rel=0.02)

def test_approx_with_absolute_tolerance():
    measured = 0.0009
    expected = 0.0
    assert measured == pytest.approx(expected, abs=0.001)
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpd93liai3_test.py::test_approx_with_relative_tolerance PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpd93liai3_test.py::test_approx_with_absolute_tolerance PASSED [100%]

============================== 2 passed in 0.10s ==============================
0

Part 5: pytest.raises - Expecting an Exception#

code = '''
import pytest

def divide(a, b):
    if b == 0:
        raise ValueError('cannot divide by zero')
    return a / b

def test_divide_by_zero_raises():
    with pytest.raises(ValueError):
        divide(10, 0)

def test_divide_normal_case():
    assert divide(10, 2) == 5
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp71219pcs_test.py::test_divide_by_zero_raises PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp71219pcs_test.py::test_divide_normal_case PASSED [100%]

============================== 2 passed in 0.11s ==============================
0

Part 6: pytest.raises With match - Checking the Message Too#

code = '''
import pytest

def parse_positive_int(text):
    value = int(text)
    if value <= 0:
        raise ValueError(f'expected a positive integer, got {value}')
    return value

def test_parse_negative_raises_with_message():
    with pytest.raises(ValueError, match='expected a positive integer'):
        parse_positive_int('-5')

def test_parse_non_numeric_raises_value_error():
    with pytest.raises(ValueError):
        parse_positive_int('abc')
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 2 items

..\..\..\Users\makmw\AppData\Local\Temp\tmpdzo24wxi_test.py::test_parse_negative_raises_with_message PASSED [ 50%]
..\..\..\Users\makmw\AppData\Local\Temp\tmpdzo24wxi_test.py::test_parse_non_numeric_raises_value_error PASSED [100%]

============================== 2 passed in 0.11s ==============================
0

Part 7: pytest.warns - Expecting a Warning#

code = '''
import warnings
import pytest

def old_calculate(a, b):
    warnings.warn('old_calculate is deprecated, use calculate instead', DeprecationWarning)
    return a + b

def test_old_calculate_warns():
    with pytest.warns(DeprecationWarning):
        result = old_calculate(2, 3)
    assert result == 5
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 1 item

..\..\..\Users\makmw\AppData\Local\Temp\tmpq_jpd1ld_test.py::test_old_calculate_warns PASSED [100%]

============================== 1 passed in 0.12s ==============================
0

Part 8: A Real Pattern - Validating Inputs With Precise Errors#

code = '''
import pytest

def apply_discount(price, percent_off):
    if price < 0:
        raise ValueError('price cannot be negative')
    if not (0 <= percent_off <= 100):
        raise ValueError('percent_off must be between 0 and 100')
    return price * (1 - percent_off / 100)

def test_apply_discount_normal_case():
    assert apply_discount(50.0, 10) == pytest.approx(45.0)

def test_apply_discount_negative_price():
    with pytest.raises(ValueError, match='price cannot be negative'):
        apply_discount(-10, 10)

def test_apply_discount_bad_percent():
    with pytest.raises(ValueError, match='percent_off must be between'):
        apply_discount(50, 150)
'''
exit_code = run_pytest(code, ['-v', '--no-header'])
print(exit_code)
============================= test session starts =============================
collecting ... collected 3 items

..\..\..\Users\makmw\AppData\Local\Temp\tmp16_za84g_test.py::test_apply_discount_normal_case PASSED [ 33%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp16_za84g_test.py::test_apply_discount_negative_price PASSED [ 66%]
..\..\..\Users\makmw\AppData\Local\Temp\tmp16_za84g_test.py::test_apply_discount_bad_percent PASSED [100%]

============================== 3 passed in 0.14s ==============================
0

Wrap-Up: What You Learned#

  • Plain == on floats is unreliable due to real floating-point rounding error.
  • pytest.approx wraps an expected value, tolerating small floating-point differences automatically.
  • rel and abs keyword arguments on approx control relative and absolute tolerance directly.
  • pytest.raises wraps a with block and requires the given exception type to actually be raised inside it.
  • The match keyword on raises checks the exception message against a regular expression too.
  • pytest.warns mirrors raises for warnings, confirming a given warning category actually fires.
  • Combining approx and raises-with-match verifies both correct float math and precise error wording.
  • That wraps up assertions deep-dive. Next up: conftest.py and Fixture Sharing.

Found this useful?

All lessons, notebooks and datasets here are free. If they helped you, a coffee keeps new lessons coming.