Skip to content

Commit bb8c46e

Browse files
Copy existing tests & convert to unittest.
1 parent 493f6aa commit bb8c46e

File tree

2 files changed

+315
-0
lines changed

2 files changed

+315
-0
lines changed

tests/helpers.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Test helpers."""
2+
3+
import asyncio
4+
from typing import (
5+
Any,
6+
Awaitable,
7+
Callable,
8+
Iterable,
9+
)
10+
from unittest.mock import MagicMock
11+
12+
from db_wrapper.connection import ConnectionParameters
13+
from db_wrapper.client import Client
14+
15+
16+
def composed_to_string(seq: Iterable[Any]) -> str:
17+
"""Test helper to convert a sql query to a string for comparison.
18+
19+
Works for queries built with postgres.sql.Composable objects.
20+
From https://github.com/psycopg/psycopg2/issues/747#issuecomment-662857306
21+
"""
22+
parts = str(seq).split("'")
23+
return "".join([p for i, p in enumerate(parts) if i % 2 == 1])
24+
25+
26+
class AsyncMock(MagicMock):
27+
"""Extend unittest.mock.MagicMock to allow mocking of async functions."""
28+
# pylint: disable=invalid-overridden-method
29+
# pylint: disable=useless-super-delegation
30+
31+
async def __call__(self, *args, **kwargs): # type: ignore
32+
return super().__call__(*args, **kwargs)
33+
34+
35+
def async_test(
36+
test: Callable[[Any], Awaitable[None]]
37+
) -> Callable[[Any], None]:
38+
"""Decorate an async test method to run it in a one-off event loop."""
39+
def wrapped(instance: Any) -> None:
40+
asyncio.run(test(instance))
41+
42+
return wrapped
43+
44+
45+
def get_client() -> Client:
46+
"""Create a client with placeholder connection data."""
47+
conn_params = ConnectionParameters('a', 'a', 'a', 'a')
48+
return Client(conn_params)

tests/test_model.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""Tests for db_wrapper.model.
2+
3+
These tests are limited by mocking of Client's ability to query Postgres. This
4+
means that actual SQL queries aren't being tested, just the processing of any
5+
results received & the act of making a request.
6+
"""
7+
# pylint: disable=missing-function-docstring
8+
# pylint: disable=too-few-public-methods
9+
10+
from typing import (
11+
cast,
12+
Any,
13+
TypeVar,
14+
List,
15+
Tuple,
16+
TypedDict,
17+
)
18+
from uuid import uuid4, UUID
19+
import unittest
20+
from unittest import TestCase
21+
22+
import helpers
23+
24+
from db_wrapper.client import Client
25+
from db_wrapper.model import (
26+
Model,
27+
ModelData,
28+
Read,
29+
UnexpectedMultipleResults,
30+
NoResultFound)
31+
32+
33+
# Generic doesn't need a more descriptive name
34+
# pylint: disable=invalid-name
35+
T = TypeVar('T', bound=ModelData)
36+
37+
38+
def setup(query_result: List[T]) -> Tuple[Model[T], Client]:
39+
"""Setup helper that returns instances of both a Model & a Client.
40+
41+
Mocks the execute_and_return method on the Client instance to skip
42+
normal execution & just return the given query_result.
43+
44+
Using this setup helper that requires manually calling in each test
45+
instance is better than unittest's setUpModule or setUpClass methods
46+
because it allows the caller to skip all the boilerplate contained
47+
here, but still specify a return value for the mocked method on the
48+
returned Client instance.
49+
"""
50+
client = helpers.get_client()
51+
52+
# mock client's sql execution method
53+
client.execute_and_return = helpers.AsyncMock( # type:ignore
54+
return_value=query_result)
55+
56+
# init model with mocked client
57+
model = Model[Any](client, 'test')
58+
59+
return model, client
60+
61+
62+
class TestReadOneById(TestCase):
63+
"""Testing Model.read.one_by_id."""
64+
65+
@helpers.async_test
66+
async def test_it_correctly_builds_query_with_given_id(self) -> None:
67+
item: ModelData = {'_id': uuid4()}
68+
model, client = setup([item])
69+
await model.read.one_by_id(str(item['_id']))
70+
query_composed = cast(
71+
helpers.AsyncMock, client.execute_and_return).call_args[0][0]
72+
query = helpers.composed_to_string(query_composed)
73+
74+
self.assertEqual(query, "SELECT * "
75+
"FROM test "
76+
f"WHERE id = {item['_id']};")
77+
78+
@helpers.async_test
79+
async def test_it_returns_a_single_result(self) -> None:
80+
item: ModelData = {'_id': uuid4()}
81+
model, _ = setup([item])
82+
result = await model.read.one_by_id(str(item['_id']))
83+
84+
self.assertEqual(result, item)
85+
86+
@helpers.async_test
87+
async def test_it_raises_exception_if_more_than_one_result(self) -> None:
88+
item: ModelData = {'_id': uuid4()}
89+
model, _ = setup([item, item])
90+
91+
with self.assertRaises(UnexpectedMultipleResults):
92+
await model.read.one_by_id(str(item['_id']))
93+
94+
@helpers.async_test
95+
async def test_it_raises_exception_if_no_result_to_return(self) -> None:
96+
model: Model[ModelData]
97+
model, _ = setup([])
98+
99+
with self.assertRaises(NoResultFound):
100+
await model.read.one_by_id('id')
101+
102+
103+
class TestCreateOne(TestCase):
104+
"""Testing Model.create.one."""
105+
106+
class Item(ModelData):
107+
"""Example ModelData dataclass instance to define shape of data."""
108+
a: str
109+
b: str
110+
111+
@helpers.async_test
112+
async def test_it_correctly_builds_query_with_given_data(self) -> None:
113+
item: TestCreateOne.Item = {
114+
'_id': uuid4(),
115+
'a': 'a',
116+
'b': 'b',
117+
}
118+
model, client = setup([item])
119+
120+
await model.create.one(item)
121+
query_composed = cast(
122+
helpers.AsyncMock, client.execute_and_return).call_args[0][0]
123+
query = helpers.composed_to_string(query_composed)
124+
125+
self.assertEqual(query, 'INSERT INTO test(_id,a,b) '
126+
f"VALUES ({item['_id']},a,b) "
127+
'RETURNING *;')
128+
129+
@helpers.async_test
130+
async def test_it_returns_the_new_record(self) -> None:
131+
item: TestCreateOne.Item = {
132+
'_id': uuid4(),
133+
'a': 'a',
134+
'b': 'b',
135+
}
136+
model, _ = setup([item])
137+
138+
result = await model.create.one(item)
139+
140+
self.assertEqual(result, item)
141+
142+
143+
class TestUpdateOne(TestCase):
144+
"""Testing Model.update.one_by_id."""
145+
146+
class Item(ModelData):
147+
"""Example ModelData Item for testing."""
148+
a: str
149+
b: str
150+
151+
@helpers.async_test
152+
async def test_it_correctly_builds_query_with_given_data(self) -> None:
153+
item: TestUpdateOne.Item = {
154+
'_id': uuid4(),
155+
'a': 'a',
156+
'b': 'b',
157+
}
158+
# cast required to avoid mypy error due to unpacking
159+
# TypedDict, see more on GitHub issue
160+
# https://github.com/python/mypy/issues/4122
161+
updated = cast(TestUpdateOne.Item, {**item, 'b': 'c'})
162+
model, client = setup([updated])
163+
164+
await model.update.one(str(item['_id']), {'b': 'c'})
165+
query_composed = cast(
166+
helpers.AsyncMock, client.execute_and_return).call_args[0][0]
167+
query = helpers.composed_to_string(query_composed)
168+
169+
self.assertEqual(query, 'UPDATE test '
170+
'SET b = c '
171+
f"WHERE id = {item['_id']} "
172+
'RETURNING *;')
173+
174+
@helpers.async_test
175+
async def test_it_returns_the_new_record(self) -> None:
176+
item: TestUpdateOne.Item = {
177+
'_id': uuid4(),
178+
'a': 'a',
179+
'b': 'b',
180+
}
181+
# cast required to avoid mypy error due to unpacking
182+
# TypedDict, see more on GitHub issue
183+
# https://github.com/python/mypy/issues/4122
184+
updated = cast(TestUpdateOne.Item, {**item, 'b': 'c'})
185+
model, _ = setup([updated])
186+
187+
result = await model.update.one(str(item['_id']), {'b': 'c'})
188+
189+
self.assertEqual(result, updated)
190+
191+
192+
class TestDeleteOneById(TestCase):
193+
"""Testing Model.update.one_by_id"""
194+
195+
class Item(ModelData):
196+
"""Example ModelData Item for testing."""
197+
a: str
198+
b: str
199+
200+
@helpers.async_test
201+
async def test_it_correctly_builds_query_with_given_data(self) -> None:
202+
item: TestDeleteOneById.Item = {
203+
'_id': uuid4(),
204+
'a': 'a',
205+
'b': 'b',
206+
}
207+
model, client = setup([item])
208+
209+
await model.delete.one(str(item['_id']))
210+
211+
query_composed = cast(
212+
helpers.AsyncMock, client.execute_and_return).call_args[0][0]
213+
query = helpers.composed_to_string(query_composed)
214+
215+
self.assertEqual(query, 'DELETE FROM test '
216+
f"WHERE id = {item['_id']} "
217+
'RETURNING *;')
218+
219+
@helpers.async_test
220+
async def test_it_returns_the_deleted_record(self) -> None:
221+
item: TestDeleteOneById.Item = {
222+
'_id': uuid4(),
223+
'a': 'a',
224+
'b': 'b',
225+
}
226+
model, _ = setup([item])
227+
228+
result = await model.delete.one(str(item['_id']))
229+
230+
self.assertEqual(result, item)
231+
232+
233+
class TestExtendingModel(TestCase):
234+
"""Testing Model's extensibility."""
235+
model: Model[ModelData]
236+
237+
def setUp(self) -> None:
238+
class ReadExtended(Read[ModelData]):
239+
"""Extending Read with additional query."""
240+
241+
def new_query(self) -> None:
242+
pass
243+
244+
model = Model[ModelData](helpers.get_client(), 'test')
245+
model.read = ReadExtended(model.client, model.table)
246+
247+
self.model = model
248+
249+
def test_it_can_add_new_queries_by_replacing_a_crud_property(self) -> None:
250+
new_method = getattr(self.model.read, "new_query", None)
251+
252+
with self.subTest():
253+
self.assertIsNotNone(new_method)
254+
with self.subTest():
255+
self.assertTrue(callable(new_method))
256+
257+
def test_it_still_has_original_queries_after_extending(self) -> None:
258+
old_method = getattr(self.model.read, "one_by_id", None)
259+
260+
with self.subTest():
261+
self.assertIsNotNone(old_method)
262+
with self.subTest():
263+
self.assertTrue(callable(old_method))
264+
265+
266+
if __name__ == '__main__':
267+
unittest.main()

0 commit comments

Comments
 (0)