# pytest Patterns Reference

## Contents
- [Fixtures](#fixtures)
- [Parametrize](#parametrize)
- [Mocking (unittest.mock + pytest-mock)](#mocking)
- [Exception testing](#exception-testing)
- [Async tests](#async-tests)
- [Temporary files and monkeypatch](#tmp-and-monkeypatch)
- [Coverage-oriented patterns](#coverage-patterns)

---

## Fixtures

```python
import pytest

# Einfaches Fixture
@pytest.fixture
def db_connection():
    conn = create_connection(":memory:")
    yield conn          # yield = Teardown nach yield
    conn.close()

# Scope-Kontrolle (function | class | module | session)
@pytest.fixture(scope="module")
def expensive_resource():
    return load_large_dataset()

# Fixture mit Parametern
@pytest.fixture(params=["sqlite", "postgres"])
def engine(request):
    return create_engine(request.param)

# Autouse — für alle Tests in einem Modul/Klasse aktiv
@pytest.fixture(autouse=True)
def reset_env(monkeypatch):
    monkeypatch.setenv("ENV", "test")
```

---

## Parametrize

```python
@pytest.mark.parametrize("value,expected", [
    (0,    True),
    (-1,   False),
    (None, False),
    ("",   False),
])
def test_is_valid(value, expected):
    assert is_valid(value) == expected

# Mehrere Parameter (kartesisches Produkt)
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert multiply(x, y) == x * y

# IDs für lesbare Testnamen
@pytest.mark.parametrize("val,exp", [
    pytest.param("hello", 5, id="normal"),
    pytest.param("",      0, id="empty"),
    pytest.param(None,    0, id="none"),
])
def test_length(val, exp):
    assert safe_len(val) == exp
```

---

## Mocking

### unittest.mock (stdlib)

```python
from unittest.mock import patch, MagicMock, call, ANY, PropertyMock

def test_calls_api(monkeypatch):
    mock_get = MagicMock(return_value={"status": "ok"})
    monkeypatch.setattr("mymodule.requests.get", mock_get)
    result = fetch_data("http://example.com")
    mock_get.assert_called_once_with("http://example.com", timeout=ANY)

def test_send_email():
    with patch("mymodule.smtplib.SMTP") as MockSMTP:
        instance = MockSMTP.return_value.__enter__.return_value
        send_notification("user@example.com")
        instance.sendmail.assert_called_once()

def test_property():
    with patch.object(MyClass, "status", new_callable=PropertyMock) as mock_prop:
        mock_prop.return_value = "active"
        assert MyClass().status == "active"

def test_handles_connection_error():
    with patch("mymodule.connect") as mock_conn:
        mock_conn.side_effect = ConnectionError("refused")
        with pytest.raises(ServiceUnavailableError):
            call_service()

def test_retry_logic():
    with patch("mymodule.fetch") as mock_fetch:
        mock_fetch.side_effect = [IOError, IOError, {"data": "ok"}]
        result = fetch_with_retry()
        assert result == {"data": "ok"}
        assert mock_fetch.call_count == 3
```

### pytest-mock (mocker fixture)

```python
def test_with_mocker(mocker):
    mock_fn = mocker.patch("mymodule.external_call", return_value=42)
    assert process() == 42
    mock_fn.assert_called_once()

def test_spy(mocker):
    spy = mocker.spy(MyClass, "compute")
    obj = MyClass()
    obj.compute(5)
    spy.assert_called_once_with(obj, 5)
```

---

## Exception Testing

```python
def test_raises_on_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_raises_value_error():
    with pytest.raises(ValueError, match=r"must be positive"):
        parse_age(-1)

def test_exception_attributes():
    with pytest.raises(CustomError) as exc_info:
        risky_operation()
    assert exc_info.value.error_code == 404

def test_warns_deprecation():
    with pytest.warns(DeprecationWarning, match="use new_fn"):
        old_fn()
```

---

## Async Tests

```python
import asyncio, pytest

# Option A: asyncio.run (kein Plugin nötig)
def test_async_fetch():
    async def _inner():
        result = await async_fetch("http://example.com")
        assert result["status"] == 200
    asyncio.run(_inner())

# Option B: pytest-asyncio  (pip install pytest-asyncio)
# pytest.ini: asyncio_mode = auto
@pytest.mark.asyncio
async def test_async_handler():
    result = await handle_request({"method": "GET"})
    assert result.status_code == 200

@pytest.mark.asyncio
async def test_async_mock(mocker):
    async def fake_fetch(url):
        return {"data": "mocked"}
    mocker.patch("mymodule.fetch", side_effect=fake_fetch)
    result = await process_url("http://example.com")
    assert result == {"data": "mocked"}
```

---

## Tmp and Monkeypatch

```python
def test_writes_file(tmp_path):
    output = tmp_path / "result.txt"
    write_report(output)
    assert output.read_text() == "expected content"

def test_reads_env(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    assert get_api_key() == "test-key-123"

def test_missing_env(monkeypatch):
    monkeypatch.delenv("API_KEY", raising=False)
    with pytest.raises(EnvironmentError):
        get_api_key()

def test_cli_args(monkeypatch):
    monkeypatch.setattr(sys, "argv", ["prog", "--verbose", "input.csv"])
    args = parse_args()
    assert args.verbose is True
```

---

## Coverage-Oriented Patterns

| Nicht abgedeckte Zeile | Lösung |
|---|---|
| `else`-Zweig | Parametrize mit Wert, der `else` auslöst |
| `except`-Block | `side_effect=ExceptionType` im Mock |
| `finally`-Block | Exception im `try`-Block auslösen |
| Frühzeitiges `return` | Eingabe, die Bedingung VOR dem return erfüllt |
| `if x is None` | `None` als Eingabe in Parametrize aufnehmen |
| Rekursionsabbruch | Basisfall direkt testen |

```python
def test_logs_warning(caplog):
    import logging
    with caplog.at_level(logging.WARNING):
        process_invalid_input(None)
    assert "Invalid input" in caplog.text

def test_prints_result(capsys):
    print_summary({"total": 5})
    captured = capsys.readouterr()
    assert "total: 5" in captured.out
```
