From 2b0b71cb08b808b035167efe701f115d18781282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Fri, 9 Jan 2026 22:21:52 +0100 Subject: [PATCH] Add more tests --- .vscode/settings.json | 1 + pyproject.toml | 2 +- tests/__init__.py | 0 tests/test_manage.py | 45 +++++++++++++++++++ twitch/tests/test_views.py | 92 ++++++++++++++++++++++++++++++++++++++ twitch/views.py | 8 ++-- uv.lock | 51 +++++++++++++++++++++ 7 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_manage.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 49b53c1..7b7abcd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,6 +18,7 @@ "dotenv", "dropcampaign", "elif", + "excinfo", "Facepunch", "filterwarnings", "granian", diff --git a/pyproject.toml b/pyproject.toml index 2fb61d6..776e96c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest", "pytest-django", "djlint", "django-stubs"] +dev = ["pytest", "pytest-django", "djlint", "django-stubs", "pytest-cov"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_manage.py b/tests/test_manage.py new file mode 100644 index 0000000..829b309 --- /dev/null +++ b/tests/test_manage.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import types +from typing import Never + +import pytest + +import manage + + +def test_main_importerror(monkeypatch: pytest.MonkeyPatch) -> None: + """Test main raises ImportError if django cannot be imported.""" + monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "") + + def import_fail(*args, **kwargs) -> Never: + msg = "No Django" + raise ImportError(msg) + + monkeypatch.setitem(sys.modules, "django.core.management", None) + monkeypatch.setattr("builtins.__import__", import_fail) + with pytest.raises(ImportError) as excinfo: + manage.main() + assert "Couldn't import Django" in str(excinfo.value) + + +def test_main_executes_command(monkeypatch: pytest.MonkeyPatch) -> None: + """Test main calls execute_from_command_line with sys.argv.""" + called: dict[str, list[str]] = {} + + def fake_execute(argv: list[str]) -> None: + called["argv"] = argv + + fake_module = types.SimpleNamespace(execute_from_command_line=fake_execute) + monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "") + monkeypatch.setitem(sys.modules, "django.core.management", fake_module) + original_import = __import__ + monkeypatch.setattr( + "builtins.__import__", + lambda name, *a, **kw: fake_module if name == "django.core.management" else original_import(name, *a, **kw), + ) + test_argv: list[str] = ["manage.py", "check"] + monkeypatch.setattr(sys, "argv", test_argv) + manage.main() + assert called["argv"] == test_argv diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index d570527..2830010 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -5,6 +5,7 @@ from typing import Any from typing import Literal import pytest +from django.urls import reverse from twitch.models import Channel from twitch.models import DropBenefit @@ -396,3 +397,94 @@ class TestChannelListView: # Should only contain the searched channel assert len(channels) == 1 assert channels[0].twitch_id == channel.twitch_id + + @pytest.mark.django_db + def test_dashboard_view(self, client: Client) -> None: + """Test dashboard view returns 200 and has active_campaigns in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:dashboard")) + assert response.status_code == 200 + assert "active_campaigns" in response.context + + @pytest.mark.django_db + def test_debug_view(self, client: Client) -> None: + """Test debug view returns 200 and has games_without_owner in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:debug")) + assert response.status_code == 200 + assert "games_without_owner" in response.context + + @pytest.mark.django_db + def test_drop_campaign_list_view(self, client: Client) -> None: + """Test campaign list view returns 200 and has campaigns in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:campaign_list")) + assert response.status_code == 200 + assert "campaigns" in response.context + + @pytest.mark.django_db + def test_drop_campaign_detail_view(self, client: Client, db: object) -> None: + """Test campaign detail view returns 200 and has campaign in context.""" + game: Game = Game.objects.create(twitch_id="g1", name="Game", display_name="Game") + campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="c1", + name="Campaign", + game=game, + operation_name="DropCampaignDetails", + ) + url: str = reverse("twitch:campaign_detail", args=[campaign.twitch_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 200 + assert "campaign" in response.context + + @pytest.mark.django_db + def test_games_grid_view(self, client: Client) -> None: + """Test games grid view returns 200 and has games in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:game_list")) + assert response.status_code == 200 + assert "games" in response.context + + @pytest.mark.django_db + def test_games_list_view(self, client: Client) -> None: + """Test games list view returns 200 and has games in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:game_list_simple")) + assert response.status_code == 200 + assert "games" in response.context + + @pytest.mark.django_db + def test_game_detail_view(self, client: Client, db: object) -> None: + """Test game detail view returns 200 and has game in context.""" + game: Game = Game.objects.create(twitch_id="g2", name="Game2", display_name="Game2") + url: str = reverse("twitch:game_detail", args=[game.twitch_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 200 + assert "game" in response.context + + @pytest.mark.django_db + def test_org_list_view(self, client: Client) -> None: + """Test org list view returns 200 and has orgs in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:org_list")) + assert response.status_code == 200 + assert "orgs" in response.context + + @pytest.mark.django_db + def test_organization_detail_view(self, client: Client, db: object) -> None: + """Test organization detail view returns 200 and has organization in context.""" + org: Organization = Organization.objects.create(twitch_id="o1", name="Org1") + url: str = reverse("twitch:organization_detail", args=[org.twitch_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 200 + assert "organization" in response.context + + @pytest.mark.django_db + def test_channel_detail_view(self, client: Client, db: object) -> None: + """Test channel detail view returns 200 and has channel in context.""" + channel: Channel = Channel.objects.create(twitch_id="ch1", name="Channel1", display_name="Channel1") + url: str = reverse("twitch:channel_detail", args=[channel.twitch_id]) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 200 + assert "channel" in response.context + + @pytest.mark.django_db + def test_docs_rss_view(self, client: Client) -> None: + """Test docs RSS view returns 200 and has feeds in context.""" + response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:docs_rss")) + assert response.status_code == 200 + assert "feeds" in response.context diff --git a/twitch/views.py b/twitch/views.py index 9e38108..2d792ba 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -313,7 +313,7 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo Http404: If the campaign is not found. """ try: - campaign: DropCampaign = DropCampaign.objects.select_related("game__owner").get( + campaign: DropCampaign = DropCampaign.objects.prefetch_related("game__owners").get( twitch_id=twitch_id, operation_name="DropCampaignDetails", ) @@ -533,7 +533,7 @@ class GameDetailView(DetailView): all_campaigns: QuerySet[DropCampaign] = ( DropCampaign.objects .filter(game=game, operation_name="DropCampaignDetails") - .select_related("game__owner") + .prefetch_related("game__owners") .prefetch_related( Prefetch( "time_based_drops", @@ -642,7 +642,7 @@ def dashboard(request: HttpRequest) -> HttpResponse: active_campaigns: QuerySet[DropCampaign] = ( DropCampaign.objects .filter(start_at__lte=now, end_at__gte=now, operation_name="DropCampaignDetails") - .select_related("game__owner") + .prefetch_related("game__owners") .prefetch_related( "allow_channels", ) @@ -907,7 +907,7 @@ class ChannelDetailView(DetailView): all_campaigns: QuerySet[DropCampaign] = ( DropCampaign.objects .filter(allow_channels=channel, operation_name="DropCampaignDetails") - .select_related("game__owner") + .prefetch_related("game__owners") .prefetch_related( Prefetch( "time_based_drops", diff --git a/uv.lock b/uv.lock index 26c896d..0a3a8b2 100644 --- a/uv.lock +++ b/uv.lock @@ -62,6 +62,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + [[package]] name = "cssbeautifier" version = "1.15.4" @@ -388,6 +423,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "pytest-django" version = "4.11.1" @@ -546,6 +595,7 @@ dev = [ { name = "django-stubs" }, { name = "djlint" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-django" }, ] @@ -570,6 +620,7 @@ dev = [ { name = "django-stubs" }, { name = "djlint" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "pytest-django" }, ]