Skip to content

Commit ab6a657

Browse files
authored
test: generate bound model methods tests (#546)
In our bound models, many of the methods are only proxies to the api client methods. But our tests are always testing the full stack down to the HTTP requests. This lead to a lot of duplicate tests for the API client and the bound models methods. This change removes the bound models methods tests, and uses a generic "proxy method test" for all our bound models.
1 parent 1467584 commit ab6a657

File tree

14 files changed

+992
-1819
lines changed

14 files changed

+992
-1819
lines changed

hcloud/zones/client.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ def update_rrset(
352352
DNS API is in beta, breaking changes may occur within minor releases.
353353
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
354354
"""
355-
return self._client.update_rrset(rrset, labels=labels)
355+
return self._client.update_rrset(rrset=rrset, labels=labels)
356356

357357
def delete_rrset(
358358
self,
@@ -369,7 +369,7 @@ def delete_rrset(
369369
DNS API is in beta, breaking changes may occur within minor releases.
370370
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
371371
"""
372-
return self._client.delete_rrset(rrset)
372+
return self._client.delete_rrset(rrset=rrset)
373373

374374
def change_rrset_protection(
375375
self,
@@ -389,7 +389,7 @@ def change_rrset_protection(
389389
DNS API is in beta, breaking changes may occur within minor releases.
390390
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
391391
"""
392-
return self._client.change_rrset_protection(rrset, change=change)
392+
return self._client.change_rrset_protection(rrset=rrset, change=change)
393393

394394
def change_rrset_ttl(
395395
self,
@@ -408,7 +408,7 @@ def change_rrset_ttl(
408408
DNS API is in beta, breaking changes may occur within minor releases.
409409
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
410410
"""
411-
return self._client.change_rrset_ttl(rrset, ttl=ttl)
411+
return self._client.change_rrset_ttl(rrset=rrset, ttl=ttl)
412412

413413
def add_rrset_records(
414414
self,
@@ -429,7 +429,7 @@ def add_rrset_records(
429429
DNS API is in beta, breaking changes may occur within minor releases.
430430
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
431431
"""
432-
return self._client.add_rrset_records(rrset, records=records, ttl=ttl)
432+
return self._client.add_rrset_records(rrset=rrset, records=records, ttl=ttl)
433433

434434
def remove_rrset_records(
435435
self,
@@ -448,7 +448,7 @@ def remove_rrset_records(
448448
DNS API is in beta, breaking changes may occur within minor releases.
449449
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
450450
"""
451-
return self._client.remove_rrset_records(rrset, records=records)
451+
return self._client.remove_rrset_records(rrset=rrset, records=records)
452452

453453
def set_rrset_records(
454454
self,
@@ -467,7 +467,7 @@ def set_rrset_records(
467467
DNS API is in beta, breaking changes may occur within minor releases.
468468
See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details.
469469
"""
470-
return self._client.set_rrset_records(rrset, records=records)
470+
return self._client.set_rrset_records(rrset=rrset, records=records)
471471

472472

473473
class BoundZoneRRSet(BoundModelBase, ZoneRRSet):

tests/unit/certificates/test_client.py

Lines changed: 30 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -12,88 +12,42 @@
1212
ManagedCertificateStatus,
1313
)
1414

15+
from ..conftest import BoundModelTestCase
1516

16-
class TestBoundCertificate:
17-
@pytest.fixture()
18-
def bound_certificate(self, client: Client):
19-
return BoundCertificate(client.certificates, data=dict(id=14))
20-
21-
def test_bound_certificate_init(self, certificate_response):
22-
bound_certificate = BoundCertificate(
23-
client=mock.MagicMock(), data=certificate_response["certificate"]
24-
)
25-
26-
assert bound_certificate.id == 2323
27-
assert bound_certificate.name == "My Certificate"
28-
assert bound_certificate.type == "managed"
29-
assert (
30-
bound_certificate.fingerprint
31-
== "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f"
32-
)
33-
assert bound_certificate.certificate == "-----BEGIN CERTIFICATE-----\n..."
34-
assert len(bound_certificate.domain_names) == 3
35-
assert bound_certificate.domain_names[0] == "example.com"
36-
assert bound_certificate.domain_names[1] == "webmail.example.com"
37-
assert bound_certificate.domain_names[2] == "www.example.com"
38-
assert isinstance(bound_certificate.status, ManagedCertificateStatus)
39-
assert bound_certificate.status.issuance == "failed"
40-
assert bound_certificate.status.renewal == "scheduled"
41-
assert bound_certificate.status.error.code == "error_code"
42-
assert bound_certificate.status.error.message == "error message"
43-
44-
def test_update(
45-
self,
46-
request_mock: mock.MagicMock,
47-
bound_certificate,
48-
response_update_certificate,
49-
):
50-
request_mock.return_value = response_update_certificate
5117

52-
certificate = bound_certificate.update(name="New name")
18+
class TestBoundCertificate(BoundModelTestCase):
19+
methods = [
20+
BoundCertificate.update,
21+
BoundCertificate.delete,
22+
BoundCertificate.retry_issuance,
23+
]
5324

54-
request_mock.assert_called_with(
55-
method="PUT",
56-
url="/certificates/14",
57-
json={"name": "New name"},
58-
)
59-
60-
assert certificate.id == 2323
61-
assert certificate.name == "New name"
62-
63-
def test_delete(
64-
self,
65-
request_mock: mock.MagicMock,
66-
bound_certificate,
67-
action_response,
68-
):
69-
request_mock.return_value = action_response
70-
71-
delete_success = bound_certificate.delete()
72-
73-
request_mock.assert_called_with(
74-
method="DELETE",
75-
url="/certificates/14",
76-
)
77-
78-
assert delete_success is True
79-
80-
def test_retry_issuance(
81-
self,
82-
request_mock: mock.MagicMock,
83-
bound_certificate,
84-
response_retry_issuance_action,
85-
):
86-
request_mock.return_value = response_retry_issuance_action
87-
88-
action = bound_certificate.retry_issuance()
25+
@pytest.fixture()
26+
def resource_client(self, client: Client):
27+
return client.certificates
8928

90-
request_mock.assert_called_with(
91-
method="POST",
92-
url="/certificates/14/actions/retry",
29+
@pytest.fixture()
30+
def bound_model(self, resource_client, certificate_response):
31+
return BoundCertificate(
32+
resource_client, data=certificate_response["certificate"]
9333
)
9434

95-
assert action.id == 14
96-
assert action.command == "issue_certificate"
35+
def test_init(self, bound_model: BoundCertificate):
36+
o = bound_model
37+
assert o.id == 2323
38+
assert o.name == "My Certificate"
39+
assert o.type == "managed"
40+
assert o.fingerprint == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f"
41+
assert o.certificate == "-----BEGIN CERTIFICATE-----\n..."
42+
assert len(o.domain_names) == 3
43+
assert o.domain_names[0] == "example.com"
44+
assert o.domain_names[1] == "webmail.example.com"
45+
assert o.domain_names[2] == "www.example.com"
46+
assert isinstance(o.status, ManagedCertificateStatus)
47+
assert o.status.issuance == "failed"
48+
assert o.status.renewal == "scheduled"
49+
assert o.status.error.code == "error_code"
50+
assert o.status.error.message == "error message"
9751

9852

9953
class TestCertificatesClient:

tests/unit/conftest.py

Lines changed: 99 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
from __future__ import annotations
44

5-
from collections.abc import Generator
5+
import inspect
6+
from typing import Callable, ClassVar, TypedDict
67
from unittest import mock
7-
from warnings import warn
88

99
import pytest
1010

@@ -134,11 +134,100 @@ def action_list_response(action1_running, action2_running):
134134
}
135135

136136

137-
@pytest.fixture()
138-
def hetzner_client() -> Generator[Client]:
139-
warn("DEPRECATED")
140-
client = Client(token="token")
141-
patcher = mock.patch.object(client, "request")
142-
patcher.start()
143-
yield client
144-
patcher.stop()
137+
def build_kwargs_mock(func: Callable) -> dict[str, mock.Mock]:
138+
"""
139+
Generate a kwargs dict that may be passed to the provided function for testing purposes.
140+
"""
141+
s = inspect.signature(func)
142+
143+
kwargs = {}
144+
for name, param in s.parameters.items():
145+
if name in ("self",):
146+
continue
147+
148+
if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY):
149+
kwargs[name] = mock.Mock()
150+
continue
151+
152+
# Ignore **kwargs
153+
if param.kind in (param.VAR_KEYWORD,):
154+
continue
155+
156+
raise NotImplementedError(f"unsupported parameter kind: {param.kind}")
157+
158+
return kwargs
159+
160+
161+
def pytest_generate_tests(metafunc: pytest.Metafunc):
162+
"""
163+
Magic function to generate a test for each bound model method.
164+
"""
165+
if "bound_model_method" in metafunc.fixturenames:
166+
metafunc.parametrize("bound_model_method", metafunc.cls.methods)
167+
168+
169+
class BoundModelTestOptions(TypedDict):
170+
sub_resource: bool
171+
172+
173+
class BoundModelTestCase:
174+
methods: ClassVar[list[Callable | tuple[Callable, BoundModelTestOptions]]]
175+
176+
def test_method_list(self, bound_model):
177+
"""
178+
Ensure the list of bound model methods is up to date.
179+
"""
180+
# Unpack methods
181+
methods = [m[0] if isinstance(m, tuple) else m for m in self.__class__.methods]
182+
183+
members_count = 0
184+
members_missing = []
185+
for name, member in inspect.getmembers(
186+
bound_model,
187+
lambda m: inspect.ismethod(m)
188+
and m.__func__ in bound_model.__class__.__dict__.values(),
189+
):
190+
# Actions methods are already tested in TestBoundModelActions.
191+
if name in ("__init__", "get_actions", "get_actions_list"):
192+
continue
193+
194+
if member.__func__ in methods:
195+
members_count += 1
196+
else:
197+
members_missing.append(member.__func__.__qualname__)
198+
199+
assert not members_missing, "untested methods:\n" + ",\n".join(members_missing)
200+
assert members_count == len(self.__class__.methods)
201+
202+
def test_method(
203+
self,
204+
resource_client,
205+
bound_model,
206+
bound_model_method: Callable | tuple[Callable, BoundModelTestOptions],
207+
):
208+
options = BoundModelTestOptions()
209+
if isinstance(bound_model_method, tuple):
210+
bound_model_method, options = bound_model_method
211+
212+
# Check if the resource client has a method named after the bound model method.
213+
assert hasattr(resource_client, bound_model_method.__name__)
214+
215+
# Mock the resource client method.
216+
resource_client_method_mock = mock.MagicMock()
217+
setattr(
218+
resource_client,
219+
bound_model_method.__name__,
220+
resource_client_method_mock,
221+
)
222+
223+
kwargs = build_kwargs_mock(bound_model_method)
224+
225+
# Call the bound model method
226+
result = getattr(bound_model, bound_model_method.__name__)(**kwargs)
227+
228+
if options.get("sub_resource"):
229+
resource_client_method_mock.assert_called_with(**kwargs)
230+
else:
231+
resource_client_method_mock.assert_called_with(bound_model, **kwargs)
232+
233+
assert result is resource_client_method_mock.return_value

0 commit comments

Comments
 (0)