Skip to content

Commit ab3c1eb

Browse files
committed
tests: mock HTTP post calls to prevent race conditions
Previously, multiple tests were publishing the same task concurrently, which increased the likelihood of race conditions and flaky failures. This update replaces real HTTP post calls with mocks, making the tests deterministic and isolated from the server.
1 parent 36c20a2 commit ab3c1eb

2 files changed

Lines changed: 176 additions & 80 deletions

File tree

tests/test_api/test_http.py

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
from requests import Response, Request
2-
import time
3-
import xmltodict
1+
from requests import Response, Request, Session
2+
from unittest.mock import patch
43
import pytest
5-
from openml.testing import TestBase, TestAPIBase
4+
from openml.testing import TestAPIBase
65
import os
76
from pathlib import Path
87
from urllib.parse import urljoin
@@ -122,32 +121,6 @@ def test_get_refresh_cache(self):
122121
self.assertEqual(response2.status_code, 200)
123122
self.assertEqual(response1.content, response2.content)
124123

125-
@pytest.mark.uses_test_server()
126-
def test_post_and_delete(self):
127-
task_xml = """
128-
<oml:task_inputs xmlns:oml="http://openml.org/openml">
129-
<oml:task_type_id>5</oml:task_type_id>
130-
<oml:input name="source_data">193</oml:input>
131-
<oml:input name="estimation_procedure">17</oml:input>
132-
</oml:task_inputs>
133-
"""
134-
# post
135-
response = self.http_client.post(
136-
"task",
137-
files={"description": task_xml},
138-
)
139-
self.assertEqual(response.status_code, 200)
140-
xml_resp = xmltodict.parse(response.content)
141-
task_id = int(xml_resp["oml:upload_task"]["oml:id"])
142-
143-
# cleanup incase of failure
144-
TestBase._mark_entity_for_removal("task", task_id)
145-
TestBase.logger.info(f"collected from {__file__}: {task_id}")
146-
147-
# delete
148-
response = self.http_client.delete(f"task/{task_id}")
149-
self.assertEqual(response.status_code, 200)
150-
151124
@pytest.mark.uses_test_server()
152125
def test_download_creates_file(self):
153126
# small stable resource
@@ -198,3 +171,44 @@ def handler(response, path: Path, encoding: str):
198171

199172
assert path.exists()
200173
assert path.read_text() == "HANDLED"
174+
175+
def test_post(self):
176+
resource_name = "resource"
177+
resource_files = {"description": """Resource Description File"""}
178+
179+
with patch.object(Session, "request") as mock_request:
180+
mock_request.return_value = Response()
181+
mock_request.return_value.status_code = 200
182+
183+
self.http_client.post(
184+
resource_name,
185+
files=resource_files,
186+
)
187+
188+
mock_request.assert_called_once_with(
189+
method="POST",
190+
url=self.http_client.server + self.http_client.base_url + resource_name,
191+
params={},
192+
data={'api_key': self.http_client.api_key},
193+
headers=self.http_client.headers,
194+
files=resource_files,
195+
)
196+
197+
def test_delete(self):
198+
resource_name = "resource"
199+
resource_id = 123
200+
201+
with patch.object(Session, "request") as mock_request:
202+
mock_request.return_value = Response()
203+
mock_request.return_value.status_code = 200
204+
205+
self.http_client.delete(f"{resource_name}/{resource_id}")
206+
207+
mock_request.assert_called_once_with(
208+
method="DELETE",
209+
url=self.http_client.server + self.http_client.base_url + resource_name + "/" + str(resource_id),
210+
params={'api_key': self.http_client.api_key},
211+
data={},
212+
headers=self.http_client.headers,
213+
files=None,
214+
)

tests/test_api/test_versions.py

Lines changed: 132 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,106 @@
1-
from time import time
21
import pytest
3-
from openml.testing import TestBase, TestAPIBase
4-
from openml._api import ResourceV1API, ResourceV2API, FallbackProxy, ResourceAPI
2+
from requests import Session, Response
3+
from unittest.mock import patch
4+
from openml.testing import TestAPIBase
5+
from openml._api import FallbackProxy, ResourceAPI
56
from openml.enums import ResourceType, APIVersion
67
from openml.exceptions import OpenMLNotSupportedError
78

89

9-
@pytest.mark.uses_test_server()
1010
class TestResourceAPIBase(TestAPIBase):
1111
resource: ResourceAPI | FallbackProxy
1212

13-
def _publish_and_delete(self):
14-
task_xml = """
15-
<oml:task_inputs xmlns:oml="http://openml.org/openml">
16-
<oml:task_type_id>5</oml:task_type_id>
17-
<oml:input name="source_data">193</oml:input>
18-
<oml:input name="estimation_procedure">17</oml:input>
19-
</oml:task_inputs>
20-
"""
21-
# publish
22-
task_id = self.resource.publish(
23-
"task",
24-
files={"description": task_xml},
25-
)
26-
self.assertIsNotNone(task_id)
27-
28-
# cleanup incase of failure
29-
TestBase._mark_entity_for_removal("task", task_id)
30-
TestBase.logger.info(f"collected from {__file__}: {task_id}")
31-
32-
# delete
33-
success = self.resource.delete(task_id)
34-
self.assertTrue(success)
35-
36-
def _tag_and_untag(self):
37-
resource_id = 1
38-
unique_indicator = str(time()).replace(".", "")
39-
tag = f"{self.__class__.__name__}_test_tag_and_untag_{unique_indicator}"
40-
41-
tags = self.resource.tag(resource_id, tag)
42-
self.assertIn(tag, tags)
43-
44-
tags = self.resource.untag(resource_id, tag)
45-
self.assertNotIn(tag, tags)
46-
13+
@property
14+
def http_client(self):
15+
return self.resource._http
16+
17+
def _publish(self):
18+
resource_name = "task"
19+
resource_files = {"description": """Resource Description File"""}
20+
resource_id = 123
21+
22+
with patch.object(Session, "request") as mock_request:
23+
mock_request.return_value = Response()
24+
mock_request.return_value.status_code = 200
25+
mock_request.return_value._content = f'<oml:upload_task xmlns:oml="http://openml.org/openml">\n\t<oml:id>{resource_id}</oml:id>\n</oml:upload_task>\n'.encode("utf-8")
26+
27+
published_resource_id = self.resource.publish(
28+
resource_name,
29+
files=resource_files,
30+
)
31+
32+
self.assertEqual(resource_id, published_resource_id)
33+
34+
mock_request.assert_called_once_with(
35+
method="POST",
36+
url=self.http_client.server + self.http_client.base_url + resource_name,
37+
params={},
38+
data={'api_key': self.http_client.api_key},
39+
headers=self.http_client.headers,
40+
files=resource_files,
41+
)
42+
43+
def _delete(self):
44+
resource_name = "task"
45+
resource_id = 123
46+
47+
with patch.object(Session, "request") as mock_request:
48+
mock_request.return_value = Response()
49+
mock_request.return_value.status_code = 200
50+
mock_request.return_value._content = f'<oml:task_delete xmlns:oml="http://openml.org/openml">\n <oml:id>{resource_id}</oml:id>\n</oml:task_delete>\n'.encode("utf-8")
51+
52+
self.resource.delete(resource_id)
53+
54+
mock_request.assert_called_once_with(
55+
method="DELETE",
56+
url=self.http_client.server + self.http_client.base_url + resource_name + "/" + str(resource_id),
57+
params={'api_key': self.http_client.api_key},
58+
data={},
59+
headers=self.http_client.headers,
60+
files=None,
61+
)
62+
63+
def _tag(self):
64+
resource_id = 123
65+
resource_tag = "TAG"
66+
67+
with patch.object(Session, "request") as mock_request:
68+
mock_request.return_value = Response()
69+
mock_request.return_value.status_code = 200
70+
mock_request.return_value._content = f'<oml:task_tag xmlns:oml="http://openml.org/openml"><oml:id>{resource_id}</oml:id><oml:tag>{resource_tag}</oml:tag></oml:task_tag>'.encode("utf-8")
71+
72+
tags = self.resource.tag(resource_id, resource_tag)
73+
self.assertIn(resource_tag, tags)
74+
75+
mock_request.assert_called_once_with(
76+
method="POST",
77+
url=self.http_client.server + self.http_client.base_url + self.resource.resource_type + "/tag",
78+
params={},
79+
data={'api_key': self.http_client.api_key, 'task_id': resource_id, 'tag': resource_tag},
80+
headers=self.http_client.headers,
81+
files=None,
82+
)
83+
84+
def _untag(self):
85+
resource_id = 123
86+
resource_tag = "TAG"
87+
88+
with patch.object(Session, "request") as mock_request:
89+
mock_request.return_value = Response()
90+
mock_request.return_value.status_code = 200
91+
mock_request.return_value._content = f'<oml:task_untag xmlns:oml="http://openml.org/openml"><oml:id>{resource_id}</oml:id></oml:task_untag>'.encode("utf-8")
92+
93+
tags = self.resource.untag(resource_id, resource_tag)
94+
self.assertNotIn(resource_tag, tags)
95+
96+
mock_request.assert_called_once_with(
97+
method="POST",
98+
url=self.http_client.server + self.http_client.base_url + self.resource.resource_type + "/untag",
99+
params={},
100+
data={'api_key': self.http_client.api_key, 'task_id': resource_id, 'tag': resource_tag},
101+
headers=self.http_client.headers,
102+
files=None,
103+
)
47104

48105
class TestResourceV1API(TestResourceAPIBase):
49106
def setUp(self):
@@ -53,11 +110,17 @@ def setUp(self):
53110
resource_type=ResourceType.TASK,
54111
)
55112

56-
def test_publish_and_delete(self):
57-
self._publish_and_delete()
113+
def test_publish(self):
114+
self._publish()
115+
116+
def test_delete(self):
117+
self._delete()
58118

59-
def test_tag_and_untag(self):
60-
self._tag_and_untag()
119+
def test_tag(self):
120+
self._tag()
121+
122+
def test_untag(self):
123+
self._untag()
61124

62125

63126
class TestResourceV2API(TestResourceAPIBase):
@@ -68,16 +131,29 @@ def setUp(self):
68131
resource_type=ResourceType.TASK,
69132
)
70133

71-
def test_publish_and_delete(self):
134+
def test_publish(self):
135+
with pytest.raises(OpenMLNotSupportedError):
136+
self._publish()
137+
138+
def test_delete(self):
139+
with pytest.raises(OpenMLNotSupportedError):
140+
self._delete()
141+
142+
def test_tag(self):
72143
with pytest.raises(OpenMLNotSupportedError):
73-
self._tag_and_untag()
144+
self._tag()
74145

75-
def test_tag_and_untag(self):
146+
def test_untag(self):
76147
with pytest.raises(OpenMLNotSupportedError):
77-
self._tag_and_untag()
148+
self._untag()
78149

79150

80151
class TestResourceFallbackAPI(TestResourceAPIBase):
152+
@property
153+
def http_client(self):
154+
# since these methods are not implemented for v2, they will fallback to v1 api
155+
return self.http_clients[APIVersion.V1]
156+
81157
def setUp(self):
82158
super().setUp()
83159
resource_v1 = self._create_resource(
@@ -90,8 +166,14 @@ def setUp(self):
90166
)
91167
self.resource = FallbackProxy(resource_v2, resource_v1)
92168

93-
def test_publish_and_delete(self):
94-
self._publish_and_delete()
169+
def test_publish(self):
170+
self._publish()
171+
172+
def test_delete(self):
173+
self._delete()
174+
175+
def test_tag(self):
176+
self._tag()
95177

96-
def test_tag_and_untag(self):
97-
self._tag_and_untag()
178+
def test_untag(self):
179+
self._untag()

0 commit comments

Comments
 (0)