From dca638db089afeb7923dce5135433e2af610c8a4 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Thu, 27 Aug 2026 11:02:41 +0100 Subject: [PATCH 1/6] Add soup_factory fixture fixes #751 --- tests/main/view_utils.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/main/view_utils.py b/tests/main/view_utils.py index e1876ea2..88ac67b4 100644 --- a/tests/main/view_utils.py +++ b/tests/main/view_utils.py @@ -1,6 +1,7 @@ """Utility module for view tests.""" from abc import ABC, abstractmethod +from collections.abc import Callable from http import HTTPStatus import pytest @@ -54,7 +55,7 @@ class BS4Mixin(ABC): """ @abstractmethod - def _get_url(self) -> str: + def _get_url(self, **kwargs) -> str: return NotImplemented @pytest.fixture @@ -63,6 +64,19 @@ def soup(self, client) -> BeautifulSoup: response = client.get(self._get_url()) return BeautifulSoup(response.content, "html.parser") + @pytest.fixture + def soup_factory(self, client) -> Callable[..., BeautifulSoup]: + """A fixture factory for the BeautifulSoup4 object of the requested page.""" + + def get_soup(**kwargs) -> BeautifulSoup: + if kwargs: + response = client.get(self._get_url(kwargs=kwargs)) + else: + response = client.get(self._get_url()) + return BeautifulSoup(response.content, "html.parser") + + return get_soup + @pytest.fixture def auth_soup(self, client, user) -> BeautifulSoup: """A BeautifulSoup4 object of the requested page viewed by a logged-in user.""" From 31961529e176b5dde9daf7c4724677a264df74a1 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Thu, 27 Aug 2026 11:02:41 +0100 Subject: [PATCH 2/6] Add admin_client and authenticated options to the soup factory --- tests/main/view_utils.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/main/view_utils.py b/tests/main/view_utils.py index 88ac67b4..bddbfe74 100644 --- a/tests/main/view_utils.py +++ b/tests/main/view_utils.py @@ -65,14 +65,17 @@ def soup(self, client) -> BeautifulSoup: return BeautifulSoup(response.content, "html.parser") @pytest.fixture - def soup_factory(self, client) -> Callable[..., BeautifulSoup]: + def soup_factory(self, client, admin_client, user) -> Callable[..., BeautifulSoup]: """A fixture factory for the BeautifulSoup4 object of the requested page.""" - def get_soup(**kwargs) -> BeautifulSoup: + def get_soup(authenticated=False, admin=False, **kwargs) -> BeautifulSoup: + _client = admin_client if admin else client + if authenticated: + _client.force_login(user) if kwargs: - response = client.get(self._get_url(kwargs=kwargs)) + response = _client.get(self._get_url(**kwargs)) else: - response = client.get(self._get_url()) + response = _client.get(self._get_url()) return BeautifulSoup(response.content, "html.parser") return get_soup From 4e88497035ce114c636352f5a6cedff5af07f0e1 Mon Sep 17 00:00:00 2001 From: Sam Bland Date: Thu, 27 Aug 2026 11:02:41 +0100 Subject: [PATCH 3/6] Implemented tests for the skill profile page view - Note we removed the use of TemplateMixin as this does not work when _get_url requires url params --- tests/main/test_main_views.py | 95 +++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/tests/main/test_main_views.py b/tests/main/test_main_views.py index 997a2928..cd98b52c 100644 --- a/tests/main/test_main_views.py +++ b/tests/main/test_main_views.py @@ -7,6 +7,7 @@ import json from http import HTTPStatus +from urllib import parse as parseUrl import pytest from django.db.models import QuerySet @@ -567,10 +568,98 @@ def _get_url(self): return reverse("licensing") -class TestViewSkillProfilePageView(TemplateOkMixin): +class TestViewSkillProfilePageView(BS4Mixin): """Test suite for the ViewSkillProfilePageView.""" _template_name = "main/shared-skills-profile.html" - def _get_url(self): - return reverse("view_skill_profile") + def _example_chart_data(self, user_skill): + return [ + { + "user_id": "root", + "user_data": [ + { + "skill": user_skill.skill.name, + "category": user_skill.skill.competency.competency_domain.name, + "subcategory": user_skill.skill.competency.name, + "skill_level": user_skill.skill_level.level, + } + ], + } + ] + + def _get_url(self, chart_data): + """Construct the URL for the view skill profile page with query parameters.""" + skill_levels = json.dumps(list(SkillLevel.objects.values("level", "name"))) + chart_data_str = json.dumps(chart_data) + url = f"{reverse('view_skill_profile')}" + params = parseUrl.urlencode( + { + "skill_levels": skill_levels, + "chart_data": chart_data_str, + } + ) + url = f"{url}?{params}" + return url + + def test_template_used(self, admin_client, user_skill): + """Test the correct template is used by the GET request.""" + with assertTemplateUsed(template_name=self._template_name): + response = admin_client.get( + self._get_url(self._example_chart_data(user_skill)) + ) + assert response.status_code == HTTPStatus.OK + + def test_provides_required_context(self, client, user_skill): + """Test that the view skill profile view provides the correct context.""" + url = self._get_url(self._example_chart_data(user_skill)) + response = client.get(url) + assert response.status_code == HTTPStatus.OK + assert "chart_data" in response.context + assert isinstance(response.context["chart_data"], str) + assert response.context["chart_data"] == json.dumps( + self._example_chart_data(user_skill) + ) + assert "skill_levels" in response.context + assert isinstance(response.context["skill_levels"], str) + assert response.context["skill_levels"] == json.dumps( + list(SkillLevel.objects.values("level", "name")) + ) + + def test_skill_wheel_script(self, soup_factory, user_skill): + """Test that the skill profile view contains the correct script.""" + soup = soup_factory( + authenticated=True, chart_data=self._example_chart_data(user_skill) + ) + card = soup.find("div", class_="card-body") + + assert card.find(tag_with_text_filter("h1", "Skills profile")) + assert card.find("div", id="dataviz_root") + + skill_level_list = list(SkillLevel.objects.values("level", "name")) + user_skill_dict = { + "skill": user_skill.skill.name, + "category": user_skill.skill.competency.competency_domain.name, + "subcategory": user_skill.skill.competency.name, + "skill_level": user_skill.skill_level.level, + } + chart_data = [{"user_id": "root", "user_data": [user_skill_dict]}] + + assert card.find( + tag_with_text_filter( + "script", + f"const skillLevels = {json.dumps(skill_level_list)};", + ) + ) + assert card.find( + tag_with_text_filter( + "script", + f"const charts = {json.dumps(chart_data)};", + ) + ) + assert card.find( + tag_with_text_filter( + "script", + "renderRadialBarChart(target, charts[i].user_data, skillLevels);", + ) + ) From 154d4d9290014d205762b5be17b6b3c9fba82860 Mon Sep 17 00:00:00 2001 From: Adrian D'Alessandro Date: Mon, 7 Sep 2026 17:32:25 +0100 Subject: [PATCH 4/6] Use TemplateOkMixin again, provide option for None in get_url --- tests/main/test_main_views.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/main/test_main_views.py b/tests/main/test_main_views.py index cd98b52c..25a29245 100644 --- a/tests/main/test_main_views.py +++ b/tests/main/test_main_views.py @@ -568,7 +568,7 @@ def _get_url(self): return reverse("licensing") -class TestViewSkillProfilePageView(BS4Mixin): +class TestViewSkillProfilePageView(TemplateOkMixin, BS4Mixin): """Test suite for the ViewSkillProfilePageView.""" _template_name = "main/shared-skills-profile.html" @@ -588,7 +588,7 @@ def _example_chart_data(self, user_skill): } ] - def _get_url(self, chart_data): + def _get_url(self, chart_data=None): """Construct the URL for the view skill profile page with query parameters.""" skill_levels = json.dumps(list(SkillLevel.objects.values("level", "name"))) chart_data_str = json.dumps(chart_data) @@ -602,14 +602,6 @@ def _get_url(self, chart_data): url = f"{url}?{params}" return url - def test_template_used(self, admin_client, user_skill): - """Test the correct template is used by the GET request.""" - with assertTemplateUsed(template_name=self._template_name): - response = admin_client.get( - self._get_url(self._example_chart_data(user_skill)) - ) - assert response.status_code == HTTPStatus.OK - def test_provides_required_context(self, client, user_skill): """Test that the view skill profile view provides the correct context.""" url = self._get_url(self._example_chart_data(user_skill)) From f14fa0404a048fada22f4b6f663cbc7435f17312 Mon Sep 17 00:00:00 2001 From: Adrian D'Alessandro Date: Mon, 7 Sep 2026 17:33:16 +0100 Subject: [PATCH 5/6] Use soup factory in the other fixtures of BS4Mixin class --- tests/main/view_utils.py | 43 +++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/main/view_utils.py b/tests/main/view_utils.py index bddbfe74..95dda4de 100644 --- a/tests/main/view_utils.py +++ b/tests/main/view_utils.py @@ -14,12 +14,16 @@ class TemplateOkMixin(ABC): """Mixin for tests that verify the correct template usage. Note: Using this requires the test class to define: - - A `_get_url` method + - A `_get_url` method that can be called with no arguments - A `_template_name` variable """ _template_name: str + @abstractmethod + def _get_url(self, **kwargs) -> str: + return NotImplemented + def test_template_used(self, admin_client): """Test the correct template is used by the GET request.""" with assertTemplateUsed(template_name=self._template_name): @@ -31,13 +35,13 @@ class LoginRequiredMixin(ABC): """Mixin for tests that require a user to be logged in. Note: Using this requires the test class to define: - - A `_get_url` method + - A `_get_url` method that can be called with no arguments """ _template_name: str @abstractmethod - def _get_url(self) -> str: + def _get_url(self, **kwargs) -> str: return NotImplemented def test_login_required(self, client): @@ -58,17 +62,18 @@ class BS4Mixin(ABC): def _get_url(self, **kwargs) -> str: return NotImplemented - @pytest.fixture - def soup(self, client) -> BeautifulSoup: - """A fixture of the BeautifulSoup4 object of the requested page.""" - response = client.get(self._get_url()) - return BeautifulSoup(response.content, "html.parser") - @pytest.fixture def soup_factory(self, client, admin_client, user) -> Callable[..., BeautifulSoup]: - """A fixture factory for the BeautifulSoup4 object of the requested page.""" + """A fixture factory for the BeautifulSoup4 object of the requested page. + + Returns a function that can be called with kwargs provided if the get_url method + requires them. Possible kwargs: + - authenticated: `True` if user should be logged-in + - admin: `True` if user should be an admin + - Any other kwargs: passed to `get_url` method + """ - def get_soup(authenticated=False, admin=False, **kwargs) -> BeautifulSoup: + def get_soup(admin=False, authenticated=False, **kwargs) -> BeautifulSoup: _client = admin_client if admin else client if authenticated: _client.force_login(user) @@ -81,17 +86,19 @@ def get_soup(authenticated=False, admin=False, **kwargs) -> BeautifulSoup: return get_soup @pytest.fixture - def auth_soup(self, client, user) -> BeautifulSoup: + def soup(self, soup_factory) -> BeautifulSoup: + """A fixture of the BeautifulSoup4 object of the requested page.""" + return soup_factory() + + @pytest.fixture + def auth_soup(self, soup_factory) -> BeautifulSoup: """A BeautifulSoup4 object of the requested page viewed by a logged-in user.""" - client.force_login(user) - response = client.get(self._get_url()) - return BeautifulSoup(response.content, "html.parser") + return soup_factory(authenticated=True) @pytest.fixture - def admin_soup(self, admin_client) -> BeautifulSoup: + def admin_soup(self, soup_factory) -> BeautifulSoup: """A BeautifulSoup4 object of the requested page viewed by an admin user.""" - response = admin_client.get(self._get_url()) - return BeautifulSoup(response.content, "html.parser") + return soup_factory(admin=True) def tag_with_text_filter(tag_name: str, text: str): From 45d4800de32dace4a5bebad986a10a07fcba8363 Mon Sep 17 00:00:00 2001 From: Adrian D'Alessandro Date: Mon, 7 Sep 2026 17:57:30 +0100 Subject: [PATCH 6/6] Fix the broken tests --- tests/main/test_main_views.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/tests/main/test_main_views.py b/tests/main/test_main_views.py index 25a29245..5568c2a0 100644 --- a/tests/main/test_main_views.py +++ b/tests/main/test_main_views.py @@ -608,21 +608,17 @@ def test_provides_required_context(self, client, user_skill): response = client.get(url) assert response.status_code == HTTPStatus.OK assert "chart_data" in response.context - assert isinstance(response.context["chart_data"], str) - assert response.context["chart_data"] == json.dumps( - self._example_chart_data(user_skill) - ) + assert isinstance(response.context["chart_data"], list) + assert response.context["chart_data"] == self._example_chart_data(user_skill) assert "skill_levels" in response.context - assert isinstance(response.context["skill_levels"], str) - assert response.context["skill_levels"] == json.dumps( - list(SkillLevel.objects.values("level", "name")) + assert isinstance(response.context["skill_levels"], list) + assert response.context["skill_levels"] == list( + SkillLevel.objects.values("level", "name") ) def test_skill_wheel_script(self, soup_factory, user_skill): """Test that the skill profile view contains the correct script.""" - soup = soup_factory( - authenticated=True, chart_data=self._example_chart_data(user_skill) - ) + soup = soup_factory(chart_data=self._example_chart_data(user_skill)) card = soup.find("div", class_="card-body") assert card.find(tag_with_text_filter("h1", "Skills profile")) @@ -638,16 +634,10 @@ def test_skill_wheel_script(self, soup_factory, user_skill): chart_data = [{"user_id": "root", "user_data": [user_skill_dict]}] assert card.find( - tag_with_text_filter( - "script", - f"const skillLevels = {json.dumps(skill_level_list)};", - ) + tag_with_text_filter("script", f"const skillLevels = {skill_level_list};") ) assert card.find( - tag_with_text_filter( - "script", - f"const charts = {json.dumps(chart_data)};", - ) + tag_with_text_filter("script", f"const charts = {chart_data};") ) assert card.find( tag_with_text_filter(