├── code ├── rpncalc │ ├── __init__.py │ ├── test_utils.py │ ├── rpn_v1.py │ ├── test_rpn_errors.py │ ├── convert.py │ ├── utils.py │ ├── test_rpn_v2.py │ ├── rpn_v2.py │ ├── test_rpn_v1.py │ └── rpn_v3.py ├── plugins │ ├── coverage │ │ ├── test_cov.py │ │ ├── cov.py │ │ └── .coverage │ ├── test_asyncio.py │ ├── test_hypothesis_rpncalc_v1.py │ ├── test_hypothesis_rpncalc_v3.py │ └── test_hypothesis.py ├── basic │ ├── ipytest_setup.py │ ├── test_calc.py │ ├── test_approx.py │ ├── test_raises.py │ ├── test_traceback.py │ └── failure_demo.py ├── fixtures │ ├── test_builtin_capsys.py │ ├── test_builtin_capfd.py │ ├── cli_opt │ │ └── conftest.py │ ├── test_fixture.py │ ├── test_fixture_param.py │ ├── test_usefixtures.py │ ├── test_builtin_caplog.py │ ├── test_fixture_scope.py │ ├── test_parametrize_indirect.py │ ├── test_autouse.py │ ├── test_fixtures_using_fixtures.py │ ├── test_builtin_request_markers.py │ ├── test_yield_fixture.py │ ├── test_fixture_scope_reset.py │ ├── test_fixture_finalizer.py │ └── test_builtin_monkeypatch.py ├── hooks │ ├── yaml │ │ ├── test_calc.yml │ │ ├── pyproject.toml │ │ └── conftest.py │ └── reporting │ │ └── conftest.py ├── mocking │ ├── converter │ │ ├── conftest.py │ │ ├── test_vcr.py │ │ ├── test_mock.py │ │ ├── test_httpserver.py │ │ ├── test_monkeypatch.py │ │ ├── test_responses.py │ │ └── cassettes │ │ │ └── test_vcr │ │ │ ├── test_eur2chf.yaml │ │ │ └── test_chf2eur.yaml │ ├── test_real.py │ ├── test_fake_mock.py │ └── test_fake.py ├── marking │ ├── test_marking.py │ ├── test_parametrization_marks.py │ └── test_parametrization.py ├── requirements.txt └── pytest.ini ├── pytest-tips-and-tricks-ep2024.pdf ├── README.md └── demos └── ep2024.ipynb /code/rpncalc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /code/plugins/coverage/test_cov.py: -------------------------------------------------------------------------------- 1 | import cov 2 | 3 | 4 | def test_func1(): 5 | cov.double(2) -------------------------------------------------------------------------------- /code/basic/ipytest_setup.py: -------------------------------------------------------------------------------- 1 | import ipytest 2 | 3 | ipytest.autoconfig(addopts=["--color=yes", "--no-header"]) -------------------------------------------------------------------------------- /code/basic/test_calc.py: -------------------------------------------------------------------------------- 1 | from rpncalc.utils import calc 2 | 3 | def test_add(): 4 | res = calc(1, 3, "+") 5 | assert res == 4 -------------------------------------------------------------------------------- /pytest-tips-and-tricks-ep2024.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/The-Compiler/pytest-tips-and-tricks/HEAD/pytest-tips-and-tricks-ep2024.pdf -------------------------------------------------------------------------------- /code/fixtures/test_builtin_capsys.py: -------------------------------------------------------------------------------- 1 | def test_output(capsys): 2 | print("Hello World") 3 | out, err = capsys.readouterr() 4 | assert out == "Hello World\n" -------------------------------------------------------------------------------- /code/basic/test_approx.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import calc 3 | 4 | 5 | def test_add(): 6 | res = calc(0.2, 0.1, "+") 7 | assert res == pytest.approx(0.3) -------------------------------------------------------------------------------- /code/plugins/coverage/cov.py: -------------------------------------------------------------------------------- 1 | def func1(double): 2 | value = 1 3 | if double: 4 | value *= 2 5 | return value 6 | 7 | 8 | def func2(): 9 | return 5 / 0 -------------------------------------------------------------------------------- /code/fixtures/test_builtin_capfd.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | 4 | def test_output(capfd): 5 | subprocess.run(["ls"]) 6 | out, err = capfd.readouterr() 7 | assert out == "..." -------------------------------------------------------------------------------- /code/hooks/yaml/test_calc.yml: -------------------------------------------------------------------------------- 1 | - name: adding-stack 2 | inputs: ["10", "20"] 3 | stack: [10, 20] 4 | 5 | - name: simple-addition 6 | inputs: ["10", "5", "+"] 7 | stack: [15] 8 | -------------------------------------------------------------------------------- /code/basic/test_raises.py: -------------------------------------------------------------------------------- 1 | from rpncalc.utils import calc 2 | 3 | import pytest 4 | 5 | 6 | def test_zero_division(): 7 | with pytest.raises(ZeroDivisionError): 8 | calc(3, 0, "/") -------------------------------------------------------------------------------- /code/basic/test_traceback.py: -------------------------------------------------------------------------------- 1 | from rpncalc.utils import calc 2 | 3 | def test_divide(): 4 | # This will raise ZeroDivisionError 5 | assert calc(2, 0, "/") == 0 6 | 7 | def test_good(): 8 | pass -------------------------------------------------------------------------------- /code/mocking/converter/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | @pytest.fixture 4 | def exchange_data() -> dict: 5 | rates = [{"alias": "chf", "rate": 2}] 6 | return {"data": {"alias": "eur", "rates": rates}} -------------------------------------------------------------------------------- /code/plugins/test_asyncio.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pytest 3 | 4 | 5 | @pytest.mark.asyncio 6 | async def test_asyncio(): 7 | answer = 42 8 | val = await asyncio.sleep(1, result=answer) 9 | assert val == answer -------------------------------------------------------------------------------- /code/plugins/coverage/.coverage: -------------------------------------------------------------------------------- 1 | !coverage.py: This is a private format, don't read it directly!{"arcs":{"/home/florian/talks/trainings/2019-11-berlin-bsh/testcourse/code/cov/cov.py":[[-1,1],[1,7],[7,-1],[-1,2],[2,3],[3,4],[4,5],[5,-1]]}} 2 | -------------------------------------------------------------------------------- /code/plugins/test_hypothesis_rpncalc_v1.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given, strategies as st 2 | from rpncalc.rpn_v1 import RPNCalculator 3 | 4 | @given(st.text()) 5 | def test_random_strings(s): 6 | rpn = RPNCalculator() 7 | rpn.evaluate(s) -------------------------------------------------------------------------------- /code/marking/test_marking.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time 3 | 4 | 5 | @pytest.mark.slow 6 | @pytest.mark.webtest 7 | def test_slow_api(): 8 | time.sleep(1) 9 | 10 | @pytest.mark.webtest 11 | def test_api(): 12 | pass 13 | 14 | def test_fast(): 15 | pass -------------------------------------------------------------------------------- /code/fixtures/cli_opt/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def pytest_addoption(parser: pytest.Parser) -> None: 5 | parser.addoption("--server-ip", type=str) 6 | 7 | @pytest.fixture 8 | def server_ip(request: pytest.FixtureRequest) -> str: 9 | return request.config.option.server_ip -------------------------------------------------------------------------------- /code/fixtures/test_fixture.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import Config 3 | from rpncalc.rpn_v2 import RPNCalculator 4 | 5 | 6 | @pytest.fixture 7 | def rpn() -> RPNCalculator: 8 | return RPNCalculator(Config()) 9 | 10 | 11 | def test_empty_stack(rpn: RPNCalculator): 12 | assert rpn.stack == [] -------------------------------------------------------------------------------- /code/fixtures/test_fixture_param.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import Config 3 | 4 | 5 | @pytest.fixture( 6 | params=[ 7 | ">", 8 | "rpn>", 9 | ] 10 | ) 11 | def config(request): 12 | c = Config(prompt=request.param) 13 | return c 14 | 15 | def test_init(config): 16 | print(config.prompt) 17 | assert False -------------------------------------------------------------------------------- /code/fixtures/test_usefixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pathlib 3 | 4 | 5 | @pytest.fixture 6 | def tmp_homedir(tmp_path, monkeypatch): 7 | monkeypatch.setenv("HOME", str(tmp_path)) 8 | return tmp_path 9 | 10 | 11 | @pytest.mark.usefixtures("tmp_homedir") 12 | class TestHomeDir: 13 | def test_empty(self): 14 | assert not list(pathlib.Path.home().iterdir()) -------------------------------------------------------------------------------- /code/fixtures/test_builtin_caplog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def test_output(caplog): 5 | logging.warning("Something failed") 6 | assert caplog.messages == ["Something failed"] 7 | 8 | 9 | def test_record_tuples(caplog): 10 | logging.warning("Something failed") 11 | assert caplog.record_tuples == [ 12 | ("root", logging.WARNING, "Something failed") 13 | ] -------------------------------------------------------------------------------- /code/hooks/reporting/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | def pytest_report_header() -> list[str]: 5 | return ["extrainfo: line 1"] 6 | 7 | 8 | def pytest_terminal_summary(terminalreporter) -> None: 9 | if terminalreporter.verbosity >= 1: 10 | terminalreporter.section("my special section") 11 | terminalreporter.line("report something here") -------------------------------------------------------------------------------- /code/mocking/test_real.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from rpncalc.utils import Config 4 | from rpncalc.rpn_v3 import RPNCalculator 5 | 6 | 7 | @pytest.fixture 8 | def rpn() -> RPNCalculator: 9 | return RPNCalculator(Config()) 10 | 11 | def test_convert(rpn: RPNCalculator): 12 | rpn.stack = [10] 13 | rpn.evaluate("eur2chf") 14 | rpn.evaluate("chf2eur") 15 | assert rpn.stack[-1] == 10 -------------------------------------------------------------------------------- /code/marking/test_parametrization_marks.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import calc 3 | 4 | 5 | @pytest.mark.parametrize( 6 | "a, b, op, expected", [ 7 | pytest.param( 8 | 2, 3, "**", 8, 9 | marks=pytest.mark.xfail(reason="..."), 10 | ), 11 | (1, 2, "+", 3), 12 | (3, 1, "-", 5), 13 | ]) 14 | def test_calc(a, b, op, expected): 15 | assert calc(a, b, op) == expected -------------------------------------------------------------------------------- /code/fixtures/test_fixture_scope.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time 3 | from rpncalc.rpn_v2 import RPNCalculator, Config 4 | 5 | 6 | @pytest.fixture(scope="function") 7 | def rpn() -> RPNCalculator: 8 | time.sleep(2) 9 | return RPNCalculator(Config()) 10 | 11 | def test_a(rpn: RPNCalculator): 12 | rpn.stack.append(42) 13 | assert rpn.stack == [42] 14 | 15 | def test_b(rpn: RPNCalculator): 16 | assert not rpn.stack -------------------------------------------------------------------------------- /code/fixtures/test_parametrize_indirect.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import Config 3 | 4 | 5 | @pytest.fixture 6 | def config(request): 7 | c = Config(prompt=request.param) 8 | return c 9 | 10 | @pytest.mark.parametrize( 11 | "config, length", [ 12 | (">", 1), ("rpn>", 4), 13 | ], 14 | indirect=["config"], 15 | ) 16 | def test_prompt(config: Config, length: int): 17 | assert len(config.prompt) == length -------------------------------------------------------------------------------- /code/fixtures/test_autouse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pathlib 3 | 4 | 5 | class TestEmptyHomedir: 6 | @pytest.fixture(autouse=True) 7 | def tmp_homedir(self, tmp_path, monkeypatch): 8 | monkeypatch.setenv("HOME", str(tmp_path)) 9 | return tmp_path 10 | 11 | def test_a(self, tmp_homedir): 12 | assert not list(tmp_homedir.iterdir()) 13 | 14 | def test_b(self): 15 | assert not list(pathlib.Path.home().iterdir()) -------------------------------------------------------------------------------- /code/mocking/converter/test_vcr.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.convert import Converter 3 | 4 | 5 | @pytest.fixture 6 | def converter() -> Converter: 7 | return Converter() 8 | 9 | 10 | @pytest.mark.vcr 11 | def test_eur2chf(converter: Converter): 12 | assert converter.eur2chf(1) == pytest.approx(0.98, rel=1e-2) 13 | 14 | 15 | @pytest.mark.vcr 16 | def test_chf2eur(converter: Converter): 17 | assert converter.chf2eur(1) == pytest.approx(1.02, rel=1e-2) -------------------------------------------------------------------------------- /code/fixtures/test_fixtures_using_fixtures.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import Config 3 | from rpncalc.rpn_v2 import RPNCalculator 4 | 5 | 6 | @pytest.fixture 7 | def config() -> Config: 8 | return Config() 9 | 10 | @pytest.fixture 11 | def rpn(config: Config) -> RPNCalculator: 12 | return RPNCalculator(config) 13 | 14 | def test_config(config: Config): 15 | assert config.prompt == ">" 16 | 17 | def test_rpn(rpn: RPNCalculator): 18 | assert rpn.stack == [] -------------------------------------------------------------------------------- /code/requirements.txt: -------------------------------------------------------------------------------- 1 | ## Basics 2 | pytest 3 | Pygments 4 | 5 | ## Hypothesis 6 | hypothesis 7 | 8 | ## Mocking 9 | pytest-mock 10 | pytest-recording 11 | pytest-httpserver 12 | requests 13 | responses 14 | 15 | ## Writing plugins / plugin tour 16 | cookiecutter 17 | pytest-cov 18 | pytest-instafail 19 | pytest-rich 20 | pytest-xdist 21 | pytest-asyncio 22 | PyYAML 23 | 24 | ## tox / devpi optional chapters 25 | Sphinx 26 | tox 27 | # devpi-client 28 | 29 | ## Live-demos with Jupyter notebooks 30 | # ipytest 31 | # jupyterlab 32 | -------------------------------------------------------------------------------- /code/fixtures/test_builtin_request_markers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from rpncalc.utils import Config 4 | 5 | 6 | @pytest.fixture 7 | def config(request: pytest.FixtureRequest) -> Config: 8 | marker = request.node.get_closest_marker("long_prompt") 9 | if marker is None: 10 | return Config(prompt=">") 11 | return Config(prompt="rpn>") 12 | 13 | 14 | def test_normal(config: Config): 15 | assert config.prompt == ">" 16 | 17 | 18 | @pytest.mark.long_prompt 19 | def test_marker(config: Config): 20 | assert config.prompt == "rpn>" -------------------------------------------------------------------------------- /code/marking/test_parametrization.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.utils import calc 3 | 4 | 5 | @pytest.mark.parametrize("a, b, expected", [ 6 | (1, 1, 3), 7 | (1, 2, 3), 8 | (2, 3, 5), 9 | ]) 10 | def test_add(a, b, expected): 11 | assert calc(a, b, "+") == expected 12 | 13 | @pytest.mark.parametrize( 14 | "op", ["+", "-", "*", "/", "**"]) 15 | def test_smoke(op): 16 | calc(1, 2, op) 17 | 18 | 19 | @pytest.mark.parametrize("a", [1, 2]) 20 | @pytest.mark.parametrize("b", [3, 4]) 21 | def test_permutations(a, b): 22 | assert calc(a, b, "+") == a + b -------------------------------------------------------------------------------- /code/fixtures/test_yield_fixture.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typing import Iterator 3 | 4 | 5 | class Client: 6 | def connect(self): 7 | print("\nConnecting...") 8 | 9 | def disconnect(self): 10 | print("\nDisconnecting...") 11 | 12 | 13 | @pytest.fixture(scope="function") 14 | def connected_client() -> Iterator[Client]: 15 | client = Client() 16 | client.connect() 17 | yield client 18 | client.disconnect() 19 | 20 | def test_client_1(connected_client: Client): 21 | print("in the test 1") 22 | 23 | def test_client_2(connected_client: Client): 24 | print("in the test 2") -------------------------------------------------------------------------------- /code/fixtures/test_fixture_scope_reset.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import time 3 | from rpncalc.rpn_v2 import RPNCalculator, Config 4 | 5 | 6 | @pytest.fixture(scope="module") 7 | def rpn_instance() -> RPNCalculator: 8 | time.sleep(2) 9 | return RPNCalculator(Config()) 10 | 11 | 12 | @pytest.fixture 13 | def rpn( 14 | rpn_instance: RPNCalculator, 15 | ) -> RPNCalculator: 16 | rpn_instance.stack.clear() 17 | return rpn_instance 18 | 19 | 20 | def test_a(rpn: RPNCalculator): 21 | rpn.stack.append(42) 22 | assert rpn.stack == [42] 23 | 24 | 25 | def test_b(rpn: RPNCalculator): 26 | assert not rpn.stack -------------------------------------------------------------------------------- /code/fixtures/test_fixture_finalizer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class Client: 5 | def connect(self): 6 | print("connect") 7 | 8 | def disconnect(self): 9 | print("disconnect") 10 | 11 | 12 | @pytest.fixture 13 | def connected_client(request: pytest.FixtureRequest) -> Client: 14 | client = Client() 15 | client.connect() 16 | request.addfinalizer(client.disconnect) 17 | return client 18 | 19 | 20 | def test_one(connected_client: Client): 21 | pass 22 | 23 | 24 | def test_two(connected_client: Client): 25 | pass 26 | 27 | 28 | def test_three(connected_client: Client): 29 | pass -------------------------------------------------------------------------------- /code/mocking/test_fake_mock.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_mock 3 | 4 | from rpncalc.utils import Config 5 | from rpncalc.rpn_v3 import RPNCalculator 6 | from rpncalc.convert import Converter 7 | 8 | 9 | @pytest.fixture 10 | def rpn(mocker: pytest_mock.MockerFixture) -> RPNCalculator: 11 | mock = mocker.Mock(spec=Converter) 12 | mock.eur2chf.return_value = 20 13 | mock.chf2eur.return_value = 5 14 | return RPNCalculator(config=Config(), converter=mock) 15 | 16 | def test_convert(rpn: RPNCalculator): 17 | rpn.stack = [10] 18 | rpn.evaluate("eur2chf") 19 | assert rpn.stack == [20] 20 | 21 | rpn.converter.eur2chf.assert_called_once_with(10) -------------------------------------------------------------------------------- /code/mocking/test_fake.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from rpncalc.utils import Config 4 | from rpncalc.rpn_v3 import RPNCalculator 5 | 6 | 7 | class FakeConverter: 8 | RATE = 2 9 | 10 | def eur2chf(self, amount: float) -> float: 11 | return amount * self.RATE 12 | 13 | def chf2eur(self, amount: float) -> float: 14 | return amount / self.RATE 15 | 16 | 17 | @pytest.fixture 18 | def rpn(monkeypatch): 19 | calc = RPNCalculator(Config()) 20 | monkeypatch.setattr( 21 | calc, 22 | "converter", 23 | FakeConverter() 24 | ) 25 | return calc 26 | 27 | def test_convert(rpn): 28 | rpn.stack = [10] 29 | rpn.evaluate("eur2chf") 30 | assert rpn.stack == [20] -------------------------------------------------------------------------------- /code/basic/failure_demo.py: -------------------------------------------------------------------------------- 1 | def test_eq_text(): 2 | assert "spam" == "eggs" 3 | 4 | 5 | def test_eq_similar_text(): 6 | assert "foo 1 bar" == "foo 2 bar" 7 | 8 | 9 | def test_eq_long_text(): 10 | a = "1" * 100 + "a" + "2" * 100 11 | b = "1" * 100 + "b" + "2" * 100 12 | assert a == b 13 | 14 | 15 | def test_eq_list(): 16 | assert [0, 1, 2] == [0, 1, 3] 17 | 18 | 19 | def test_eq_dict(): 20 | assert {"a": 0, "b": 1} == {"a": 0, "b": 2} 21 | 22 | 23 | def test_eq_set(): 24 | assert {0, 10, 11, 12} == {0, 20, 21} 25 | 26 | 27 | def test_eq_longer_list(): 28 | assert [1, 2] == [1, 2, 3] 29 | 30 | 31 | def test_not_in_text_single(): 32 | text = "single foo line" 33 | assert "foo" not in text -------------------------------------------------------------------------------- /code/plugins/test_hypothesis_rpncalc_v3.py: -------------------------------------------------------------------------------- 1 | from hypothesis import given, strategies as st 2 | from rpncalc.rpn_v3 import RPNCalculator, Config 3 | 4 | 5 | @given(st.integers(), st.integers()) 6 | def test_operators(n1, n2): 7 | rpn = RPNCalculator(Config()) 8 | rpn.evaluate(str(n1)) 9 | rpn.evaluate(str(n2)) 10 | rpn.evaluate("+") 11 | assert rpn.stack == [n1 + n2] 12 | 13 | 14 | @given( 15 | st.lists( 16 | st.one_of( 17 | st.integers().map(str), 18 | st.floats().map(str), 19 | st.just("+"), st.just("-"), 20 | st.just("*"), st.just("/"), 21 | ) 22 | ) 23 | ) 24 | def test_usage(inputs): 25 | rpn = RPNCalculator(Config()) 26 | for inp in inputs: 27 | rpn.evaluate(inp) -------------------------------------------------------------------------------- /code/rpncalc/test_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from rpncalc.utils import Config 6 | 7 | 8 | @pytest.fixture 9 | def config(): 10 | return Config() 11 | 12 | 13 | @pytest.fixture 14 | def ini_path(tmp_path: Path) -> Path: 15 | return tmp_path / "rpncalc.ini" 16 | 17 | @pytest.fixture 18 | def config_path(ini_path: Path) -> Path: 19 | contents = ( 20 | "[rpncalc]\n" 21 | "prompt = rpn>" 22 | ) 23 | ini_path.write_text(contents) 24 | return ini_path 25 | 26 | 27 | def test_config_load(config_path: Path, config: Config): 28 | # call config.load(...), ensure that the prompt is set to "rpn>" 29 | ... 30 | 31 | 32 | def test_config_save(ini_path: Path, config: Config): 33 | # call config.save(...), ensure that the ini file is written correctly 34 | ... 35 | 36 | 37 | -------------------------------------------------------------------------------- /code/rpncalc/rpn_v1.py: -------------------------------------------------------------------------------- 1 | from rpncalc.utils import calc 2 | 3 | class RPNCalculator: 4 | def __init__(self) -> None: 5 | self.stack = [] 6 | 7 | def run(self) -> None: 8 | while True: 9 | inp = input("> ") 10 | if inp == "q": 11 | return 12 | elif inp == "p": 13 | print(self.stack) 14 | else: 15 | self.evaluate(inp) 16 | 17 | def evaluate(self, inp: str): 18 | if inp.isdigit(): 19 | n = float(inp) 20 | self.stack.append(n) 21 | elif inp in "+-*/": 22 | b = self.stack.pop() 23 | a = self.stack.pop() 24 | res = calc(a, b, inp) 25 | self.stack.append(res) 26 | print(res) 27 | 28 | 29 | if __name__ == "__main__": 30 | rpn = RPNCalculator() 31 | rpn.run() -------------------------------------------------------------------------------- /code/rpncalc/test_rpn_errors.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | # from rpncalc.rpn_v1 import RPNCalculator 4 | from rpncalc.rpn_v2 import RPNCalculator 5 | from rpncalc.utils import Config 6 | 7 | 8 | @pytest.fixture 9 | def rpn() -> RPNCalculator: 10 | return RPNCalculator(Config()) 11 | 12 | 13 | @pytest.mark.parametrize("op", ["**", "+-"]) 14 | def test_unknown_operator(rpn: RPNCalculator, op: str): 15 | rpn.stack = [1, 2] 16 | rpn.evaluate(op) # FIXME how to test that this prints an error? 17 | 18 | def test_division_by_zero(rpn: RPNCalculator): 19 | rpn.stack = [1, 0] 20 | rpn.evaluate("/") # FIXME how to test that this prints an error? 21 | 22 | @pytest.mark.parametrize("stack", [[1], []]) 23 | def test_not_enough_operands(rpn: RPNCalculator, stack: list[int]): 24 | rpn.stack = stack 25 | rpn.evaluate("+") # FIXME how to test that this prints an error? -------------------------------------------------------------------------------- /code/mocking/converter/test_mock.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_mock 3 | import requests 4 | 5 | from rpncalc.convert import Converter 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def mock_requests_get( 10 | mocker: pytest_mock.MockerFixture, 11 | exchange_data: dict, 12 | ): 13 | mock_get = mocker.patch.object(requests, "get", autospec=True) 14 | mock_get(Converter.API_URL).json.return_value = exchange_data 15 | yield mock_get 16 | mock_get.assert_called_with( 17 | Converter.API_URL, 18 | params=Converter.PARAMS, 19 | headers=Converter.HEADERS 20 | ) 21 | 22 | 23 | @pytest.fixture 24 | def converter() -> Converter: 25 | return Converter() 26 | 27 | 28 | def test_eur2chf(converter: Converter): 29 | assert converter.eur2chf(1) == 2 30 | 31 | 32 | def test_chf2eur(converter: Converter): 33 | assert converter.chf2eur(1) == 0.5 -------------------------------------------------------------------------------- /code/plugins/test_hypothesis.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from hypothesis import given 4 | from hypothesis.strategies import text 5 | 6 | 7 | def decode(lst: list[tuple[int, str]]) -> str: 8 | s = "" 9 | for count, character in lst: 10 | s += count * character 11 | return s 12 | 13 | 14 | def encode(input_string: str) -> list[tuple[int, str]]: 15 | count = 1; prev = ""; lst = [] 16 | for character in input_string: 17 | if character != prev: 18 | if prev: 19 | entry = (count, prev) 20 | lst.append(entry) 21 | count = 1 22 | prev = character 23 | else: 24 | count += 1 25 | entry = (count, character) 26 | lst.append(entry) 27 | return lst 28 | 29 | 30 | @given(text()) 31 | def test_decode_inverts_encode(s: str): 32 | assert decode(encode(s)) == s -------------------------------------------------------------------------------- /code/mocking/converter/test_httpserver.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import pytest_httpserver 3 | import urllib.parse 4 | from rpncalc.convert import Converter 5 | 6 | 7 | @pytest.fixture 8 | def server_url( 9 | httpserver: pytest_httpserver.HTTPServer, exchange_data: dict 10 | ): 11 | url_path = urllib.parse.urlparse(Converter.API_URL).path 12 | 13 | req = httpserver.expect_request( 14 | url_path, query_string=Converter.PARAMS, 15 | headers=Converter.HEADERS, 16 | ) 17 | req.respond_with_json(exchange_data) 18 | 19 | yield httpserver.url_for(url_path) 20 | httpserver.check() 21 | 22 | 23 | @pytest.fixture 24 | def converter( 25 | server_url: str, # e.g. http://localhost:41475/v1/currencies/... 26 | monkeypatch: pytest.MonkeyPatch, 27 | ) -> Converter: 28 | conv = Converter() 29 | monkeypatch.setattr(conv, "API_URL", server_url) 30 | return conv 31 | 32 | def test_eur2chf(converter: Converter): 33 | assert converter.eur2chf(1) == 2 34 | 35 | def test_chf2eur(converter: Converter): 36 | assert converter.chf2eur(1) == 0.5 -------------------------------------------------------------------------------- /code/fixtures/test_builtin_monkeypatch.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import getpass 4 | import pytest 5 | 6 | 7 | def print_info(): 8 | path = os.environ.get("PATH", "") 9 | print(f"platform: {sys.platform}") 10 | print(f"PATH: {path}") 11 | 12 | def test_a(monkeypatch: pytest.MonkeyPatch): 13 | monkeypatch.setattr(sys, "platform", "MonkeyOS") 14 | monkeypatch.setenv("PATH", "/zoo") 15 | print_info() 16 | assert False 17 | 18 | def test_b(): 19 | print_info() 20 | assert False 21 | 22 | 23 | def get_folder_name() -> str: 24 | user = getpass.getuser() 25 | return f"pytest-of-{user}" 26 | 27 | 28 | def fake_getuser() -> str: 29 | return "fakeuser" 30 | 31 | def test_get_folder_name(monkeypatch: pytest.MonkeyPatch): 32 | monkeypatch.setattr(getpass, "getuser", fake_getuser) 33 | assert get_folder_name() == "pytest-of-fakeuser" 34 | 35 | 36 | def test_get_folder_name_lambda(monkeypatch: pytest.MonkeyPatch): 37 | monkeypatch.setattr(getpass, "getuser", lambda: "fakeuser") 38 | assert get_folder_name() == "pytest-of-fakeuser" -------------------------------------------------------------------------------- /code/rpncalc/convert.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | try: 4 | from functools import cache 5 | except ImportError: 6 | # Python 3.7 / 3.8 7 | from functools import lru_cache 8 | cache = lru_cache(maxsize=None) 9 | 10 | 11 | class Converter: 12 | API_URL = "https://api.exchangeit.app/v1/currencies/eur/latest" 13 | HEADERS = {"User-Agent": "rpncalc/0.1 (florian@bruhin.software)"} 14 | PARAMS = {"for": "chf"} 15 | 16 | def eur2chf(self, amount: float) -> float: 17 | eur2chf_rate = self._fetch() 18 | return amount * eur2chf_rate 19 | 20 | def chf2eur(self, amount: float) -> float: 21 | eur2chf_rate = self._fetch() 22 | return amount / eur2chf_rate 23 | 24 | @cache 25 | def _fetch(self) -> float: 26 | print("Fetching exchange rates...") 27 | response = requests.get( 28 | self.API_URL, 29 | params=self.PARAMS, 30 | headers=self.HEADERS, 31 | ) 32 | response.raise_for_status() 33 | d = response.json() 34 | rates = d["data"]["rates"] 35 | return rates[0]["rate"] -------------------------------------------------------------------------------- /code/mocking/converter/test_monkeypatch.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import requests 3 | from rpncalc.convert import Converter 4 | 5 | # mocking/converter/ 6 | # test_monkeypatch.py 7 | 8 | 9 | class FakeResponse: 10 | def __init__(self, data): 11 | self._data = data 12 | 13 | def raise_for_status(self): 14 | pass 15 | 16 | def json(self): 17 | return self._data 18 | 19 | 20 | def fake_get(url: str, params: dict, headers: dict) -> FakeResponse: 21 | assert url == Converter.API_URL 22 | assert params == Converter.PARAMS 23 | assert headers == Converter.HEADERS 24 | rates = [{"alias": "chf", "rate": 2}] 25 | return FakeResponse({"data": {"alias": "eur", "rates": rates}}) 26 | 27 | 28 | @pytest.fixture(autouse=True) 29 | def patch_requests_get(monkeypatch: pytest.MonkeyPatch) -> None: 30 | monkeypatch.setattr(requests, "get", fake_get) 31 | 32 | 33 | @pytest.fixture 34 | def converter() -> Converter: 35 | return Converter() 36 | 37 | 38 | def test_eur2chf(converter: Converter): 39 | assert converter.eur2chf(1) == 2 40 | 41 | def test_chf2eur(converter: Converter): 42 | assert converter.chf2eur(1) == 0.5 -------------------------------------------------------------------------------- /code/pytest.ini: -------------------------------------------------------------------------------- 1 | # pytest.ini 2 | 3 | [pytest] 4 | ### Configure where code can be imported from 5 | ## Make things importable from the current directory. 6 | ## Usually set to "src" with the src-layout. 7 | ## For the exercises, we want to do "import calc" 8 | ## in all tests without a separate folder. 9 | pythonpath = . 10 | 11 | ### Recommended strictness settings 12 | ## - --strict-markers turns warnings into errors for markers not declared below. 13 | ## - --strict-config turns missing config settings into errors. 14 | ## - xfail_strict turns XPASS tests (expected to fail but passed) into errors. 15 | ## Commented out here to show default behaviors 16 | # addopts = --strict-markers --strict-config 17 | # xfail_strict = true 18 | 19 | ### Plugin disables 20 | # We disable pytest-recording (mocking chapter) and pytest-xdist (plugin tour), 21 | # as they run various # fixtures automatically (autouse=True), which interferes 22 | # with exercises. 23 | addopts = -p no:recording -p no:asyncio 24 | 25 | ### For marking/ 26 | markers = 27 | slow: Tests which take some time to run 28 | webtest: Tests making web requests 29 | 30 | ### To avoid collecting things from tox/ 31 | norecursedirs = .* venv tox 32 | -------------------------------------------------------------------------------- /code/mocking/converter/test_responses.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rpncalc.convert import Converter 3 | 4 | from responses import RequestsMock, matchers 5 | 6 | @pytest.fixture(autouse=True) 7 | def patch_requests_get( 8 | responses: RequestsMock, exchange_data: dict 9 | ) -> None: 10 | responses.get( 11 | Converter.API_URL, 12 | json=exchange_data, 13 | match=[ 14 | matchers.query_param_matcher(Converter.PARAMS), 15 | matchers.header_matcher(Converter.HEADERS), 16 | ], 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def responses(): 22 | # NOTE: Normally you would use the `responses` fixture from the 23 | # pytest-responses plugin instead of hand-rolling it like here. 24 | # 25 | # However, the plugin assumes that it's responsible for all requests (and 26 | # all of them should be mocked using it). 27 | # 28 | # Since we want to demonstrate other ways too, we avoid installing it. 29 | with RequestsMock() as rm: 30 | yield rm 31 | 32 | 33 | @pytest.fixture 34 | def converter() -> Converter: 35 | return Converter() 36 | 37 | 38 | def test_eur2chf(converter: Converter): 39 | assert converter.eur2chf(1) == 2 40 | 41 | 42 | def test_chf2eur(converter: Converter): 43 | assert converter.chf2eur(1) == 0.5 -------------------------------------------------------------------------------- /code/rpncalc/utils.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import configparser 3 | import os 4 | 5 | 6 | def calc(a, b, op): 7 | if op == "+": 8 | return a + b 9 | elif op == "-": 10 | return a - b 11 | elif op == "*": 12 | return a * b 13 | elif op == "/": 14 | return a / b 15 | raise ValueError("Invalid operator") 16 | 17 | 18 | class Config: 19 | def __init__(self, prompt=">"): 20 | self.prompt = prompt 21 | 22 | def __repr__(self) -> str: 23 | return f"Config(prompt={self.prompt!r})" 24 | 25 | def load(self, path: pathlib.Path) -> None: 26 | parser = configparser.ConfigParser() 27 | parser.read(path) 28 | self.prompt = parser["rpncalc"]["prompt"] 29 | 30 | def save(self, path: pathlib.Path) -> None: 31 | parser = configparser.ConfigParser() 32 | parser["rpncalc"] = {"prompt": self.prompt} 33 | with path.open("w") as f: 34 | parser.write(f) 35 | 36 | def load_env(self) -> None: 37 | var = "RPNCALC_CONFIG_DIR" 38 | config_dir = os.environ.get(var) 39 | if not config_dir: 40 | return 41 | path = pathlib.Path(config_dir) 42 | ini_path = path / "rpncalc.ini" 43 | if not ini_path.exists(): 44 | raise FileNotFoundError( 45 | f"{ini_path} not found") 46 | self.load(ini_path) -------------------------------------------------------------------------------- /code/rpncalc/test_rpn_v2.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from rpncalc.rpn_v2 import RPNCalculator 6 | from rpncalc.utils import Config 7 | 8 | 9 | def test_complex_example(): 10 | rpn = RPNCalculator(Config()) 11 | 12 | rpn.evaluate("1") 13 | assert rpn.stack == [1] 14 | rpn.evaluate("2") 15 | assert rpn.stack == [1, 2] 16 | rpn.evaluate("+") 17 | assert rpn.stack == [3] 18 | rpn.evaluate("5") 19 | assert rpn.stack == [3, 5] 20 | rpn.evaluate("*") 21 | assert rpn.stack == [15] 22 | 23 | 24 | def test_stack_push(): 25 | rpn = RPNCalculator(Config()) 26 | rpn.evaluate("1") 27 | rpn.evaluate("2") 28 | assert rpn.stack == [1, 2] 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "op, expected", 33 | [ 34 | ("+", 3), 35 | ("-", -1), 36 | ("*", 2), 37 | ("/", 0.5), 38 | ], 39 | ) 40 | def test_operations(op, expected): 41 | rpn = RPNCalculator(Config()) 42 | rpn.stack = [1, 2] 43 | rpn.evaluate(op) 44 | assert rpn.stack == [expected] 45 | 46 | 47 | @pytest.mark.parametrize("n", [1.5, -1]) 48 | def test_number_input(n): 49 | rpn = RPNCalculator(Config()) 50 | rpn.evaluate(str(n)) 51 | assert rpn.stack == [n] 52 | 53 | 54 | @pytest.mark.parametrize( 55 | "op", ["**", "+-"]) 56 | def test_unknown_operator(op: str): 57 | rpn = RPNCalculator(Config()) 58 | rpn.stack = [1, 2] 59 | rpn.evaluate(op) -------------------------------------------------------------------------------- /code/hooks/yaml/pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=61.0.0", 4 | ] 5 | build-backend = "setuptools.build_meta" 6 | 7 | [project] 8 | name = "pytest-yamlcalc" 9 | description = "yaml-based rpn calculator tests" 10 | version = "0.1.0" 11 | readme = "README.rst" 12 | requires-python = ">=3.8" 13 | authors = [ 14 | { name = "yourname", email = "you@example.com" }, 15 | ] 16 | maintainers = [ 17 | { name = "yourname", email = "you@example.com" }, 18 | ] 19 | license = {file = "LICENSE"} 20 | classifiers = [ 21 | "Framework :: Pytest", 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "Topic :: Software Development :: Testing", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python", 27 | "Programming Language :: Python :: 3.8", 28 | "Programming Language :: Python :: 3.9", 29 | "Programming Language :: Python :: 3.10", 30 | "Programming Language :: Python :: 3.11", 31 | "Programming Language :: Python :: 3.12", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Programming Language :: Python :: Implementation :: CPython", 34 | "Programming Language :: Python :: Implementation :: PyPy", 35 | "License :: OSI Approved :: MIT License", 36 | ] 37 | dependencies = [ 38 | "pytest>=7.0.0", 39 | ] 40 | [project.urls] 41 | Repository = "https://github.com/yourname/pytest-yamlcalc" 42 | [project.entry-points.pytest11] 43 | yamlcalc = "pytest_yamlcalc.plugin" 44 | -------------------------------------------------------------------------------- /code/rpncalc/rpn_v2.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | from rpncalc.utils import calc, Config 6 | 7 | class RPNCalculator: 8 | def __init__(self, config): 9 | self.config = config 10 | self.stack = [] 11 | 12 | def get_inputs(self) -> list[str]: 13 | inp = input(self.config.prompt + " ") 14 | return inp.split() 15 | 16 | def run(self) -> None: 17 | while True: 18 | for inp in self.get_inputs(): 19 | if inp == "q": 20 | return 21 | elif inp == "p": 22 | print(self.stack) 23 | else: 24 | self.evaluate(inp) 25 | 26 | def err(self, msg: str) -> None: 27 | print(msg, file=sys.stderr) 28 | 29 | def evaluate(self, inp: str) -> None: 30 | try: 31 | self.stack.append(float(inp)) 32 | return 33 | except ValueError: 34 | pass 35 | 36 | if inp not in ["+", "-", "*", "/"]: 37 | self.err( 38 | f"Invalid input: {inp}") 39 | return 40 | 41 | if len(self.stack) < 2: 42 | self.err("Not enough operands") 43 | return 44 | 45 | b = self.stack.pop() 46 | a = self.stack.pop() 47 | 48 | try: 49 | res = calc(a, b, inp) 50 | except ZeroDivisionError: 51 | self.err("Division by zero") 52 | return 53 | 54 | self.stack.append(res) 55 | print(res) 56 | 57 | 58 | if __name__ == "__main__": 59 | config = Config() 60 | config.load_env() 61 | rpn = RPNCalculator(config) 62 | rpn.run() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest tips and tricks for a better testsuite 2 | 3 | ## Note 4 | 5 | The provided code is for 3 days of training, so we're not going to use all of 6 | it. If you want to prepare, take a look at `rpncalc/` (especially `rpn_v2.py` 7 | and `utils.py`), which is a small example project we'll use in the training. 8 | 9 | ## Setup instructions 10 | 11 | - We'll be using pytest on the commandline for the training. 12 | - If you use PyCharm: 13 | - Open the `code/` folder as a project 14 | - Open `basic/test_calc.py`, configure Python interpreter 15 | - Wait until "Install requirements" prompt appears and accept 16 | - Open a terminal inside PyCharm 17 | - If you use VS Code: 18 | - Open the `code/` folder as a project 19 | - Ctrl-Shift-P to open command palette, run "Python: Create Environment..." 20 | - Select `venv` and `requirements.txt` for installation 21 | - Open a terminal inside VS Code 22 | - Manual setup: 23 | - [Create a virtualenv](https://chriswarrick.com/blog/2018/09/04/python-virtual-environments/) and activate it (or substitute tool paths below) 24 | - `pip install -r code/requirements.txt` 25 | - Check everything works: 26 | - Check `python3 --version` (Windows: `py -3 --version`), make sure you run 3.8 or newer. 27 | - Check `pytest --version`, you should see 8.2.x ideally (7.0+ is ok) 28 | - In case of trouble/questions, please feel free to ask! Any of these will work fine: 29 | - [`@thecompiler` on Telegram](https://telegram.me/thecompiler) 30 | - [`florian@bruhin.software`](mailto:florian@bruhin.software) 31 | - IRC: `The-Compiler` on [Libera Chat](https://libera.chat/) 32 | - [`@the_compiler` on Discord](https://discord.com/users/329364263896481802) (e.g. Python Discord or PyConDE) 33 | -------------------------------------------------------------------------------- /code/rpncalc/test_rpn_v1.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from rpncalc.rpn_v1 import RPNCalculator 4 | 5 | 6 | def test_complex_example(): 7 | rpn = RPNCalculator() 8 | 9 | rpn.evaluate("1") 10 | assert rpn.stack == [1] 11 | rpn.evaluate("2") 12 | assert rpn.stack == [1, 2] 13 | rpn.evaluate("+") 14 | assert rpn.stack == [3] 15 | rpn.evaluate("5") 16 | assert rpn.stack == [3, 5] 17 | rpn.evaluate("*") 18 | assert rpn.stack == [15] 19 | 20 | 21 | def test_stack_push(): 22 | rpn = RPNCalculator() 23 | rpn.evaluate("1") 24 | rpn.evaluate("2") 25 | assert rpn.stack == [1, 2] 26 | 27 | @pytest.mark.parametrize("op, expected", [ 28 | ("+", 3), ("-", -1), 29 | ("*", 2), ("/", 0.5), 30 | ]) 31 | def test_operations(op, expected): 32 | rpn = RPNCalculator() 33 | rpn.stack = [1, 2] 34 | rpn.evaluate(op) 35 | assert rpn.stack == [expected] 36 | 37 | 38 | @pytest.mark.skip(reason="TODO: Error handling") 39 | def test_unknown_operator(): 40 | rpn = RPNCalculator() 41 | rpn.stack = [1, 2] 42 | 43 | rpn.evaluate("**") 44 | # FIXME how do we test that this printed an error? 45 | 46 | 47 | @pytest.mark.skip(reason="TODO: Error handling") 48 | def test_division_by_zero(): 49 | rpn = RPNCalculator() 50 | rpn.stack = [1, 0] 51 | 52 | rpn.evaluate("/") 53 | # FIXME how do we test that this printed an error? 54 | 55 | 56 | @pytest.mark.skip(reason="TODO: Error handling") 57 | def test_not_enough_operands(): 58 | rpn = RPNCalculator() 59 | rpn.stack = [1] 60 | 61 | rpn.evaluate("+") 62 | # FIXME how do we test that this printed an error? 63 | 64 | 65 | @pytest.mark.skip(reason="FIXME: Should this be possible?") 66 | def test_float_input(): 67 | rpn = RPNCalculator() 68 | rpn.evaluate("1.5") -------------------------------------------------------------------------------- /code/mocking/converter/cassettes/test_vcr/test_eur2chf.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - rpncalc/0.1 (florian@bruhin.software) 13 | method: GET 14 | uri: https://api.exchangeit.app/v1/currencies/eur/latest?for=chf 15 | response: 16 | body: 17 | string: '{"copyright":{"title":"Thank you for choosing Exchange It API","description":"We''re 18 | excited to see the incredible things you''ll achieve with it. If you have 19 | any questions or need support, our team is here to help.","url":"https://exchangeit.app","email":"hello@exchangeit.app"},"data":{"alias":"eur","title":"Euro","rates":[{"alias":"chf","rate":0.9771274994,"date":"2024-05-09T23:59:59.000Z","fluctuation":0.0009838531}]}}' 20 | headers: 21 | Access-Control-Allow-Origin: 22 | - '*' 23 | CF-Cache-Status: 24 | - DYNAMIC 25 | CF-RAY: 26 | - 881a44a6593730e8-FRA 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Fri, 10 May 2024 13:27:41 GMT 35 | ETag: 36 | - W/"1a7-5bjLyp+Adq71WOtkIUH/xusZt48" 37 | NEL: 38 | - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' 39 | Report-To: 40 | - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2BS8Xai1sOQG1zRIvBXAJIyI8qR3IkmrZW1ev8RtwenpUGWfrBntCQvGHU0cQhJd1WXlLU8LqRMn0wjE6LIKJ2LAUL5g4GxPlnM2kf0lOFmpDDoD6yR9Jng766xdXd80mq08wAYNy5OdWLDcnpDm9p1E%3D"}],"group":"cf-nel","max_age":604800}' 41 | Server: 42 | - cloudflare 43 | Transfer-Encoding: 44 | - chunked 45 | X-Powered-By: 46 | - Express 47 | X-Served-By: 48 | - api.exchangeit.app 49 | alt-svc: 50 | - h3=":443"; ma=86400 51 | status: 52 | code: 200 53 | message: OK 54 | version: 1 55 | -------------------------------------------------------------------------------- /code/mocking/converter/cassettes/test_vcr/test_chf2eur.yaml: -------------------------------------------------------------------------------- 1 | interactions: 2 | - request: 3 | body: null 4 | headers: 5 | Accept: 6 | - '*/*' 7 | Accept-Encoding: 8 | - gzip, deflate 9 | Connection: 10 | - keep-alive 11 | User-Agent: 12 | - rpncalc/0.1 (florian@bruhin.software) 13 | method: GET 14 | uri: https://api.exchangeit.app/v1/currencies/eur/latest?for=chf 15 | response: 16 | body: 17 | string: '{"copyright":{"title":"Thank you for choosing Exchange It API","description":"We''re 18 | excited to see the incredible things you''ll achieve with it. If you have 19 | any questions or need support, our team is here to help.","url":"https://exchangeit.app","email":"hello@exchangeit.app"},"data":{"alias":"eur","title":"Euro","rates":[{"alias":"chf","rate":0.9771274994,"date":"2024-05-09T23:59:59.000Z","fluctuation":0.0009838531}]}}' 20 | headers: 21 | Access-Control-Allow-Origin: 22 | - '*' 23 | CF-Cache-Status: 24 | - DYNAMIC 25 | CF-RAY: 26 | - 881a44a7d868bba3-FRA 27 | Connection: 28 | - keep-alive 29 | Content-Encoding: 30 | - gzip 31 | Content-Type: 32 | - application/json; charset=utf-8 33 | Date: 34 | - Fri, 10 May 2024 13:27:42 GMT 35 | ETag: 36 | - W/"1a7-5bjLyp+Adq71WOtkIUH/xusZt48" 37 | NEL: 38 | - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' 39 | Report-To: 40 | - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=0JmenNOMnl4u3BubbggtG0sRNvLC5uU2%2FqCmo8XACL6hUst92LzJIyMRgYLja%2FrOSjIeVtbEEH3VbecPiZ%2FMbrH%2F%2B7kZcz3VhfOHQ1zSXa28Eh2lVRQrBnG%2BJhC8EoJtwJ1IhYyVajyeD3LELvl4tLQ%3D"}],"group":"cf-nel","max_age":604800}' 41 | Server: 42 | - cloudflare 43 | Transfer-Encoding: 44 | - chunked 45 | X-Powered-By: 46 | - Express 47 | X-Served-By: 48 | - api.exchangeit.app 49 | alt-svc: 50 | - h3=":443"; ma=86400 51 | status: 52 | code: 200 53 | message: OK 54 | version: 1 55 | -------------------------------------------------------------------------------- /code/rpncalc/rpn_v3.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from rpncalc.utils import calc, Config 4 | from rpncalc.convert import Converter 5 | 6 | 7 | class RPNCalculator: 8 | def __init__(self, config): 9 | self.converter = Converter() 10 | self.config = config 11 | self.stack = [] 12 | 13 | def get_inputs(self) -> list[str]: 14 | inp = input(self.config.prompt + " ") 15 | return inp.split() 16 | 17 | def run(self) -> None: 18 | while True: 19 | for inp in self.get_inputs(): 20 | if inp == "q": 21 | return 22 | elif inp == "p": 23 | print(self.stack) 24 | else: 25 | self.evaluate(inp) 26 | 27 | def _evaluate_convert(self, inp: str) -> None: 28 | try: 29 | amount = self.stack.pop() 30 | except IndexError: 31 | print("Not enough operands") 32 | return 33 | 34 | if inp == "eur2chf": 35 | res = self.converter.eur2chf(amount) 36 | elif inp == "chf2eur": 37 | res = self.converter.chf2eur(amount) 38 | 39 | self.stack.append(res) 40 | print(f"{res:.2f}") 41 | 42 | def evaluate(self, inp: str) -> None: 43 | try: 44 | self.stack.append(float(inp)) 45 | return 46 | except ValueError: 47 | pass 48 | 49 | if inp in ["eur2chf", "chf2eur"]: 50 | self._evaluate_convert(inp) 51 | return 52 | elif inp not in ["+", "-", "*", "/"]: 53 | print(f"Invalid input: {inp}") 54 | return 55 | 56 | if len(self.stack) < 2: 57 | print("Not enough operands") 58 | return 59 | 60 | b = self.stack.pop() 61 | a = self.stack.pop() 62 | 63 | try: 64 | res = calc(a, b, inp) 65 | except ZeroDivisionError: 66 | print("Division by zero") 67 | return 68 | 69 | self.stack.append(res) 70 | print(res) 71 | 72 | 73 | if __name__ == "__main__": 74 | config = Config() 75 | config.load_env() 76 | rpn = RPNCalculator(config) 77 | rpn.run() -------------------------------------------------------------------------------- /code/hooks/yaml/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pathlib 4 | from typing import Iterator, Any 5 | 6 | import yaml 7 | import pytest 8 | 9 | from rpncalc.rpn_v3 import RPNCalculator, Config 10 | 11 | 12 | class YamlException(Exception): 13 | """Custom exception for error reporting.""" 14 | 15 | 16 | def pytest_collect_file( 17 | parent: pytest.Collector, 18 | file_path: pathlib.Path, 19 | ) -> pytest.Collector | None: 20 | """Hook into pytest to collect test*.yml files.""" 21 | if ( 22 | file_path.name.startswith("test") 23 | and file_path.suffix == ".yml" 24 | ): 25 | return YamlFile.from_parent(parent, path=file_path) 26 | return None 27 | 28 | 29 | class YamlFile(pytest.File): 30 | """Internal representation of a YAML test file.""" 31 | 32 | def collect(self) -> Iterator[YamlItem]: 33 | with self.path.open("r") as f: 34 | raw = yaml.safe_load(f) 35 | 36 | for spec in raw: 37 | name = spec["name"] 38 | yield YamlItem.from_parent(self, name=name, spec=spec) 39 | 40 | 41 | class YamlItem(pytest.Item): 42 | """Internal representation of a single test.""" 43 | 44 | def __init__(self, name: str, parent: YamlFile, spec: dict[str, Any]): 45 | super().__init__(name, parent) 46 | self.spec = spec 47 | 48 | def runtest(self) -> None: 49 | """Run the test, raise exceptions on issues.""" 50 | rpn = RPNCalculator(Config()) 51 | try: 52 | inputs = self.spec["inputs"] 53 | stack = self.spec["stack"] 54 | except KeyError as e: 55 | raise YamlException(f"Missing key: {e}") 56 | 57 | for inp in inputs: 58 | rpn.evaluate(inp) 59 | 60 | assert rpn.stack == stack 61 | 62 | def repr_failure(self, excinfo: pytest.ExceptionInfo) -> str: 63 | """Called when self.runtest() raises an exception.""" 64 | if isinstance(excinfo.value, YamlException): 65 | return f"Invalid YAML: {excinfo.value}" 66 | return super().repr_failure(excinfo) 67 | 68 | def reportinfo(self) -> tuple[str, int, str]: 69 | """Return the path/linenumber/text to show.""" 70 | return self.fspath, 0, f"usecase: {self.name}" -------------------------------------------------------------------------------- /demos/ep2024.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "d47fcb20-03c2-4710-9d26-66705b273860", 6 | "metadata": {}, 7 | "source": [ 8 | "# pytest.raises" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": 14, 14 | "id": "5901ebc5-6eb5-495c-a160-077d58313cb8", 15 | "metadata": {}, 16 | "outputs": [ 17 | { 18 | "name": "stdout", 19 | "output_type": "stream", 20 | "text": [ 21 | "\u001b[1m======================================= test session starts ========================================\u001b[0m\n", 22 | "collected 3 items\n", 23 | "\n", 24 | "t_6947442cdcf74a3696aef7e735f8c8a1.py \u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[31mF\u001b[0m\u001b[31m [100%]\u001b[0m\n", 25 | "\n", 26 | "============================================= FAILURES =============================================\n", 27 | "\u001b[31m\u001b[1m__________________________________________ test_negative ___________________________________________\u001b[0m\n", 28 | "\n", 29 | " \u001b[0m\u001b[94mdef\u001b[39;49;00m \u001b[92mtest_negative\u001b[39;49;00m():\u001b[90m\u001b[39;49;00m\n", 30 | " \u001b[94mwith\u001b[39;49;00m pytest.raises(\u001b[96mValueError\u001b[39;49;00m, match=\u001b[33m\"\u001b[39;49;00m\u001b[33mNo negativity allowed\u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m):\u001b[90m\u001b[39;49;00m\n", 31 | "> parse_pos_int(\u001b[33m\"\u001b[39;49;00m\u001b[33m-5a\u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m)\u001b[90m\u001b[39;49;00m\n", 32 | "\n", 33 | "\u001b[1m\u001b[31m/tmp/ipykernel_486516/4055813075.py\u001b[0m:18: \n", 34 | "_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ \n", 35 | "\n", 36 | "s = '-5a'\n", 37 | "\n", 38 | " \u001b[0m\u001b[94mdef\u001b[39;49;00m \u001b[92mparse_pos_int\u001b[39;49;00m(s):\u001b[90m\u001b[39;49;00m\n", 39 | "> n = \u001b[96mint\u001b[39;49;00m(s)\u001b[90m\u001b[39;49;00m\n", 40 | "\u001b[1m\u001b[31mE ValueError: invalid literal for int() with base 10: '-5a'\u001b[0m\n", 41 | "\n", 42 | "\u001b[1m\u001b[31m/tmp/ipykernel_486516/4055813075.py\u001b[0m:4: ValueError\n", 43 | "\n", 44 | "\u001b[33mDuring handling of the above exception, another exception occurred:\u001b[0m\n", 45 | "\n", 46 | " \u001b[0m\u001b[94mdef\u001b[39;49;00m \u001b[92mtest_negative\u001b[39;49;00m():\u001b[90m\u001b[39;49;00m\n", 47 | "> \u001b[94mwith\u001b[39;49;00m pytest.raises(\u001b[96mValueError\u001b[39;49;00m, match=\u001b[33m\"\u001b[39;49;00m\u001b[33mNo negativity allowed\u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m):\u001b[90m\u001b[39;49;00m\n", 48 | "\u001b[1m\u001b[31mE AssertionError: Regex pattern did not match.\u001b[0m\n", 49 | "\u001b[1m\u001b[31mE Regex: 'No negativity allowed'\u001b[0m\n", 50 | "\u001b[1m\u001b[31mE Input: \"invalid literal for int() with base 10: '-5a'\"\u001b[0m\n", 51 | "\n", 52 | "\u001b[1m\u001b[31m/tmp/ipykernel_486516/4055813075.py\u001b[0m:17: AssertionError\n", 53 | "\u001b[36m\u001b[1m===================================== short test summary info ======================================\u001b[0m\n", 54 | "\u001b[31mFAILED\u001b[0m t_6947442cdcf74a3696aef7e735f8c8a1.py::\u001b[1mtest_negative\u001b[0m - AssertionError: Regex pattern did not match.\n", 55 | "\u001b[31m=================================== \u001b[31m\u001b[1m1 failed\u001b[0m, \u001b[32m2 passed\u001b[0m\u001b[31m in 0.02s\u001b[0m\u001b[31m ====================================\u001b[0m\n" 56 | ] 57 | } 58 | ], 59 | "source": [ 60 | "%%ipytest\n", 61 | "import pytest\n", 62 | "\n", 63 | "def parse_pos_int(s):\n", 64 | " n = int(s)\n", 65 | " if n < 0:\n", 66 | " raise ValueError(\"No negativity allowed, positive vibes only!\")\n", 67 | " return n\n", 68 | "\n", 69 | "def test_ok():\n", 70 | " assert parse_pos_int(\"5\") == 5\n", 71 | "\n", 72 | "def test_not_an_int():\n", 73 | " with pytest.raises(ValueError):\n", 74 | " parse_pos_int(\"x\")\n", 75 | "\n", 76 | "def test_negative():\n", 77 | " with pytest.raises(ValueError, match=\"No negativity allowed\"):\n", 78 | " parse_pos_int(\"-5\")" 79 | ] 80 | }, 81 | { 82 | "cell_type": "markdown", 83 | "id": "f1ce4adc-ee3b-469b-ac26-a87fb185dd0e", 84 | "metadata": {}, 85 | "source": [ 86 | "# Marks\n", 87 | "\n", 88 | "Audience question: How to parametrize but only have one test case raise something?" 89 | ] 90 | }, 91 | { 92 | "cell_type": "code", 93 | "execution_count": 20, 94 | "id": "07e82c69-b3a9-45ce-94a3-180718a80d45", 95 | "metadata": {}, 96 | "outputs": [ 97 | { 98 | "name": "stdout", 99 | "output_type": "stream", 100 | "text": [ 101 | "\u001b[1m======================================= test session starts ========================================\u001b[0m\n", 102 | "collected 4 items\n", 103 | "\n", 104 | "t_6947442cdcf74a3696aef7e735f8c8a1.py \u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m [100%]\u001b[0m\n", 105 | "\n", 106 | "\u001b[32m======================================== \u001b[32m\u001b[1m4 passed\u001b[0m\u001b[32m in 0.02s\u001b[0m\u001b[32m =========================================\u001b[0m\n" 107 | ] 108 | } 109 | ], 110 | "source": [ 111 | "%%ipytest\n", 112 | "import pytest\n", 113 | "\n", 114 | "@pytest.mark.parametrize(\"s, n\", [\n", 115 | " (\"5\", 5),\n", 116 | " (\"8\", 8),\n", 117 | "])\n", 118 | "def test_good(s, n):\n", 119 | " assert parse_pos_int(s) == n\n", 120 | "\n", 121 | "\n", 122 | "@pytest.mark.parametrize(\"s, message\", [\n", 123 | " (\"-1\", \"No negativity allowed\"),\n", 124 | " (\"a\", \"invalid literal for int\"),\n", 125 | "])\n", 126 | "def test_bad(s, message):\n", 127 | " with pytest.raises(ValueError, match=message):\n", 128 | " parse_pos_int(s)" 129 | ] 130 | }, 131 | { 132 | "cell_type": "code", 133 | "execution_count": 24, 134 | "id": "57d45e98-dca2-45a0-890f-4a44488722eb", 135 | "metadata": {}, 136 | "outputs": [ 137 | { 138 | "name": "stdout", 139 | "output_type": "stream", 140 | "text": [ 141 | "\u001b[1m======================================= test session starts ========================================\u001b[0m\n", 142 | "collected 4 items\n", 143 | "\n", 144 | "t_6947442cdcf74a3696aef7e735f8c8a1.py \u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m.\u001b[0m\u001b[32m [100%]\u001b[0m\n", 145 | "\n", 146 | "\u001b[32m======================================== \u001b[32m\u001b[1m4 passed\u001b[0m\u001b[32m in 0.02s\u001b[0m\u001b[32m =========================================\u001b[0m\n" 147 | ] 148 | } 149 | ], 150 | "source": [ 151 | "%%ipytest\n", 152 | "import pytest\n", 153 | "from contextlib import nullcontext\n", 154 | "\n", 155 | "@pytest.mark.parametrize(\"inp, expectation\", [\n", 156 | " (\"5\", nullcontext()),\n", 157 | " (\"8\", nullcontext()),\n", 158 | " (\"-1\", pytest.raises(ValueError)),\n", 159 | " (\"a\", pytest.raises(ValueError)),\n", 160 | "])\n", 161 | "def test_smoke(inp, expectation):\n", 162 | " with expectation:\n", 163 | " parse_pos_int(inp)" 164 | ] 165 | }, 166 | { 167 | "cell_type": "markdown", 168 | "id": "fb0852d1-dad5-45f1-80f8-3680cc346ed7", 169 | "metadata": {}, 170 | "source": [ 171 | "[Flaky tests · Issue #5390 · qutebrowser/qutebrowser](https://github.com/qutebrowser/qutebrowser/issues/5390)" 172 | ] 173 | }, 174 | { 175 | "cell_type": "markdown", 176 | "id": "fdf477eb-5b4e-49b3-a515-e3936f9246b2", 177 | "metadata": {}, 178 | "source": [ 179 | "# Fixtures" 180 | ] 181 | }, 182 | { 183 | "cell_type": "code", 184 | "execution_count": 32, 185 | "id": "a0b6a0fb-7faa-4ca2-ba72-09488f99ae82", 186 | "metadata": {}, 187 | "outputs": [ 188 | { 189 | "name": "stdout", 190 | "output_type": "stream", 191 | "text": [ 192 | "\u001b[1m======================================= test session starts ========================================\u001b[0m\n", 193 | "collected 2 items\n", 194 | "\n", 195 | "t_6947442cdcf74a3696aef7e735f8c8a1.py \u001b[32m.\u001b[0m\u001b[31mE\u001b[0m\u001b[31m [100%]\u001b[0m\n", 196 | "\n", 197 | "============================================== ERRORS ==============================================\n", 198 | "\u001b[31m\u001b[1m__________________________________ ERROR at setup of test_marker ___________________________________\u001b[0m\n", 199 | "\n", 200 | "request = >\n", 201 | "\n", 202 | " \u001b[0m\u001b[37m@pytest\u001b[39;49;00m.fixture\u001b[90m\u001b[39;49;00m\n", 203 | " \u001b[94mdef\u001b[39;49;00m \u001b[92mconfig\u001b[39;49;00m(request: pytest.FixtureRequest) -> Config:\u001b[90m\u001b[39;49;00m\n", 204 | " marker = request.node.get_closest_marker(\u001b[33m\"\u001b[39;49;00m\u001b[33mprompt\u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m)\u001b[90m\u001b[39;49;00m\n", 205 | " \u001b[94mif\u001b[39;49;00m marker \u001b[95mis\u001b[39;49;00m \u001b[94mNone\u001b[39;49;00m:\u001b[90m\u001b[39;49;00m\n", 206 | " \u001b[94mreturn\u001b[39;49;00m Config(prompt=\u001b[33m\"\u001b[39;49;00m\u001b[33m>\u001b[39;49;00m\u001b[33m\"\u001b[39;49;00m)\u001b[90m\u001b[39;49;00m\n", 207 | "> \u001b[94mreturn\u001b[39;49;00m Config(prompt=get_prompt_from_marker(*marker.args, **marker.kwargs))\u001b[90m\u001b[39;49;00m\n", 208 | "\u001b[1m\u001b[31mE TypeError: get_prompt_from_marker() got an unexpected keyword argument 'blabla'\u001b[0m\n", 209 | "\n", 210 | "\u001b[1m\u001b[31m/tmp/ipykernel_486516/1425000994.py\u001b[0m:14: TypeError\n", 211 | "\u001b[36m\u001b[1m===================================== short test summary info ======================================\u001b[0m\n", 212 | "\u001b[31mERROR\u001b[0m t_6947442cdcf74a3696aef7e735f8c8a1.py::\u001b[1mtest_marker\u001b[0m - TypeError: get_prompt_from_marker() got an unexpected keyword argument 'blabla'\n", 213 | "\u001b[31m==================================== \u001b[32m1 passed\u001b[0m, \u001b[31m\u001b[1m1 error\u001b[0m\u001b[31m in 0.02s\u001b[0m\u001b[31m ====================================\u001b[0m\n" 214 | ] 215 | } 216 | ], 217 | "source": [ 218 | "%%ipytest\n", 219 | "\n", 220 | "import pytest\n", 221 | "\n", 222 | "from rpncalc.utils import Config\n", 223 | "\n", 224 | "def get_prompt_from_marker(prompt):\n", 225 | " return prompt\n", 226 | "\n", 227 | "\n", 228 | "@pytest.fixture\n", 229 | "def config(request: pytest.FixtureRequest) -> Config:\n", 230 | " marker = request.node.get_closest_marker(\"prompt\")\n", 231 | " if marker is None:\n", 232 | " return Config(prompt=\">\")\n", 233 | " return Config(prompt=get_prompt_from_marker(*marker.args, **marker.kwargs))\n", 234 | "\n", 235 | "\n", 236 | "def test_normal(config: Config):\n", 237 | " assert config.prompt == \">\"\n", 238 | "\n", 239 | "\n", 240 | "\n", 241 | "@pytest.mark.prompt(\"rpn>\", blabla=42)\n", 242 | "def test_marker(config: Config):\n", 243 | " assert config.prompt == \"rpn>\"\n", 244 | "\n", 245 | "\n", 246 | "### possible alternative approach for arg validation\n", 247 | "\n", 248 | "def prompt_mark(prompt: str):\n", 249 | " return pytest.mark.prompt(prompt)\n", 250 | "\n" 251 | ] 252 | }, 253 | { 254 | "cell_type": "markdown", 255 | "id": "cdb8a928-a5d7-4350-9810-dfc7aae7b389", 256 | "metadata": {}, 257 | "source": [ 258 | "# Links\n", 259 | "\n", 260 | "- [GitHub - boxed/mutmut: Mutation testing system](https://github.com/boxed/mutmut)\n", 261 | "- [flakytest.dev](https://flakytest.dev/)" 262 | ] 263 | } 264 | ], 265 | "metadata": { 266 | "kernelspec": { 267 | "display_name": "venv-pytest-training", 268 | "language": "python", 269 | "name": "venv-pytest-training" 270 | }, 271 | "language_info": { 272 | "codemirror_mode": { 273 | "name": "ipython", 274 | "version": 3 275 | }, 276 | "file_extension": ".py", 277 | "mimetype": "text/x-python", 278 | "name": "python", 279 | "nbconvert_exporter": "python", 280 | "pygments_lexer": "ipython3", 281 | "version": "3.12.4" 282 | } 283 | }, 284 | "nbformat": 4, 285 | "nbformat_minor": 5 286 | } 287 | --------------------------------------------------------------------------------