From 498c66e23aad824a4e5df5179e3339026a72fc10 Mon Sep 17 00:00:00 2001 From: nikilok Date: Thu, 10 Jul 2025 12:26:07 +0100 Subject: [PATCH 1/5] add pytest to dev dep --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 7f32f18..025267d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,4 +27,5 @@ build-backend = "poetry.core.masonry.api" black = "^25.1.0" isort = "^6.0.1" flake8 = "^7.3.0" +pytest = "^8.4.1" From 151f8d5779aa0fa776d95c092ace8ac49ec5ca2a Mon Sep 17 00:00:00 2001 From: nikilok Date: Thu, 10 Jul 2025 12:26:46 +0100 Subject: [PATCH 2/5] add tests for search service --- tests/conftest.py | 5 ++++ tests/services/test_search.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/services/test_search.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2560cef --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import os +import sys + +# Ensure the app module is importable for all tests +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/tests/services/test_search.py b/tests/services/test_search.py new file mode 100644 index 0000000..41b368a --- /dev/null +++ b/tests/services/test_search.py @@ -0,0 +1,53 @@ +from unittest.mock import patch + +import pandas as pd +import pytest + +from app.services.search import search_companies + + +@pytest.fixture +def mock_data(): + return pd.DataFrame( + { + "Organisation Name": ["Foo Company", "Xyz Motion", "Test Company"], + "Town/City": ["London", "London", "Manchester"], + "County": ["Greater London", "Greater London", "Greater Manchester"], + } + ) + +# Fixture to patch skilled_worker_data_current and set up side effects +@pytest.fixture +def setup_mock_skilled_worker_data_current(mock_data): + with patch("app.services.search.skilled_worker_data_current") as mock_skilled_worker_data_current: + mock_skilled_worker_data_current.__getitem__.side_effect = mock_data.__getitem__ + mock_skilled_worker_data_current.loc.__getitem__.side_effect = mock_data.loc.__getitem__ + yield mock_skilled_worker_data_current + + +def test_exact_match(setup_mock_skilled_worker_data_current): + results = search_companies("Foo Company", threshold=90) + assert any("Foo Company" in r.Organisation_Name for r in results) + + +def test_fuzzy_match(setup_mock_skilled_worker_data_current): + results = search_companies("Foo", threshold=70) + assert any("Foo Company" in r.Organisation_Name for r in results) + + +def test_no_match(setup_mock_skilled_worker_data_current): + results = search_companies("Nonexistent Company", threshold=90) + assert results == [] + + +@pytest.fixture +def setup_mock_skilled_worker_data_current_nan(): + mock_data = pd.DataFrame({"Organisation Name": [None], "Town/City": [None], "County": [None]}) + with patch("app.services.search.skilled_worker_data_current") as mock_skilled_worker_data_current: + mock_skilled_worker_data_current.__getitem__.side_effect = mock_data.__getitem__ + mock_skilled_worker_data_current.loc.__getitem__.side_effect = mock_data.loc.__getitem__ + yield mock_skilled_worker_data_current + +def test_nan_handling(setup_mock_skilled_worker_data_current_nan): + results = search_companies("anything", threshold=0) + assert results[0].Organisation_Name == "" From 33251700abde95f63a5717df60707d23f1cac7c1 Mon Sep 17 00:00:00 2001 From: nikilok Date: Thu, 10 Jul 2025 12:27:22 +0100 Subject: [PATCH 3/5] add ci.yml to run tests in ci,update readme and update the lock file --- .github/workflows/ci.yml | 4 +++ README.md | 4 +++ poetry.lock | 56 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6823182..c378db7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,7 @@ jobs: - name: Run flake8 (linting) run: | poetry run flake8 . + + - name: Run pytest (testing) + run: | + poetry run pytest diff --git a/README.md b/README.md index 109d298..79732fc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ `poetry run flake8 .` +## Test + +`poetry run pytest` + image image diff --git a/poetry.lock b/poetry.lock index fc31154..59b87d0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -235,11 +235,11 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main", "dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "platform_system == \"Windows\""} [[package]] name = "dnspython" @@ -551,6 +551,18 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + [[package]] name = "isort" version = "6.0.1" @@ -1075,6 +1087,22 @@ docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-a test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] type = ["mypy (>=1.14.1)"] +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + [[package]] name = "pycodestyle" version = "2.14.0" @@ -1264,7 +1292,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "dev"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1273,6 +1301,28 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pytest" +version = "8.4.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2419,4 +2469,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "2be3517a27818c801a2c62b592f52c8841ad19c211fc942d92c853b7992f95f9" +content-hash = "3cc60db5932709cb8e5275d7bd0b1a27f73dfa796a64c3ac1b9ed7f78fa3ed9a" From 1119a5709e37969f0065e1cd748a3fa0be7ce269 Mon Sep 17 00:00:00 2001 From: nikilok Date: Thu, 10 Jul 2025 12:30:26 +0100 Subject: [PATCH 4/5] fix formatting issue --- tests/services/test_search.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/tests/services/test_search.py b/tests/services/test_search.py index 41b368a..202cd1c 100644 --- a/tests/services/test_search.py +++ b/tests/services/test_search.py @@ -16,12 +16,17 @@ def mock_data(): } ) + # Fixture to patch skilled_worker_data_current and set up side effects @pytest.fixture def setup_mock_skilled_worker_data_current(mock_data): - with patch("app.services.search.skilled_worker_data_current") as mock_skilled_worker_data_current: + with patch( + "app.services.search.skilled_worker_data_current" + ) as mock_skilled_worker_data_current: mock_skilled_worker_data_current.__getitem__.side_effect = mock_data.__getitem__ - mock_skilled_worker_data_current.loc.__getitem__.side_effect = mock_data.loc.__getitem__ + mock_skilled_worker_data_current.loc.__getitem__.side_effect = ( + mock_data.loc.__getitem__ + ) yield mock_skilled_worker_data_current @@ -42,12 +47,19 @@ def test_no_match(setup_mock_skilled_worker_data_current): @pytest.fixture def setup_mock_skilled_worker_data_current_nan(): - mock_data = pd.DataFrame({"Organisation Name": [None], "Town/City": [None], "County": [None]}) - with patch("app.services.search.skilled_worker_data_current") as mock_skilled_worker_data_current: + mock_data = pd.DataFrame( + {"Organisation Name": [None], "Town/City": [None], "County": [None]} + ) + with patch( + "app.services.search.skilled_worker_data_current" + ) as mock_skilled_worker_data_current: mock_skilled_worker_data_current.__getitem__.side_effect = mock_data.__getitem__ - mock_skilled_worker_data_current.loc.__getitem__.side_effect = mock_data.loc.__getitem__ + mock_skilled_worker_data_current.loc.__getitem__.side_effect = ( + mock_data.loc.__getitem__ + ) yield mock_skilled_worker_data_current + def test_nan_handling(setup_mock_skilled_worker_data_current_nan): results = search_companies("anything", threshold=0) assert results[0].Organisation_Name == "" From 1afa245ef724b5b11a6220c57664bd9e94c5796e Mon Sep 17 00:00:00 2001 From: nikilok Date: Thu, 10 Jul 2025 13:35:56 +0100 Subject: [PATCH 5/5] generalize the fixture using contextlib decorator and passing in the mock data as arguments to avoid code repeats --- tests/services/test_search.py | 67 +++++++++++++++++------------------ 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/tests/services/test_search.py b/tests/services/test_search.py index 202cd1c..850b162 100644 --- a/tests/services/test_search.py +++ b/tests/services/test_search.py @@ -1,3 +1,4 @@ +import contextlib from unittest.mock import patch import pandas as pd @@ -17,49 +18,45 @@ def mock_data(): ) -# Fixture to patch skilled_worker_data_current and set up side effects +# Generalized fixture factory for patching skilled_worker_data_current @pytest.fixture -def setup_mock_skilled_worker_data_current(mock_data): - with patch( - "app.services.search.skilled_worker_data_current" - ) as mock_skilled_worker_data_current: - mock_skilled_worker_data_current.__getitem__.side_effect = mock_data.__getitem__ - mock_skilled_worker_data_current.loc.__getitem__.side_effect = ( - mock_data.loc.__getitem__ - ) - yield mock_skilled_worker_data_current +def patch_skilled_worker_data_current(): + @contextlib.contextmanager + def _patch(data): + with patch( + "app.services.search.skilled_worker_data_current" + ) as mock_skilled_worker_data_current: + mock_skilled_worker_data_current.__getitem__.side_effect = data.__getitem__ + mock_skilled_worker_data_current.loc.__getitem__.side_effect = ( + data.loc.__getitem__ + ) + yield mock_skilled_worker_data_current + return _patch -def test_exact_match(setup_mock_skilled_worker_data_current): - results = search_companies("Foo Company", threshold=90) - assert any("Foo Company" in r.Organisation_Name for r in results) +def test_exact_match(patch_skilled_worker_data_current, mock_data): + with patch_skilled_worker_data_current(mock_data): + results = search_companies("Foo Company", threshold=90) + assert any("Foo Company" in r.Organisation_Name for r in results) -def test_fuzzy_match(setup_mock_skilled_worker_data_current): - results = search_companies("Foo", threshold=70) - assert any("Foo Company" in r.Organisation_Name for r in results) +def test_fuzzy_match(patch_skilled_worker_data_current, mock_data): + with patch_skilled_worker_data_current(mock_data): + results = search_companies("Foo", threshold=70) + assert any("Foo Company" in r.Organisation_Name for r in results) -def test_no_match(setup_mock_skilled_worker_data_current): - results = search_companies("Nonexistent Company", threshold=90) - assert results == [] +def test_no_match(patch_skilled_worker_data_current, mock_data): + with patch_skilled_worker_data_current(mock_data): + results = search_companies("Nonexistent Company", threshold=90) + assert not results -@pytest.fixture -def setup_mock_skilled_worker_data_current_nan(): - mock_data = pd.DataFrame( + +def test_nan_handling(patch_skilled_worker_data_current): + nan_data = pd.DataFrame( {"Organisation Name": [None], "Town/City": [None], "County": [None]} ) - with patch( - "app.services.search.skilled_worker_data_current" - ) as mock_skilled_worker_data_current: - mock_skilled_worker_data_current.__getitem__.side_effect = mock_data.__getitem__ - mock_skilled_worker_data_current.loc.__getitem__.side_effect = ( - mock_data.loc.__getitem__ - ) - yield mock_skilled_worker_data_current - - -def test_nan_handling(setup_mock_skilled_worker_data_current_nan): - results = search_companies("anything", threshold=0) - assert results[0].Organisation_Name == "" + with patch_skilled_worker_data_current(nan_data): + results = search_companies("anything", threshold=0) + assert results[0].Organisation_Name == ""