assertical 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2024
4
+
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the “Software”), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
@@ -0,0 +1,318 @@
1
+ Metadata-Version: 2.1
2
+ Name: assertical
3
+ Version: 0.0.1
4
+ Summary: Assertical - a modular library for helping write (async) integration/unit tests for fastapi/sqlalchemy/postgres projects
5
+ Author: Battery Storage and Grid Integration Program
6
+ Project-URL: Homepage, https://github.com/bsgip/assertical
7
+ Keywords: test,fastapi,postgres,sqlalchemy
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Software Development :: Testing
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE.txt
20
+ Requires-Dist: pytest
21
+ Requires-Dist: pytest-asyncio
22
+ Requires-Dist: anyio
23
+ Requires-Dist: httpx
24
+ Provides-Extra: all
25
+ Requires-Dist: assertical[dev,fastapi,pandas,postgres,pydantic,xml]; extra == "all"
26
+ Provides-Extra: dev
27
+ Requires-Dist: bandit; extra == "dev"
28
+ Requires-Dist: flake8; extra == "dev"
29
+ Requires-Dist: mypy; extra == "dev"
30
+ Requires-Dist: black; extra == "dev"
31
+ Requires-Dist: coverage; extra == "dev"
32
+ Provides-Extra: fastapi
33
+ Requires-Dist: fastapi; extra == "fastapi"
34
+ Requires-Dist: asgi_lifespan; extra == "fastapi"
35
+ Provides-Extra: pandas
36
+ Requires-Dist: pandas; extra == "pandas"
37
+ Requires-Dist: pandas_stubs; extra == "pandas"
38
+ Requires-Dist: numpy; extra == "pandas"
39
+ Provides-Extra: pydantic
40
+ Requires-Dist: pydantic; extra == "pydantic"
41
+ Provides-Extra: postgres
42
+ Requires-Dist: pytest-postgresql; extra == "postgres"
43
+ Requires-Dist: psycopg; extra == "postgres"
44
+ Requires-Dist: sqlalchemy>=2.0.0; extra == "postgres"
45
+ Provides-Extra: xml
46
+ Requires-Dist: pydantic_xml[lxml]; extra == "xml"
47
+
48
+ # Assertical (assertical)
49
+
50
+ Assertical is a library for helping write (async) integration/unit tests for fastapi/postgres/other projects. It has been developed by the Battery Storage and Grid Integration Program (BSGIP) at the Australian National University (https://bsgip.com/) for use with a variety of our internal libraries/packages.
51
+
52
+ It's attempting to be lightweight and modular, if you're not using `pandas` then just don't import the pandas asserts.
53
+
54
+ Contributions/PR's are welcome
55
+
56
+ ## Example Usage
57
+
58
+ ### Generating Class Instances
59
+
60
+ Say you have an SQLAlchemy model (the below also supports dataclasses, pydantic models and any type that expose its properties/types at runtime)
61
+ ```
62
+ class Student(DeclarativeBase):
63
+ student_id: Mapped[int] = mapped_column(INTEGER, primary_key=True)
64
+ date_of_birth: Mapped[datetime] = mapped_column(DateTime)
65
+ name_full: Mapped[str] = mapped_column(VARCHAR(128))
66
+ name_preferred: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
67
+ height: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(7, 2), nullable=True)
68
+ weight: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(7, 2), nullable=True)
69
+ ```
70
+ Instead of writing the following boilerplate in your tests:
71
+
72
+ ```
73
+ def test_my_insert():
74
+ # Arrange
75
+ s1 = Student(student_id=1, date_of_birth=datetime(2014, 1, 25), name_full="Bobby Tables", name_preferred="Bob", height=Decimal("185.5"), weight=Decimal("85.2"))
76
+ s2 = Student(student_id=2, date_of_birth=datetime(2015, 9, 23), name_full="Carly Chairs", name_preferred="CC", height=Decimal("175.5"), weight=Decimal("65"))
77
+ # Act ...
78
+ ```
79
+
80
+ It can be simplified to:
81
+
82
+ ```
83
+ def test_my_insert():
84
+ # Arrange
85
+ s1 = generate_class_instance(Student, seed=1)
86
+ s2 = generate_class_instance(Student, seed=2)
87
+ # Act ...
88
+ ```
89
+
90
+ Which will generate two instances of Student with every property being set with appropriately typed values and unique values. Eg s1/s2 will be proper `Student` instances with values like:
91
+
92
+ | field | s1 | s2 |
93
+ | ----- | -- | -- |
94
+ | student_id | 5 (int) | 6 (int) |
95
+ | date_of_birth | '2010-01-02T00:00:01Z' (datetime) | '2010-01-03T00:00:02Z' (datetime) |
96
+ | name_full | '3-str' (str) | '4-str' (str) |
97
+ | name_preferred | '4-str' (Decimal) | '5-str' (Decimal) |
98
+ | height | 2 (Decimal) | 3 (Decimal) |
99
+ | weight | 6 (Decimal) | 7 (Decimal) |
100
+
101
+ Passing property name/values via kwargs is also supported :
102
+
103
+ `generate_class_instance(Student, seed=1, height=Decimal("12.34"))` will generate a `Student` instance similar to `s1` above but where `height` is `Decimal("12.34")`
104
+
105
+ You can also control the behaviour of `Optional` properties - by default they will populate with the full type but using `generate_class_instance(Student, optional_is_none=True)` will generate a `Student` instance where `height`, `weight` and `name_preferred` are `None`.
106
+
107
+ Finally, say we add the following "child" class `TestResult`:
108
+
109
+ ```
110
+ class TestResult(DeclarativeBase):
111
+ test_result_id = mapped_column(INTEGER, primary_key=True)
112
+ student_id: Mapped[int] = mapped_column(INTEGER)
113
+ class: Mapped[str] = mapped_column(VARCHAR(128))
114
+ grade: Mapped[str] = mapped_column(VARCHAR(8))
115
+ ```
116
+
117
+ And assuming `Student` has a property `all_results: Mapped[list[TestResult]]`. `generate_class_instance(Student)` will NOT supply a value for `all_results`. But by setting `generate_class_instance(Student, generate_relationships=True)` the generation will recurse into any generatable / list of generatable type instances.
118
+
119
+
120
+ ### Mocking HTTP AsyncClient
121
+
122
+ `MockedAsyncClient` is a duck typed equivalent to `from httpx import AsyncClient` that can be useful fo injecting into classes that depend on a AsyncClient implementation.
123
+
124
+ Example usage that injects a MockedAsyncClient that will always return a `HTTPStatus.NO_CONTENT` for all requests:
125
+ ```
126
+ mock_async_client = MockedAsyncClient(Response(status_code=HTTPStatus.NO_CONTENT))
127
+ with mock.patch("my_package.my_module.AsyncClient") as mock_client:
128
+ # test body here
129
+ assert mock_client.call_count_by_method[HTTPMethod.GET] > 0
130
+ ```
131
+ The constructor for `MockedAsyncClient` allows you to setup either constant or varying responses. Eg: by supplying a list of responses you can mock behaviour that changes over multiple requests.
132
+
133
+ Eg: This instance will raise an Exception, then return a HTTP 500 then a HTTP 200
134
+ ```
135
+ MockedAsyncClient([
136
+ Exception("My mocked error that will be raised"),
137
+ Response(status_code=HTTPStatus.NO_CONTENT),
138
+ Response(status_code=HTTPStatus.OK),
139
+ ])
140
+ ```
141
+ Response behavior can also be also be specified per remote uri:
142
+
143
+ ```
144
+ MockedAsyncClient({
145
+ "http://first.example.com/": [
146
+ Exception("My mocked error that will be raised"),
147
+ Response(status_code=HTTPStatus.NO_CONTENT),
148
+ Response(status_code=HTTPStatus.OK),
149
+ ],
150
+ "http://second.example.com/": Response(status_code=HTTPStatus.NO_CONTENT),
151
+ })
152
+ ```
153
+
154
+ ### Environment Management
155
+
156
+ If you have tests that depend on environment variables, the `assertical.fixtures.environment` module has utilities to aid in snapshotting/restoring the state of the operating system environment variables.
157
+
158
+ Eg: This `environment_snapshot` context manager will snapshot the environment allowing a test to freely modify it and then reset everything to before the test run
159
+ ```
160
+ import os
161
+ from assertical.fixtures.environment import environment_snapshot
162
+
163
+ def test_my_custom_test():
164
+ with environment_snapshot():
165
+ os.environ["MY_ENV"] = new_value
166
+ # Do test body
167
+ ```
168
+
169
+ This can also be simplified by using a fixture:
170
+ ```
171
+ @pytest.fixture
172
+ def preserved_environment():
173
+ with environment_snapshot():
174
+ yield
175
+
176
+ def test_my_custom_test_2(preserved_environment):
177
+ os.environ["MY_ENV"] = new_value
178
+ # Do test body
179
+ ```
180
+
181
+ ### Running Testing FastAPI Apps
182
+
183
+ FastAPI (or ASGI apps) can be loaded for integration testing in two ways with Assertical:
184
+ 1. Creating a lightweight httpx.AsyncClient wrapper around the app instance
185
+ 1. Running a full uvicorn instance
186
+
187
+ #### AsyncClient Wrapper
188
+
189
+ `assertical.fixtures.fastapi.start_app_with_client` will act as an async context manager that can wrap an ASGI app instance and yield a `httpx.AsyncClient` that will communicate directly with that app instance.
190
+
191
+ Eg: This fixture will start an app instance and tests can depend on it to start up a fresh app instance for every test
192
+ ```
193
+ @pytest.fixture
194
+ async def custom_test_client():
195
+ app: FastApi = generate_app() # This is just a reference to a fully constructed instance of your FastApi app
196
+ async with start_app_with_client(app) as c:
197
+ yield c # c is an instance of httpx.AsyncClient
198
+
199
+
200
+ @pytest.mark.anyio
201
+ async def test_thing(custom_test_client: AsyncClient):
202
+ response = await custom_test_client.get("/my_endpoint")
203
+ assert response.status == 200
204
+ ```
205
+
206
+ #### Full uvicorn instance
207
+
208
+ `assertical.fixtures.fastapi.start_uvicorn_server` will behave similar to the above `start_app_with_client` but it will start a full running instance of uvicorn that will tear down once the context manager is exited.
209
+
210
+ This can be useful if you need to not just test the ASGI behavior of the app, but also how it interacts with a "real" uvicorn instance. Perhaps your app has middleware playing around with the underlying starlette functionality?
211
+
212
+ Eg: This fixture will start an app instance (listening on a fixed address) and will return the base URI of that instance
213
+ ```
214
+ @pytest.fixture
215
+ async def custom_test_uri():
216
+ app: FastApi = generate_app() # This is just a reference to a fully constructed instance of your FastApi app
217
+ async with start_uvicorn_server(app) as c:
218
+ yield c # c is uri like "http://127.0.0.1:12345"
219
+
220
+
221
+ @pytest.mark.anyio
222
+ async def test_thing(custom_test_uri: str):
223
+ client = AsyncClient()
224
+ response = await client.get(custom_test_uri + "/my_endpoint")
225
+ assert response.status == 200
226
+ ```
227
+
228
+
229
+ ### Assertion utilities
230
+
231
+ #### Generator assertical.asserts.generator.*
232
+
233
+ This package isn't designed to be a collection of all possible asserts, other packages handle that. What is included are a few useful asserts around typing
234
+
235
+ `assertical.asserts.generator.assert_class_instance_equality()` will allow the comparison of two objects, property by property using a class/type definition as the source of compared properties. Using the above earlier `Student` example:
236
+
237
+ ```
238
+ s1 = generate_class_instance(Student, seed=1)
239
+ s1_dup = generate_class_instance(Student, seed=1)
240
+ s2 = generate_class_instance(Student, seed=2)
241
+
242
+ # This will raise an assertion error saying that certain Student properties don't match
243
+ assert_class_instance_equality(Student, s1, s2)
244
+
245
+ # This will NOT raise an assertion as each property will be the same value/type
246
+ assert_class_instance_equality(Student, s1, s1_dup)
247
+
248
+
249
+ # This will compare on all Student properties EXCEPT 'student_id'
250
+ assert_class_instance_equality(Student, s1, s1_dup, ignored_properties=set(['student_id]))
251
+ ```
252
+
253
+ #### Time assertical.asserts.time.*
254
+
255
+ contains some utilities for comparing times in different forms (eg timestamps, datetimes etc)
256
+
257
+ For example, the following asserts that a timestamp or datetime is "roughly now"
258
+ ```
259
+ dt1 = datetime(2023, 11, 10, 1, 2, 0)
260
+ ts2 = datetime(2023, 11, 10, 1, 2, 3).timestamp() # 3 seconds difference
261
+ ts2 = datetime(2023, 11, 10, 1, 2, 3).timestamp() # 3 seconds difference
262
+ assert_fuzzy_datetime_match(dt1, ts2, fuzziness_seconds=5) # This will pass (difference is <5 seconds)
263
+ assert_fuzzy_datetime_match(dt1, ts2, fuzziness_seconds=2) # This will raise (difference is >2 seconds)
264
+ ```
265
+
266
+ #### Type collections assertical.asserts.type.*
267
+
268
+ `assertical.asserts.type` contains some utilities for asserting collections of types are properly formed.
269
+
270
+ For example, the following asserts that an instance is a list type, that only contains Student elements and that there are 5 total items.
271
+ ```
272
+ my_custom_list = []
273
+ assert_list_type(Student, my_custom_list, count=5)
274
+ ```
275
+
276
+ #### Pandas assertical.asserts.pandas.*
277
+
278
+ Contains a number of simple assertions for a dataframe for ensuring certain columns/rows exist
279
+
280
+ ## Installation (for use)
281
+
282
+ `pip install assertical[all]`
283
+
284
+ ## Installation (for dev)
285
+
286
+ `pip install -e .[all]`
287
+
288
+ ## Modular Components
289
+
290
+ | **module** | **requires** |
291
+ | ---------- | ------------ |
292
+ | `asserts/generator` | `None`+ |
293
+ | `asserts/pandas` | `assertical[pandas]` |
294
+ | `fake/generator` | `None`+ |
295
+ | `fake/sqlalchemy` | `assertical[postgres]` |
296
+ | `fixtures/fastapi` | `assertical[fastapi]` |
297
+ | `fixtures/postgres` | `assertical[postgres]` |
298
+
299
+ + No requirements are mandatory but additional types will be supported if `assertical[pydantic]`, `assertical[postgres]`, `assertical[xml]` are installed
300
+
301
+ All other types just require just the base `pip install assertical`
302
+
303
+ ## Editors
304
+
305
+
306
+ ### vscode
307
+
308
+ The file `vscode/settings.json` is an example configuration for vscode. To use these setting copy this file to `.vscode/settings,json`
309
+
310
+ The main features of this settings file are:
311
+ - Enabling flake8 and disabling pylint
312
+ - Autoformat on save (using the black and isort formatters)
313
+
314
+ Settings that you may want to change:
315
+ - Set the python path to your python in your venv with `python.defaultInterpreterPath`.
316
+ - Enable mypy by setting `python.linting.mypyEnabled` to true in settings.json.
317
+
318
+
@@ -0,0 +1,271 @@
1
+ # Assertical (assertical)
2
+
3
+ Assertical is a library for helping write (async) integration/unit tests for fastapi/postgres/other projects. It has been developed by the Battery Storage and Grid Integration Program (BSGIP) at the Australian National University (https://bsgip.com/) for use with a variety of our internal libraries/packages.
4
+
5
+ It's attempting to be lightweight and modular, if you're not using `pandas` then just don't import the pandas asserts.
6
+
7
+ Contributions/PR's are welcome
8
+
9
+ ## Example Usage
10
+
11
+ ### Generating Class Instances
12
+
13
+ Say you have an SQLAlchemy model (the below also supports dataclasses, pydantic models and any type that expose its properties/types at runtime)
14
+ ```
15
+ class Student(DeclarativeBase):
16
+ student_id: Mapped[int] = mapped_column(INTEGER, primary_key=True)
17
+ date_of_birth: Mapped[datetime] = mapped_column(DateTime)
18
+ name_full: Mapped[str] = mapped_column(VARCHAR(128))
19
+ name_preferred: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True)
20
+ height: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(7, 2), nullable=True)
21
+ weight: Mapped[Optional[Decimal]] = mapped_column(DECIMAL(7, 2), nullable=True)
22
+ ```
23
+ Instead of writing the following boilerplate in your tests:
24
+
25
+ ```
26
+ def test_my_insert():
27
+ # Arrange
28
+ s1 = Student(student_id=1, date_of_birth=datetime(2014, 1, 25), name_full="Bobby Tables", name_preferred="Bob", height=Decimal("185.5"), weight=Decimal("85.2"))
29
+ s2 = Student(student_id=2, date_of_birth=datetime(2015, 9, 23), name_full="Carly Chairs", name_preferred="CC", height=Decimal("175.5"), weight=Decimal("65"))
30
+ # Act ...
31
+ ```
32
+
33
+ It can be simplified to:
34
+
35
+ ```
36
+ def test_my_insert():
37
+ # Arrange
38
+ s1 = generate_class_instance(Student, seed=1)
39
+ s2 = generate_class_instance(Student, seed=2)
40
+ # Act ...
41
+ ```
42
+
43
+ Which will generate two instances of Student with every property being set with appropriately typed values and unique values. Eg s1/s2 will be proper `Student` instances with values like:
44
+
45
+ | field | s1 | s2 |
46
+ | ----- | -- | -- |
47
+ | student_id | 5 (int) | 6 (int) |
48
+ | date_of_birth | '2010-01-02T00:00:01Z' (datetime) | '2010-01-03T00:00:02Z' (datetime) |
49
+ | name_full | '3-str' (str) | '4-str' (str) |
50
+ | name_preferred | '4-str' (Decimal) | '5-str' (Decimal) |
51
+ | height | 2 (Decimal) | 3 (Decimal) |
52
+ | weight | 6 (Decimal) | 7 (Decimal) |
53
+
54
+ Passing property name/values via kwargs is also supported :
55
+
56
+ `generate_class_instance(Student, seed=1, height=Decimal("12.34"))` will generate a `Student` instance similar to `s1` above but where `height` is `Decimal("12.34")`
57
+
58
+ You can also control the behaviour of `Optional` properties - by default they will populate with the full type but using `generate_class_instance(Student, optional_is_none=True)` will generate a `Student` instance where `height`, `weight` and `name_preferred` are `None`.
59
+
60
+ Finally, say we add the following "child" class `TestResult`:
61
+
62
+ ```
63
+ class TestResult(DeclarativeBase):
64
+ test_result_id = mapped_column(INTEGER, primary_key=True)
65
+ student_id: Mapped[int] = mapped_column(INTEGER)
66
+ class: Mapped[str] = mapped_column(VARCHAR(128))
67
+ grade: Mapped[str] = mapped_column(VARCHAR(8))
68
+ ```
69
+
70
+ And assuming `Student` has a property `all_results: Mapped[list[TestResult]]`. `generate_class_instance(Student)` will NOT supply a value for `all_results`. But by setting `generate_class_instance(Student, generate_relationships=True)` the generation will recurse into any generatable / list of generatable type instances.
71
+
72
+
73
+ ### Mocking HTTP AsyncClient
74
+
75
+ `MockedAsyncClient` is a duck typed equivalent to `from httpx import AsyncClient` that can be useful fo injecting into classes that depend on a AsyncClient implementation.
76
+
77
+ Example usage that injects a MockedAsyncClient that will always return a `HTTPStatus.NO_CONTENT` for all requests:
78
+ ```
79
+ mock_async_client = MockedAsyncClient(Response(status_code=HTTPStatus.NO_CONTENT))
80
+ with mock.patch("my_package.my_module.AsyncClient") as mock_client:
81
+ # test body here
82
+ assert mock_client.call_count_by_method[HTTPMethod.GET] > 0
83
+ ```
84
+ The constructor for `MockedAsyncClient` allows you to setup either constant or varying responses. Eg: by supplying a list of responses you can mock behaviour that changes over multiple requests.
85
+
86
+ Eg: This instance will raise an Exception, then return a HTTP 500 then a HTTP 200
87
+ ```
88
+ MockedAsyncClient([
89
+ Exception("My mocked error that will be raised"),
90
+ Response(status_code=HTTPStatus.NO_CONTENT),
91
+ Response(status_code=HTTPStatus.OK),
92
+ ])
93
+ ```
94
+ Response behavior can also be also be specified per remote uri:
95
+
96
+ ```
97
+ MockedAsyncClient({
98
+ "http://first.example.com/": [
99
+ Exception("My mocked error that will be raised"),
100
+ Response(status_code=HTTPStatus.NO_CONTENT),
101
+ Response(status_code=HTTPStatus.OK),
102
+ ],
103
+ "http://second.example.com/": Response(status_code=HTTPStatus.NO_CONTENT),
104
+ })
105
+ ```
106
+
107
+ ### Environment Management
108
+
109
+ If you have tests that depend on environment variables, the `assertical.fixtures.environment` module has utilities to aid in snapshotting/restoring the state of the operating system environment variables.
110
+
111
+ Eg: This `environment_snapshot` context manager will snapshot the environment allowing a test to freely modify it and then reset everything to before the test run
112
+ ```
113
+ import os
114
+ from assertical.fixtures.environment import environment_snapshot
115
+
116
+ def test_my_custom_test():
117
+ with environment_snapshot():
118
+ os.environ["MY_ENV"] = new_value
119
+ # Do test body
120
+ ```
121
+
122
+ This can also be simplified by using a fixture:
123
+ ```
124
+ @pytest.fixture
125
+ def preserved_environment():
126
+ with environment_snapshot():
127
+ yield
128
+
129
+ def test_my_custom_test_2(preserved_environment):
130
+ os.environ["MY_ENV"] = new_value
131
+ # Do test body
132
+ ```
133
+
134
+ ### Running Testing FastAPI Apps
135
+
136
+ FastAPI (or ASGI apps) can be loaded for integration testing in two ways with Assertical:
137
+ 1. Creating a lightweight httpx.AsyncClient wrapper around the app instance
138
+ 1. Running a full uvicorn instance
139
+
140
+ #### AsyncClient Wrapper
141
+
142
+ `assertical.fixtures.fastapi.start_app_with_client` will act as an async context manager that can wrap an ASGI app instance and yield a `httpx.AsyncClient` that will communicate directly with that app instance.
143
+
144
+ Eg: This fixture will start an app instance and tests can depend on it to start up a fresh app instance for every test
145
+ ```
146
+ @pytest.fixture
147
+ async def custom_test_client():
148
+ app: FastApi = generate_app() # This is just a reference to a fully constructed instance of your FastApi app
149
+ async with start_app_with_client(app) as c:
150
+ yield c # c is an instance of httpx.AsyncClient
151
+
152
+
153
+ @pytest.mark.anyio
154
+ async def test_thing(custom_test_client: AsyncClient):
155
+ response = await custom_test_client.get("/my_endpoint")
156
+ assert response.status == 200
157
+ ```
158
+
159
+ #### Full uvicorn instance
160
+
161
+ `assertical.fixtures.fastapi.start_uvicorn_server` will behave similar to the above `start_app_with_client` but it will start a full running instance of uvicorn that will tear down once the context manager is exited.
162
+
163
+ This can be useful if you need to not just test the ASGI behavior of the app, but also how it interacts with a "real" uvicorn instance. Perhaps your app has middleware playing around with the underlying starlette functionality?
164
+
165
+ Eg: This fixture will start an app instance (listening on a fixed address) and will return the base URI of that instance
166
+ ```
167
+ @pytest.fixture
168
+ async def custom_test_uri():
169
+ app: FastApi = generate_app() # This is just a reference to a fully constructed instance of your FastApi app
170
+ async with start_uvicorn_server(app) as c:
171
+ yield c # c is uri like "http://127.0.0.1:12345"
172
+
173
+
174
+ @pytest.mark.anyio
175
+ async def test_thing(custom_test_uri: str):
176
+ client = AsyncClient()
177
+ response = await client.get(custom_test_uri + "/my_endpoint")
178
+ assert response.status == 200
179
+ ```
180
+
181
+
182
+ ### Assertion utilities
183
+
184
+ #### Generator assertical.asserts.generator.*
185
+
186
+ This package isn't designed to be a collection of all possible asserts, other packages handle that. What is included are a few useful asserts around typing
187
+
188
+ `assertical.asserts.generator.assert_class_instance_equality()` will allow the comparison of two objects, property by property using a class/type definition as the source of compared properties. Using the above earlier `Student` example:
189
+
190
+ ```
191
+ s1 = generate_class_instance(Student, seed=1)
192
+ s1_dup = generate_class_instance(Student, seed=1)
193
+ s2 = generate_class_instance(Student, seed=2)
194
+
195
+ # This will raise an assertion error saying that certain Student properties don't match
196
+ assert_class_instance_equality(Student, s1, s2)
197
+
198
+ # This will NOT raise an assertion as each property will be the same value/type
199
+ assert_class_instance_equality(Student, s1, s1_dup)
200
+
201
+
202
+ # This will compare on all Student properties EXCEPT 'student_id'
203
+ assert_class_instance_equality(Student, s1, s1_dup, ignored_properties=set(['student_id]))
204
+ ```
205
+
206
+ #### Time assertical.asserts.time.*
207
+
208
+ contains some utilities for comparing times in different forms (eg timestamps, datetimes etc)
209
+
210
+ For example, the following asserts that a timestamp or datetime is "roughly now"
211
+ ```
212
+ dt1 = datetime(2023, 11, 10, 1, 2, 0)
213
+ ts2 = datetime(2023, 11, 10, 1, 2, 3).timestamp() # 3 seconds difference
214
+ ts2 = datetime(2023, 11, 10, 1, 2, 3).timestamp() # 3 seconds difference
215
+ assert_fuzzy_datetime_match(dt1, ts2, fuzziness_seconds=5) # This will pass (difference is <5 seconds)
216
+ assert_fuzzy_datetime_match(dt1, ts2, fuzziness_seconds=2) # This will raise (difference is >2 seconds)
217
+ ```
218
+
219
+ #### Type collections assertical.asserts.type.*
220
+
221
+ `assertical.asserts.type` contains some utilities for asserting collections of types are properly formed.
222
+
223
+ For example, the following asserts that an instance is a list type, that only contains Student elements and that there are 5 total items.
224
+ ```
225
+ my_custom_list = []
226
+ assert_list_type(Student, my_custom_list, count=5)
227
+ ```
228
+
229
+ #### Pandas assertical.asserts.pandas.*
230
+
231
+ Contains a number of simple assertions for a dataframe for ensuring certain columns/rows exist
232
+
233
+ ## Installation (for use)
234
+
235
+ `pip install assertical[all]`
236
+
237
+ ## Installation (for dev)
238
+
239
+ `pip install -e .[all]`
240
+
241
+ ## Modular Components
242
+
243
+ | **module** | **requires** |
244
+ | ---------- | ------------ |
245
+ | `asserts/generator` | `None`+ |
246
+ | `asserts/pandas` | `assertical[pandas]` |
247
+ | `fake/generator` | `None`+ |
248
+ | `fake/sqlalchemy` | `assertical[postgres]` |
249
+ | `fixtures/fastapi` | `assertical[fastapi]` |
250
+ | `fixtures/postgres` | `assertical[postgres]` |
251
+
252
+ + No requirements are mandatory but additional types will be supported if `assertical[pydantic]`, `assertical[postgres]`, `assertical[xml]` are installed
253
+
254
+ All other types just require just the base `pip install assertical`
255
+
256
+ ## Editors
257
+
258
+
259
+ ### vscode
260
+
261
+ The file `vscode/settings.json` is an example configuration for vscode. To use these setting copy this file to `.vscode/settings,json`
262
+
263
+ The main features of this settings file are:
264
+ - Enabling flake8 and disabling pylint
265
+ - Autoformat on save (using the black and isort formatters)
266
+
267
+ Settings that you may want to change:
268
+ - Set the python path to your python in your venv with `python.defaultInterpreterPath`.
269
+ - Enable mypy by setting `python.linting.mypyEnabled` to true in settings.json.
270
+
271
+
@@ -0,0 +1,70 @@
1
+ [tool.black]
2
+ line-length = 120
3
+
4
+ [tool.pytest.ini_options]
5
+ pythonpath = ["src/"]
6
+ testpaths = "tests"
7
+
8
+ [tool.isort]
9
+ profile = "black"
10
+
11
+ [tool.bandit]
12
+ exclude_dirs = ["tests"]
13
+ skips = ["B101"]
14
+
15
+ [tool.mypy]
16
+ exclude = ["tests"]
17
+ check_untyped_defs = true
18
+ disallow_incomplete_defs = true
19
+ disallow_untyped_calls = true
20
+ disallow_untyped_decorators = true
21
+ disallow_untyped_defs = true
22
+ namespace_packages = true
23
+ warn_redundant_casts = true
24
+ warn_unused_ignores = true
25
+
26
+
27
+ [build-system]
28
+ requires = ["setuptools >= 40.9.0", "wheel"]
29
+ build-backend = "setuptools.build_meta"
30
+
31
+ [project]
32
+ name = "assertical"
33
+ version = "0.0.1"
34
+ description = "Assertical - a modular library for helping write (async) integration/unit tests for fastapi/sqlalchemy/postgres projects"
35
+ authors = [{ name = "Battery Storage and Grid Integration Program" }]
36
+ readme = "README.md"
37
+ license = { file = "LICENSE" }
38
+ keywords = ["test", "fastapi", "postgres", "sqlalchemy"]
39
+ dependencies = ["pytest", "pytest-asyncio", "anyio", "httpx"]
40
+ requires-python = ">=3.9"
41
+ classifiers = [
42
+ "Development Status :: 4 - Beta",
43
+ "Intended Audience :: Developers",
44
+ "Topic :: Software Development :: Testing",
45
+
46
+ # Pick your license as you wish (see also "license" above)
47
+ "Framework :: FastAPI",
48
+ "License :: OSI Approved :: MIT License",
49
+
50
+ # Specify the Python versions you support here.
51
+ "Programming Language :: Python :: 3",
52
+ "Programming Language :: Python :: 3.9",
53
+ "Programming Language :: Python :: 3.10",
54
+ "Programming Language :: Python :: 3.11",
55
+ ]
56
+
57
+ [project.urls]
58
+ Homepage = "https://github.com/bsgip/assertical"
59
+
60
+ [project.optional-dependencies]
61
+ all = ["assertical[dev,fastapi,pandas,pydantic,postgres,xml]"]
62
+ dev = ["bandit", "flake8", "mypy", "black", "coverage"]
63
+ fastapi = ["fastapi", "asgi_lifespan"]
64
+ pandas = ["pandas", "pandas_stubs", "numpy"]
65
+ pydantic = ["pydantic"]
66
+ postgres = ["pytest-postgresql", "psycopg", "sqlalchemy>=2.0.0"]
67
+ xml = ["pydantic_xml[lxml]"]
68
+
69
+ [tool.setuptools.package-data]
70
+ "assertical" = ["py.typed"]