{ "cells": [ { "cell_type": "markdown", "id": "359d0d32-68c5-4e8d-8b34-2c5b2a0cad21", "metadata": {}, "source": [ "# Testing with pytest Fixtures\n", "\n", "**Fixtures** are pytest's way of setting up the data and objects that tests need. Think of them as reusable \"preparation steps\" that run before your tests.\n", "\n", "Instead of copy-pasting setup code into every test function, you define a fixture once and let pytest inject it automatically in tests:\n", "```python\n", "import pytest\n", "\n", "@pytest.fixture\n", "def sample_user():\n", " return {\"name\": \"Alice\", \"role\": \"admin\"}\n", "\n", "def test_user_has_name(sample_user):\n", " assert sample_user[\"name\"] == \"Alice\"\n", "```\n", "\n", "Fixtures can also handle **teardown** (cleanup after a test), be **scoped** to run once per module or session, and be **parametrized** to run the same test against multiple inputs. They aim at keeping the test suite easy to maintain.\n", "\n", "## Usefull references\n", "- [pytest fixtures — official docs](https://docs.pytest.org/en/stable/reference/fixtures.html)\n", "- [How to use fixtures — pytest how-to guide](https://docs.pytest.org/en/stable/how-to/fixtures.html)\n", "- [A Complete Guide to Pytest Fixtures — Better Stack](https://betterstack.com/community/guides/testing/pytest-fixtures-guide/#step-6-parametrizing-fixtures) *(includes parametrizing fixtures)*" ] }, { "cell_type": "markdown", "id": "62247b28-05e5-46a5-8aca-c35b94d64216", "metadata": {}, "source": [ "## The `conftest.py` File\n", "\n", "Pytest has a special file called `conftest.py` that acts as a **shared fixture library**. Any fixture defined there is automatically available to every test in the same directory and all its subdirectories.\n", "\n", "### How it works\n", "```\n", "climada/\n", "├── test/\n", "| ├── conftest.py ← fixtures here are available to all tests in climada/test/\n", "| ├── test_engine.py\n", "| └── ...\n", "├── entity/exposures/test/\n", " |── conftest.py ← fixtures here are available only within climada/entity/exposures/test/\n", " └── test_exposures.py\n", "...\n", "```\n", "\n", "Define your fixture in the corresponding `conftest.py` or in your test file directly depending on its specificity:\n", "```python\n", "import pytest\n", "from climada.entity.exposures\n", "\n", "@pytest.fixture\n", "def empty_exposures():\n", " return Exposure()\n", "```\n", "\n", "Then just use it by name in any test that requires it.\n", "```python\n", "def test_empty_exposures(empty_exposures): # pytest injects the fixture automatically\n", " assert empty_exposures.gdf.empty()\n", "```\n", "\n", "> **Never import fixtures directly.** pytest's injection mechanism won't work properly with imported fixtures, and you may get confusing errors. Just use the fixture name as a function argument and let pytest handle the rest.\n", "\n", "### When to use `conftest.py`\n", "\n", "| Put fixtures in `conftest.py` when... | Keep fixtures in the test file when... |\n", "|---|---|\n", "| Multiple test files need them | Only one test file uses them |\n", "| They set up shared resources | They are very specific to one test scenario |" ] }, { "cell_type": "markdown", "id": "550e12a9-2ab0-4f27-9276-ebc412a78c0b", "metadata": {}, "source": [ "## How to use `climada/test/conftest.py` for integration tests.\n", "\n", "Our integration test `conftest.py` defines a set of **ready-made CLIMADA objects** (exposures, hazard, impact functions) with values chosen so that expected results are rather easy to compute by hand and minimalistic. All fixtures are `session`-scoped, meaning they are created once and shared across the entire test session.\n", "\n", "Here is what is available at a glance:\n", "\n", "| Fixture | What it gives you |\n", "|---|---|\n", "| `exposures` | An `Exposures` object with 6 points and values `[0, 1000, 2000, 3000, 4000, 5000]` |\n", "| `hazard` | A `Hazard` with 5 events and \"helpful\" intensities and frequencies (see below) |\n", "| `linear_impact_function` | An `ImpactFunc` where intensity % == damage % (identity) |\n", "| `impfset` | An `ImpactFuncSet` wrapping the linear impact function |\n", "| `centroids` | Hazard centroids (slightly offset from exposure points (`+0.1°`)) |\n", "\n", "### Using fixtures directly for simple assertions\n", "\n", "When you just need a standard object to test against, request the fixture by name:\n", "```python\n", "def test_impact_aai(exposures, hazard, impfset):\n", " impact = ImpactCalc(exposures, impfset, hazard).impact()\n", " assert impact.aai_agg == pytest.approx(18) # analytically known value\n", "```\n", "\n", "Values of the defaults fixtures were chosen such that impacts are rather easy to compute by hand. \n", "This is documented at the top of `conftest.py`, but here are some key design choices:\n", "\n", "- There are 4 events, with frequencies == 0.03, 0.01, 0.006, 0.004, 0,\n", " such that impacts for RP250, 100 and 50 and 20 correspond to `at_event`,\n", " (sorted frequencies cumulate to 1/250, 1/100, 1/50 and 1/20).\n", "- Hazard intensity is:\n", " * Event 1: zero everywhere (always no impact)\n", " * Event 2: max intensity (100) at first centroid (also always no impact (first centroid is 0))\n", " * Event 3: half max intensity at second centroid (impact == half second centroid)\n", " * Event 4: quarter max intensity everywhere (impact == 1/4 total value)\n", " * Event 5: max intensity everywhere (but zero frequency)\n", "\n", "This results in the following expected values:\n", "\n", "| Metric | Expected value | Why |\n", "|---|---|---|\n", "| AAI | `18` | Events 3 & 4 weighted by frequency |\n", "| RP 20 & 50 | `0` | Events 1 & 2 produce zero impact |\n", "| RP 100 | `500` | Event 3: half intensity on second point (value 1000) |\n", "| RP 250 | `3750` | Event 4: quarter intensity on all points |\n", "\n", "> Note: The overview above reflects the `conftest.py` file at the time of writing. If you notice any discrepancy, the docstring at the top of `conftest.py` is the authoritative source.\n", "\n", "### Using factories for custom scenarios\n", "\n", "When your test needs a variation (e.g. scaled intensity, a different hazard type, group IDs), you can make use of the `_factory` fixtures. Each factory is a callable that accepts keyword arguments:\n", "\n", "```python\n", "def test_scaled_hazard(hazard_factory):\n", " stronger_hazard = hazard_factory(intensity_scale=1.5)\n", " # intensities are halved so expected impacts scale accordingly\n", "\n", "def test_grouped_exposure(exposures_factory):\n", " exp = exposures_factory(group_id=np.array([1, 1, 2, 1, 1, 3]))\n", " # exposure with group_id column populated\n", "\n", "def test_custom_impf(impf_factory):\n", " impf = impf_factory(paa_scale=0.5)\n", " # PAA halved\n", "```\n", "\n", "> **Tip:** Prefer the direct fixtures (`exposures`, `hazard`, …) when the default setup is sufficient. Reach for factories only when your test specifically targets behaviour that depends on a variation — this keeps tests focused and their intent clear." ] } ], "metadata": { "kernelspec": { "display_name": "Python [conda env:climada_env_dev]", "language": "python", "name": "conda-env-climada_env_dev-py" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.15" } }, "nbformat": 4, "nbformat_minor": 5 }