pytest-neon 0.6.0__tar.gz → 2.0.0__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.
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/CLAUDE.md +7 -2
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/PKG-INFO +67 -22
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/README.md +66 -21
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/pyproject.toml +1 -1
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/src/pytest_neon/__init__.py +1 -1
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/src/pytest_neon/plugin.py +134 -18
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_integration.py +35 -14
- pytest_neon-2.0.0/tests/test_readwrite_readonly_fixtures.py +258 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_reset_behavior.py +113 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/uv.lock +10 -1
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/.env.example +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/.github/workflows/release.yml +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/.github/workflows/tests.yml +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/.gitignore +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/.neon +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/LICENSE +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/src/pytest_neon/py.typed +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/conftest.py +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_branch_lifecycle.py +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_cli_options.py +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_env_var.py +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_fixture_errors.py +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_migrations.py +0 -0
- {pytest_neon-0.6.0 → pytest_neon-2.0.0}/tests/test_skip_behavior.py +0 -0
|
@@ -13,7 +13,10 @@ This is a pytest plugin that provides isolated Neon database branches for integr
|
|
|
13
13
|
- **Entry point**: `src/pytest_neon/plugin.py` - Contains all fixtures and pytest hooks
|
|
14
14
|
- **Migration fixture**: `_neon_migration_branch` - Session-scoped, parent for all test branches
|
|
15
15
|
- **User migration hook**: `neon_apply_migrations` - Session-scoped no-op, users override to run migrations
|
|
16
|
-
- **Core
|
|
16
|
+
- **Core fixtures**:
|
|
17
|
+
- `neon_branch_readwrite` - Function-scoped, resets after each test (recommended for write tests)
|
|
18
|
+
- `neon_branch_readonly` - Function-scoped, NO reset (recommended for read-only tests, faster)
|
|
19
|
+
- `neon_branch` - Deprecated alias for `neon_branch_readwrite`
|
|
17
20
|
- **Shared fixture**: `neon_branch_shared` - Module-scoped, no reset between tests
|
|
18
21
|
- **Convenience fixtures**: `neon_connection`, `neon_connection_psycopg`, `neon_engine` - Optional, require extras
|
|
19
22
|
|
|
@@ -28,7 +31,9 @@ This is a pytest plugin that provides isolated Neon database branches for integr
|
|
|
28
31
|
- `_neon_migration_branch`: `scope="session"` - internal, parent for all test branches, migrations run here
|
|
29
32
|
- `neon_apply_migrations`: `scope="session"` - user overrides to run migrations
|
|
30
33
|
- `_neon_branch_for_reset`: `scope="session"` - internal, creates one branch per session from migration branch
|
|
31
|
-
- `
|
|
34
|
+
- `neon_branch_readwrite`: `scope="function"` - resets branch after each test (for write tests)
|
|
35
|
+
- `neon_branch_readonly`: `scope="function"` - NO reset (for read-only tests, faster)
|
|
36
|
+
- `neon_branch`: `scope="function"` - deprecated alias for `neon_branch_readwrite`
|
|
32
37
|
- `neon_branch_shared`: `scope="module"` - one branch per test file, no reset
|
|
33
38
|
- Connection fixtures: `scope="function"` (default) - fresh connection per test
|
|
34
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-neon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Pytest plugin for Neon database branch isolation in tests
|
|
5
5
|
Project-URL: Homepage, https://github.com/ZainRizvi/pytest-neon
|
|
6
6
|
Project-URL: Repository, https://github.com/ZainRizvi/pytest-neon
|
|
@@ -97,7 +97,7 @@ export NEON_PROJECT_ID="your-project-id"
|
|
|
97
97
|
2. Write tests:
|
|
98
98
|
|
|
99
99
|
```python
|
|
100
|
-
def test_user_creation(
|
|
100
|
+
def test_user_creation(neon_branch_readwrite):
|
|
101
101
|
# DATABASE_URL is automatically set to the test branch
|
|
102
102
|
import psycopg # Your own install
|
|
103
103
|
|
|
@@ -105,6 +105,7 @@ def test_user_creation(neon_branch):
|
|
|
105
105
|
with conn.cursor() as cur:
|
|
106
106
|
cur.execute("INSERT INTO users (email) VALUES ('test@example.com')")
|
|
107
107
|
conn.commit()
|
|
108
|
+
# Branch automatically resets after test - next test sees clean state
|
|
108
109
|
```
|
|
109
110
|
|
|
110
111
|
3. Run tests:
|
|
@@ -115,33 +116,75 @@ pytest
|
|
|
115
116
|
|
|
116
117
|
## Fixtures
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
**Which fixture should I use?**
|
|
119
120
|
|
|
120
|
-
|
|
121
|
+
- **Use `neon_branch_readonly`** if your test only reads data (SELECT queries). This is the fastest option with no per-test overhead.
|
|
122
|
+
- **Use `neon_branch_readwrite`** if your test modifies data (INSERT, UPDATE, DELETE). This resets the branch after each test for isolation.
|
|
121
123
|
|
|
122
|
-
|
|
124
|
+
### `neon_branch_readonly` (recommended, fastest)
|
|
123
125
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
**Use this fixture by default** if your tests don't need to write data. It provides the best performance by skipping the branch reset step (~0.5s saved per test), which also reduces API calls and avoids rate limiting issues.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
def test_query_users(neon_branch_readonly):
|
|
130
|
+
# DATABASE_URL is set automatically
|
|
131
|
+
import psycopg
|
|
132
|
+
with psycopg.connect(neon_branch_readonly.connection_string) as conn:
|
|
133
|
+
result = conn.execute("SELECT * FROM users").fetchall()
|
|
134
|
+
assert len(result) >= 0
|
|
135
|
+
# No reset after this test - fast!
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
**Use this when**:
|
|
139
|
+
- Tests only perform SELECT queries
|
|
140
|
+
- Tests don't modify database state
|
|
141
|
+
- You want maximum performance
|
|
142
|
+
|
|
143
|
+
**Warning**: If you accidentally write data using this fixture, subsequent tests will see those modifications. The fixture does not enforce read-only access at the database level.
|
|
144
|
+
|
|
145
|
+
**Performance**: ~1.5s initial setup per session, **no per-test overhead**. For 10 read-only tests, expect only ~1.5s total overhead (vs ~6.5s with readwrite).
|
|
146
|
+
|
|
147
|
+
### `neon_branch_readwrite` (for write tests)
|
|
148
|
+
|
|
149
|
+
Use this fixture when your tests need to INSERT, UPDATE, or DELETE data. Creates one branch per test session, then resets it to the parent branch's state after each test. This provides test isolation with ~0.5s overhead per test.
|
|
129
150
|
|
|
130
151
|
```python
|
|
131
152
|
import os
|
|
132
153
|
|
|
133
|
-
def
|
|
154
|
+
def test_insert_user(neon_branch_readwrite):
|
|
134
155
|
# DATABASE_URL is set automatically
|
|
135
|
-
assert os.environ["DATABASE_URL"] ==
|
|
156
|
+
assert os.environ["DATABASE_URL"] == neon_branch_readwrite.connection_string
|
|
136
157
|
|
|
137
158
|
# Use with any driver
|
|
138
159
|
import psycopg
|
|
139
|
-
|
|
160
|
+
with psycopg.connect(neon_branch_readwrite.connection_string) as conn:
|
|
161
|
+
conn.execute("INSERT INTO users (name) VALUES ('test')")
|
|
162
|
+
conn.commit()
|
|
163
|
+
# Branch resets after this test - changes won't affect other tests
|
|
140
164
|
```
|
|
141
165
|
|
|
142
|
-
**Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 tests, expect ~6.5s total overhead.
|
|
166
|
+
**Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 write tests, expect ~6.5s total overhead.
|
|
143
167
|
|
|
144
|
-
### `
|
|
168
|
+
### `NeonBranch` dataclass
|
|
169
|
+
|
|
170
|
+
Both fixtures return a `NeonBranch` dataclass with:
|
|
171
|
+
|
|
172
|
+
- `branch_id`: The Neon branch ID
|
|
173
|
+
- `project_id`: The Neon project ID
|
|
174
|
+
- `connection_string`: Full PostgreSQL connection URI
|
|
175
|
+
- `host`: The database host
|
|
176
|
+
- `parent_id`: The parent branch ID (used for resets)
|
|
177
|
+
|
|
178
|
+
### `neon_branch` (deprecated)
|
|
179
|
+
|
|
180
|
+
> **Deprecated**: Use `neon_branch_readwrite` or `neon_branch_readonly` instead.
|
|
181
|
+
|
|
182
|
+
This fixture is an alias for `neon_branch_readwrite` and will emit a deprecation warning. Migrate to the explicit fixture names for clarity:
|
|
183
|
+
|
|
184
|
+
- `neon_branch_readwrite`: For tests that modify data (INSERT/UPDATE/DELETE)
|
|
185
|
+
- `neon_branch_readonly`: For tests that only read data (SELECT)
|
|
186
|
+
|
|
187
|
+
### `neon_branch_shared` (module-scoped, no isolation)
|
|
145
188
|
|
|
146
189
|
Creates one branch per test module and shares it across all tests without resetting. This is the fastest option but tests can see each other's data modifications.
|
|
147
190
|
|
|
@@ -207,19 +250,21 @@ def test_query(neon_engine):
|
|
|
207
250
|
|
|
208
251
|
### Using Your Own SQLAlchemy Engine
|
|
209
252
|
|
|
210
|
-
If you have a module-level SQLAlchemy engine (common pattern)
|
|
253
|
+
If you have a module-level SQLAlchemy engine (common pattern) and use `neon_branch_readwrite`, you **must** use `pool_pre_ping=True`:
|
|
211
254
|
|
|
212
255
|
```python
|
|
213
256
|
# database.py
|
|
214
257
|
from sqlalchemy import create_engine
|
|
215
258
|
from config import DATABASE_URL
|
|
216
259
|
|
|
217
|
-
# pool_pre_ping=True is REQUIRED
|
|
260
|
+
# pool_pre_ping=True is REQUIRED when using neon_branch_readwrite
|
|
218
261
|
# It verifies connections are alive before using them
|
|
219
262
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
220
263
|
```
|
|
221
264
|
|
|
222
|
-
**Why?** After each test,
|
|
265
|
+
**Why?** After each test, `neon_branch_readwrite` resets the branch which terminates server-side connections. Without `pool_pre_ping`, SQLAlchemy may try to reuse a dead pooled connection, causing `SSL connection has been closed unexpectedly` errors.
|
|
266
|
+
|
|
267
|
+
**Note**: If you only use `neon_branch_readonly`, `pool_pre_ping` is not required since no resets occur.
|
|
223
268
|
|
|
224
269
|
This is also a best practice for any cloud database (Neon, RDS, etc.) where connections can be terminated externally.
|
|
225
270
|
|
|
@@ -435,7 +480,7 @@ Branches use copy-on-write storage, so you only pay for data that differs from t
|
|
|
435
480
|
|
|
436
481
|
### What Reset Does
|
|
437
482
|
|
|
438
|
-
The `
|
|
483
|
+
The `neon_branch_readwrite` fixture uses Neon's branch restore API to reset database state after each test:
|
|
439
484
|
|
|
440
485
|
- **Data changes are reverted**: All INSERT, UPDATE, DELETE operations are undone
|
|
441
486
|
- **Schema changes are reverted**: CREATE TABLE, ALTER TABLE, DROP TABLE, etc. are undone
|
|
@@ -472,12 +517,12 @@ pip install pytest-neon[psycopg2]
|
|
|
472
517
|
pip install pytest-neon[sqlalchemy]
|
|
473
518
|
```
|
|
474
519
|
|
|
475
|
-
Or use the core
|
|
520
|
+
Or use the core fixtures with your own driver:
|
|
476
521
|
|
|
477
522
|
```python
|
|
478
|
-
def test_example(
|
|
523
|
+
def test_example(neon_branch_readwrite):
|
|
479
524
|
import my_preferred_driver
|
|
480
|
-
conn = my_preferred_driver.connect(
|
|
525
|
+
conn = my_preferred_driver.connect(neon_branch_readwrite.connection_string)
|
|
481
526
|
```
|
|
482
527
|
|
|
483
528
|
### "Neon API key not configured"
|
|
@@ -52,7 +52,7 @@ export NEON_PROJECT_ID="your-project-id"
|
|
|
52
52
|
2. Write tests:
|
|
53
53
|
|
|
54
54
|
```python
|
|
55
|
-
def test_user_creation(
|
|
55
|
+
def test_user_creation(neon_branch_readwrite):
|
|
56
56
|
# DATABASE_URL is automatically set to the test branch
|
|
57
57
|
import psycopg # Your own install
|
|
58
58
|
|
|
@@ -60,6 +60,7 @@ def test_user_creation(neon_branch):
|
|
|
60
60
|
with conn.cursor() as cur:
|
|
61
61
|
cur.execute("INSERT INTO users (email) VALUES ('test@example.com')")
|
|
62
62
|
conn.commit()
|
|
63
|
+
# Branch automatically resets after test - next test sees clean state
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
3. Run tests:
|
|
@@ -70,33 +71,75 @@ pytest
|
|
|
70
71
|
|
|
71
72
|
## Fixtures
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
**Which fixture should I use?**
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
- **Use `neon_branch_readonly`** if your test only reads data (SELECT queries). This is the fastest option with no per-test overhead.
|
|
77
|
+
- **Use `neon_branch_readwrite`** if your test modifies data (INSERT, UPDATE, DELETE). This resets the branch after each test for isolation.
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
### `neon_branch_readonly` (recommended, fastest)
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
**Use this fixture by default** if your tests don't need to write data. It provides the best performance by skipping the branch reset step (~0.5s saved per test), which also reduces API calls and avoids rate limiting issues.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
def test_query_users(neon_branch_readonly):
|
|
85
|
+
# DATABASE_URL is set automatically
|
|
86
|
+
import psycopg
|
|
87
|
+
with psycopg.connect(neon_branch_readonly.connection_string) as conn:
|
|
88
|
+
result = conn.execute("SELECT * FROM users").fetchall()
|
|
89
|
+
assert len(result) >= 0
|
|
90
|
+
# No reset after this test - fast!
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Use this when**:
|
|
94
|
+
- Tests only perform SELECT queries
|
|
95
|
+
- Tests don't modify database state
|
|
96
|
+
- You want maximum performance
|
|
97
|
+
|
|
98
|
+
**Warning**: If you accidentally write data using this fixture, subsequent tests will see those modifications. The fixture does not enforce read-only access at the database level.
|
|
99
|
+
|
|
100
|
+
**Performance**: ~1.5s initial setup per session, **no per-test overhead**. For 10 read-only tests, expect only ~1.5s total overhead (vs ~6.5s with readwrite).
|
|
101
|
+
|
|
102
|
+
### `neon_branch_readwrite` (for write tests)
|
|
103
|
+
|
|
104
|
+
Use this fixture when your tests need to INSERT, UPDATE, or DELETE data. Creates one branch per test session, then resets it to the parent branch's state after each test. This provides test isolation with ~0.5s overhead per test.
|
|
84
105
|
|
|
85
106
|
```python
|
|
86
107
|
import os
|
|
87
108
|
|
|
88
|
-
def
|
|
109
|
+
def test_insert_user(neon_branch_readwrite):
|
|
89
110
|
# DATABASE_URL is set automatically
|
|
90
|
-
assert os.environ["DATABASE_URL"] ==
|
|
111
|
+
assert os.environ["DATABASE_URL"] == neon_branch_readwrite.connection_string
|
|
91
112
|
|
|
92
113
|
# Use with any driver
|
|
93
114
|
import psycopg
|
|
94
|
-
|
|
115
|
+
with psycopg.connect(neon_branch_readwrite.connection_string) as conn:
|
|
116
|
+
conn.execute("INSERT INTO users (name) VALUES ('test')")
|
|
117
|
+
conn.commit()
|
|
118
|
+
# Branch resets after this test - changes won't affect other tests
|
|
95
119
|
```
|
|
96
120
|
|
|
97
|
-
**Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 tests, expect ~6.5s total overhead.
|
|
121
|
+
**Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 write tests, expect ~6.5s total overhead.
|
|
98
122
|
|
|
99
|
-
### `
|
|
123
|
+
### `NeonBranch` dataclass
|
|
124
|
+
|
|
125
|
+
Both fixtures return a `NeonBranch` dataclass with:
|
|
126
|
+
|
|
127
|
+
- `branch_id`: The Neon branch ID
|
|
128
|
+
- `project_id`: The Neon project ID
|
|
129
|
+
- `connection_string`: Full PostgreSQL connection URI
|
|
130
|
+
- `host`: The database host
|
|
131
|
+
- `parent_id`: The parent branch ID (used for resets)
|
|
132
|
+
|
|
133
|
+
### `neon_branch` (deprecated)
|
|
134
|
+
|
|
135
|
+
> **Deprecated**: Use `neon_branch_readwrite` or `neon_branch_readonly` instead.
|
|
136
|
+
|
|
137
|
+
This fixture is an alias for `neon_branch_readwrite` and will emit a deprecation warning. Migrate to the explicit fixture names for clarity:
|
|
138
|
+
|
|
139
|
+
- `neon_branch_readwrite`: For tests that modify data (INSERT/UPDATE/DELETE)
|
|
140
|
+
- `neon_branch_readonly`: For tests that only read data (SELECT)
|
|
141
|
+
|
|
142
|
+
### `neon_branch_shared` (module-scoped, no isolation)
|
|
100
143
|
|
|
101
144
|
Creates one branch per test module and shares it across all tests without resetting. This is the fastest option but tests can see each other's data modifications.
|
|
102
145
|
|
|
@@ -162,19 +205,21 @@ def test_query(neon_engine):
|
|
|
162
205
|
|
|
163
206
|
### Using Your Own SQLAlchemy Engine
|
|
164
207
|
|
|
165
|
-
If you have a module-level SQLAlchemy engine (common pattern)
|
|
208
|
+
If you have a module-level SQLAlchemy engine (common pattern) and use `neon_branch_readwrite`, you **must** use `pool_pre_ping=True`:
|
|
166
209
|
|
|
167
210
|
```python
|
|
168
211
|
# database.py
|
|
169
212
|
from sqlalchemy import create_engine
|
|
170
213
|
from config import DATABASE_URL
|
|
171
214
|
|
|
172
|
-
# pool_pre_ping=True is REQUIRED
|
|
215
|
+
# pool_pre_ping=True is REQUIRED when using neon_branch_readwrite
|
|
173
216
|
# It verifies connections are alive before using them
|
|
174
217
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
175
218
|
```
|
|
176
219
|
|
|
177
|
-
**Why?** After each test,
|
|
220
|
+
**Why?** After each test, `neon_branch_readwrite` resets the branch which terminates server-side connections. Without `pool_pre_ping`, SQLAlchemy may try to reuse a dead pooled connection, causing `SSL connection has been closed unexpectedly` errors.
|
|
221
|
+
|
|
222
|
+
**Note**: If you only use `neon_branch_readonly`, `pool_pre_ping` is not required since no resets occur.
|
|
178
223
|
|
|
179
224
|
This is also a best practice for any cloud database (Neon, RDS, etc.) where connections can be terminated externally.
|
|
180
225
|
|
|
@@ -390,7 +435,7 @@ Branches use copy-on-write storage, so you only pay for data that differs from t
|
|
|
390
435
|
|
|
391
436
|
### What Reset Does
|
|
392
437
|
|
|
393
|
-
The `
|
|
438
|
+
The `neon_branch_readwrite` fixture uses Neon's branch restore API to reset database state after each test:
|
|
394
439
|
|
|
395
440
|
- **Data changes are reverted**: All INSERT, UPDATE, DELETE operations are undone
|
|
396
441
|
- **Schema changes are reverted**: CREATE TABLE, ALTER TABLE, DROP TABLE, etc. are undone
|
|
@@ -427,12 +472,12 @@ pip install pytest-neon[psycopg2]
|
|
|
427
472
|
pip install pytest-neon[sqlalchemy]
|
|
428
473
|
```
|
|
429
474
|
|
|
430
|
-
Or use the core
|
|
475
|
+
Or use the core fixtures with your own driver:
|
|
431
476
|
|
|
432
477
|
```python
|
|
433
|
-
def test_example(
|
|
478
|
+
def test_example(neon_branch_readwrite):
|
|
434
479
|
import my_preferred_driver
|
|
435
|
-
conn = my_preferred_driver.connect(
|
|
480
|
+
conn = my_preferred_driver.connect(neon_branch_readwrite.connection_string)
|
|
436
481
|
```
|
|
437
482
|
|
|
438
483
|
### "Neon API key not configured"
|
|
@@ -5,15 +5,17 @@ instant branching feature. Each test gets a clean database state via
|
|
|
5
5
|
branch reset after each test.
|
|
6
6
|
|
|
7
7
|
Main fixtures:
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
neon_branch_readwrite: Read-write access with reset after each test (recommended)
|
|
9
|
+
neon_branch_readonly: Read-only access, no reset (fastest for read-only tests)
|
|
10
|
+
neon_branch: Deprecated alias for neon_branch_readwrite
|
|
11
|
+
neon_branch_shared: Shared branch without reset (module-scoped)
|
|
10
12
|
neon_connection: psycopg2 connection (requires psycopg2 extra)
|
|
11
13
|
neon_connection_psycopg: psycopg v3 connection (requires psycopg extra)
|
|
12
14
|
neon_engine: SQLAlchemy engine (requires sqlalchemy extra)
|
|
13
15
|
|
|
14
16
|
SQLAlchemy Users:
|
|
15
17
|
If you create your own SQLAlchemy engine (not using neon_engine fixture),
|
|
16
|
-
you MUST use pool_pre_ping=True:
|
|
18
|
+
you MUST use pool_pre_ping=True when using neon_branch_readwrite:
|
|
17
19
|
|
|
18
20
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
19
21
|
|
|
@@ -21,6 +23,9 @@ SQLAlchemy Users:
|
|
|
21
23
|
Without pool_pre_ping, SQLAlchemy may try to reuse dead pooled connections,
|
|
22
24
|
causing "SSL connection has been closed unexpectedly" errors.
|
|
23
25
|
|
|
26
|
+
Note: pool_pre_ping is not required for neon_branch_readonly since no
|
|
27
|
+
resets occur.
|
|
28
|
+
|
|
24
29
|
Configuration:
|
|
25
30
|
Set NEON_API_KEY and NEON_PROJECT_ID environment variables, or use
|
|
26
31
|
--neon-api-key and --neon-project-id CLI options.
|
|
@@ -33,6 +38,7 @@ from __future__ import annotations
|
|
|
33
38
|
import contextlib
|
|
34
39
|
import os
|
|
35
40
|
import time
|
|
41
|
+
import warnings
|
|
36
42
|
from collections.abc import Generator
|
|
37
43
|
from dataclasses import dataclass
|
|
38
44
|
from datetime import datetime, timedelta, timezone
|
|
@@ -373,8 +379,19 @@ def _create_neon_branch(
|
|
|
373
379
|
)
|
|
374
380
|
|
|
375
381
|
|
|
376
|
-
def _reset_branch_to_parent(
|
|
377
|
-
|
|
382
|
+
def _reset_branch_to_parent(
|
|
383
|
+
branch: NeonBranch, api_key: str, max_retries: int = 3
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Reset a branch to its parent's state using the Neon API.
|
|
386
|
+
|
|
387
|
+
Uses exponential backoff retry logic to handle transient API errors
|
|
388
|
+
that can occur during parallel test execution.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
branch: The branch to reset
|
|
392
|
+
api_key: Neon API key
|
|
393
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
394
|
+
"""
|
|
378
395
|
if not branch.parent_id:
|
|
379
396
|
raise RuntimeError(f"Branch {branch.branch_id} has no parent - cannot reset")
|
|
380
397
|
|
|
@@ -383,10 +400,27 @@ def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
|
|
|
383
400
|
"Authorization": f"Bearer {api_key}",
|
|
384
401
|
"Content-Type": "application/json",
|
|
385
402
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
)
|
|
389
|
-
|
|
403
|
+
|
|
404
|
+
last_error: Exception | None = None
|
|
405
|
+
for attempt in range(max_retries + 1):
|
|
406
|
+
try:
|
|
407
|
+
response = requests.post(
|
|
408
|
+
url,
|
|
409
|
+
headers=headers,
|
|
410
|
+
json={"source_branch_id": branch.parent_id},
|
|
411
|
+
timeout=30,
|
|
412
|
+
)
|
|
413
|
+
response.raise_for_status()
|
|
414
|
+
return # Success
|
|
415
|
+
except requests.RequestException as e:
|
|
416
|
+
last_error = e
|
|
417
|
+
if attempt < max_retries:
|
|
418
|
+
# Exponential backoff: 1s, 2s, 4s
|
|
419
|
+
wait_time = 2**attempt
|
|
420
|
+
time.sleep(wait_time)
|
|
421
|
+
|
|
422
|
+
# All retries exhausted
|
|
423
|
+
raise last_error # type: ignore[misc]
|
|
390
424
|
|
|
391
425
|
|
|
392
426
|
@pytest.fixture(scope="session")
|
|
@@ -538,16 +572,21 @@ def _neon_branch_for_reset(
|
|
|
538
572
|
|
|
539
573
|
|
|
540
574
|
@pytest.fixture(scope="function")
|
|
541
|
-
def
|
|
575
|
+
def neon_branch_readwrite(
|
|
542
576
|
request: pytest.FixtureRequest,
|
|
543
577
|
_neon_branch_for_reset: NeonBranch,
|
|
544
578
|
) -> Generator[NeonBranch, None, None]:
|
|
545
579
|
"""
|
|
546
|
-
Provide
|
|
580
|
+
Provide a read-write Neon database branch with reset after each test.
|
|
581
|
+
|
|
582
|
+
This is the recommended fixture for tests that modify database state.
|
|
583
|
+
It creates one branch per test session, then resets it to the parent
|
|
584
|
+
branch's state after each test. This provides test isolation with
|
|
585
|
+
~0.5s overhead per test.
|
|
547
586
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
587
|
+
Use this fixture when your tests INSERT, UPDATE, or DELETE data.
|
|
588
|
+
For read-only tests, use ``neon_branch_readonly`` instead for better
|
|
589
|
+
performance (no reset overhead).
|
|
551
590
|
|
|
552
591
|
The branch is automatically deleted after all tests complete, unless
|
|
553
592
|
--neon-keep-branches is specified. Branches also auto-expire after
|
|
@@ -575,11 +614,16 @@ def neon_branch(
|
|
|
575
614
|
|
|
576
615
|
Example::
|
|
577
616
|
|
|
578
|
-
def
|
|
617
|
+
def test_insert_user(neon_branch_readwrite):
|
|
579
618
|
# DATABASE_URL is automatically set
|
|
580
619
|
conn_string = os.environ["DATABASE_URL"]
|
|
581
620
|
# or use directly
|
|
582
|
-
conn_string =
|
|
621
|
+
conn_string = neon_branch_readwrite.connection_string
|
|
622
|
+
|
|
623
|
+
# Insert data - branch will reset after this test
|
|
624
|
+
with psycopg.connect(conn_string) as conn:
|
|
625
|
+
conn.execute("INSERT INTO users (name) VALUES ('test')")
|
|
626
|
+
conn.commit()
|
|
583
627
|
"""
|
|
584
628
|
config = request.config
|
|
585
629
|
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY", "neon_api_key")
|
|
@@ -588,8 +632,9 @@ def neon_branch(
|
|
|
588
632
|
if not _neon_branch_for_reset.parent_id:
|
|
589
633
|
pytest.fail(
|
|
590
634
|
f"\n\nBranch {_neon_branch_for_reset.branch_id} has no parent. "
|
|
591
|
-
f"The
|
|
592
|
-
f"
|
|
635
|
+
f"The neon_branch_readwrite fixture requires a parent branch for "
|
|
636
|
+
f"reset.\n\n"
|
|
637
|
+
f"Use neon_branch_readonly if you don't need reset, or specify "
|
|
593
638
|
f"a parent branch with --neon-parent-branch or NEON_PARENT_BRANCH_ID."
|
|
594
639
|
)
|
|
595
640
|
|
|
@@ -608,6 +653,77 @@ def neon_branch(
|
|
|
608
653
|
)
|
|
609
654
|
|
|
610
655
|
|
|
656
|
+
@pytest.fixture(scope="function")
|
|
657
|
+
def neon_branch_readonly(
|
|
658
|
+
_neon_branch_for_reset: NeonBranch,
|
|
659
|
+
) -> NeonBranch:
|
|
660
|
+
"""
|
|
661
|
+
Provide a read-only Neon database branch without reset.
|
|
662
|
+
|
|
663
|
+
This is the recommended fixture for tests that only read data (SELECT queries).
|
|
664
|
+
No branch reset occurs after each test, making it faster than
|
|
665
|
+
``neon_branch_readwrite`` (~0.5s saved per test).
|
|
666
|
+
|
|
667
|
+
Use this fixture when your tests only perform SELECT queries and don't
|
|
668
|
+
modify database state. For tests that INSERT, UPDATE, or DELETE data,
|
|
669
|
+
use ``neon_branch_readwrite`` instead to ensure test isolation.
|
|
670
|
+
|
|
671
|
+
Warning:
|
|
672
|
+
If you accidentally write data using this fixture, subsequent tests
|
|
673
|
+
will see those modifications. The fixture does not enforce read-only
|
|
674
|
+
access at the database level - it simply skips the reset step.
|
|
675
|
+
|
|
676
|
+
The connection string is automatically set in the DATABASE_URL environment
|
|
677
|
+
variable (configurable via --neon-env-var).
|
|
678
|
+
|
|
679
|
+
Requires either:
|
|
680
|
+
- NEON_API_KEY and NEON_PROJECT_ID environment variables, or
|
|
681
|
+
- --neon-api-key and --neon-project-id command line options
|
|
682
|
+
|
|
683
|
+
Yields:
|
|
684
|
+
NeonBranch: Object with branch_id, project_id, connection_string, and host.
|
|
685
|
+
|
|
686
|
+
Example::
|
|
687
|
+
|
|
688
|
+
def test_query_users(neon_branch_readonly):
|
|
689
|
+
# DATABASE_URL is automatically set
|
|
690
|
+
conn_string = os.environ["DATABASE_URL"]
|
|
691
|
+
|
|
692
|
+
# Read-only query - no reset needed after this test
|
|
693
|
+
with psycopg.connect(conn_string) as conn:
|
|
694
|
+
result = conn.execute("SELECT * FROM users").fetchall()
|
|
695
|
+
assert len(result) > 0
|
|
696
|
+
"""
|
|
697
|
+
return _neon_branch_for_reset
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@pytest.fixture(scope="function")
|
|
701
|
+
def neon_branch(
|
|
702
|
+
request: pytest.FixtureRequest,
|
|
703
|
+
neon_branch_readwrite: NeonBranch,
|
|
704
|
+
) -> Generator[NeonBranch, None, None]:
|
|
705
|
+
"""
|
|
706
|
+
Deprecated: Use ``neon_branch_readwrite`` or ``neon_branch_readonly`` instead.
|
|
707
|
+
|
|
708
|
+
This fixture is an alias for ``neon_branch_readwrite`` and will be removed
|
|
709
|
+
in a future version. Please migrate to the explicit fixture names:
|
|
710
|
+
|
|
711
|
+
- ``neon_branch_readwrite``: For tests that modify data (INSERT/UPDATE/DELETE)
|
|
712
|
+
- ``neon_branch_readonly``: For tests that only read data (SELECT)
|
|
713
|
+
|
|
714
|
+
.. deprecated:: 1.1.0
|
|
715
|
+
Use ``neon_branch_readwrite`` for read-write access with reset,
|
|
716
|
+
or ``neon_branch_readonly`` for read-only access without reset.
|
|
717
|
+
"""
|
|
718
|
+
warnings.warn(
|
|
719
|
+
"neon_branch is deprecated. Use neon_branch_readwrite (for tests that "
|
|
720
|
+
"modify data) or neon_branch_readonly (for read-only tests) instead.",
|
|
721
|
+
DeprecationWarning,
|
|
722
|
+
stacklevel=2,
|
|
723
|
+
)
|
|
724
|
+
yield neon_branch_readwrite
|
|
725
|
+
|
|
726
|
+
|
|
611
727
|
@pytest.fixture(scope="module")
|
|
612
728
|
def neon_branch_shared(
|
|
613
729
|
request: pytest.FixtureRequest,
|
|
@@ -69,16 +69,22 @@ pytestmark = pytest.mark.skipif(
|
|
|
69
69
|
class TestRealBranchCreation:
|
|
70
70
|
"""Test actual branch creation against Neon API."""
|
|
71
71
|
|
|
72
|
-
def test_branch_is_created_and_accessible(self,
|
|
72
|
+
def test_branch_is_created_and_accessible(self, neon_branch_readwrite):
|
|
73
73
|
"""Test that a real branch is created and has valid connection info."""
|
|
74
|
-
assert
|
|
75
|
-
assert
|
|
76
|
-
assert "neon.tech" in
|
|
77
|
-
assert
|
|
74
|
+
assert neon_branch_readwrite.branch_id.startswith("br-")
|
|
75
|
+
assert neon_branch_readwrite.project_id == PROJECT_ID
|
|
76
|
+
assert "neon.tech" in neon_branch_readwrite.host
|
|
77
|
+
assert neon_branch_readwrite.connection_string.startswith("postgresql://")
|
|
78
78
|
|
|
79
|
-
def test_database_url_is_set(self,
|
|
79
|
+
def test_database_url_is_set(self, neon_branch_readwrite):
|
|
80
80
|
"""Test that DATABASE_URL environment variable is set."""
|
|
81
|
-
assert os.environ.get("DATABASE_URL") ==
|
|
81
|
+
assert os.environ.get("DATABASE_URL") == neon_branch_readwrite.connection_string
|
|
82
|
+
|
|
83
|
+
def test_readonly_fixture_works(self, neon_branch_readonly):
|
|
84
|
+
"""Test that readonly fixture provides valid connection info."""
|
|
85
|
+
assert neon_branch_readonly.branch_id.startswith("br-")
|
|
86
|
+
assert neon_branch_readonly.project_id == PROJECT_ID
|
|
87
|
+
assert neon_branch_readonly.connection_string.startswith("postgresql://")
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
class TestMigrations:
|
|
@@ -159,7 +165,7 @@ def test_second_insert_after_reset(neon_branch):
|
|
|
159
165
|
class TestRealDatabaseConnectivity:
|
|
160
166
|
"""Test actual database connectivity."""
|
|
161
167
|
|
|
162
|
-
def test_can_connect_and_query(self,
|
|
168
|
+
def test_can_connect_and_query(self, neon_branch_readwrite):
|
|
163
169
|
"""Test that we can actually connect to the created branch."""
|
|
164
170
|
try:
|
|
165
171
|
import psycopg
|
|
@@ -167,14 +173,14 @@ class TestRealDatabaseConnectivity:
|
|
|
167
173
|
pytest.skip("psycopg not installed - run: pip install pytest-neon[psycopg]")
|
|
168
174
|
|
|
169
175
|
with (
|
|
170
|
-
psycopg.connect(
|
|
176
|
+
psycopg.connect(neon_branch_readwrite.connection_string) as conn,
|
|
171
177
|
conn.cursor() as cur,
|
|
172
178
|
):
|
|
173
179
|
cur.execute("SELECT 1 AS result")
|
|
174
180
|
result = cur.fetchone()
|
|
175
181
|
assert result[0] == 1
|
|
176
182
|
|
|
177
|
-
def test_can_create_and_query_table(self,
|
|
183
|
+
def test_can_create_and_query_table(self, neon_branch_readwrite):
|
|
178
184
|
"""Test that we can create tables and insert data."""
|
|
179
185
|
try:
|
|
180
186
|
import psycopg
|
|
@@ -182,7 +188,7 @@ class TestRealDatabaseConnectivity:
|
|
|
182
188
|
pytest.skip("psycopg not installed - run: pip install pytest-neon[psycopg]")
|
|
183
189
|
|
|
184
190
|
with (
|
|
185
|
-
psycopg.connect(
|
|
191
|
+
psycopg.connect(neon_branch_readwrite.connection_string) as conn,
|
|
186
192
|
conn.cursor() as cur,
|
|
187
193
|
):
|
|
188
194
|
# Create a test table
|
|
@@ -207,6 +213,21 @@ class TestRealDatabaseConnectivity:
|
|
|
207
213
|
result = cur.fetchone()
|
|
208
214
|
assert result[0] == "test_value"
|
|
209
215
|
|
|
216
|
+
def test_readonly_can_query(self, neon_branch_readonly):
|
|
217
|
+
"""Test that readonly fixture can execute queries."""
|
|
218
|
+
try:
|
|
219
|
+
import psycopg
|
|
220
|
+
except ImportError:
|
|
221
|
+
pytest.skip("psycopg not installed - run: pip install pytest-neon[psycopg]")
|
|
222
|
+
|
|
223
|
+
with (
|
|
224
|
+
psycopg.connect(neon_branch_readonly.connection_string) as conn,
|
|
225
|
+
conn.cursor() as cur,
|
|
226
|
+
):
|
|
227
|
+
cur.execute("SELECT 1 AS result")
|
|
228
|
+
result = cur.fetchone()
|
|
229
|
+
assert result[0] == 1
|
|
230
|
+
|
|
210
231
|
|
|
211
232
|
class TestSQLAlchemyPooledConnections:
|
|
212
233
|
"""Test SQLAlchemy connection pooling behavior with branch resets.
|
|
@@ -245,7 +266,7 @@ engine = create_engine(DATABASE_URL, pool_pre_ping=True) if DATABASE_URL else No
|
|
|
245
266
|
test_sqlalchemy_reset="""
|
|
246
267
|
from sqlalchemy import text
|
|
247
268
|
|
|
248
|
-
def test_first_query(
|
|
269
|
+
def test_first_query(neon_branch_readwrite):
|
|
249
270
|
'''First test - creates pooled connection.'''
|
|
250
271
|
from database import engine
|
|
251
272
|
with engine.connect() as conn:
|
|
@@ -253,14 +274,14 @@ def test_first_query(neon_branch):
|
|
|
253
274
|
assert result.scalar() == 1
|
|
254
275
|
# Connection returned to pool
|
|
255
276
|
|
|
256
|
-
def test_second_query_after_reset(
|
|
277
|
+
def test_second_query_after_reset(neon_branch_readwrite):
|
|
257
278
|
'''Second test - branch was reset, but pool_pre_ping handles it.'''
|
|
258
279
|
from database import engine
|
|
259
280
|
with engine.connect() as conn:
|
|
260
281
|
result = conn.execute(text("SELECT 2"))
|
|
261
282
|
assert result.scalar() == 2
|
|
262
283
|
|
|
263
|
-
def test_third_query_after_another_reset(
|
|
284
|
+
def test_third_query_after_another_reset(neon_branch_readwrite):
|
|
264
285
|
'''Third test - still works thanks to pool_pre_ping.'''
|
|
265
286
|
from database import engine
|
|
266
287
|
with engine.connect() as conn:
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Tests for neon_branch_readwrite and neon_branch_readonly fixtures."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TestReadwriteFixture:
|
|
5
|
+
"""Test neon_branch_readwrite fixture behavior."""
|
|
6
|
+
|
|
7
|
+
def test_readwrite_resets_after_each_test(self, pytester):
|
|
8
|
+
"""Verify that neon_branch_readwrite resets after each test."""
|
|
9
|
+
pytester.makeconftest(
|
|
10
|
+
"""
|
|
11
|
+
import os
|
|
12
|
+
import pytest
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
reset_count = [0]
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FakeNeonBranch:
|
|
19
|
+
branch_id: str
|
|
20
|
+
project_id: str
|
|
21
|
+
connection_string: str
|
|
22
|
+
host: str
|
|
23
|
+
parent_id: str
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(scope="session")
|
|
26
|
+
def _neon_branch_for_reset():
|
|
27
|
+
branch = FakeNeonBranch(
|
|
28
|
+
branch_id="br-test",
|
|
29
|
+
project_id="proj-test",
|
|
30
|
+
connection_string="postgresql://test",
|
|
31
|
+
host="test.neon.tech",
|
|
32
|
+
parent_id="br-parent",
|
|
33
|
+
)
|
|
34
|
+
os.environ["DATABASE_URL"] = branch.connection_string
|
|
35
|
+
try:
|
|
36
|
+
yield branch
|
|
37
|
+
finally:
|
|
38
|
+
os.environ.pop("DATABASE_URL", None)
|
|
39
|
+
|
|
40
|
+
@pytest.fixture(scope="function")
|
|
41
|
+
def neon_branch_readwrite(_neon_branch_for_reset):
|
|
42
|
+
yield _neon_branch_for_reset
|
|
43
|
+
# Simulate reset
|
|
44
|
+
reset_count[0] += 1
|
|
45
|
+
|
|
46
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
47
|
+
# Verify resets happened
|
|
48
|
+
assert reset_count[0] == 2, f"Expected 2 resets, got {reset_count[0]}"
|
|
49
|
+
"""
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
pytester.makepyfile(
|
|
53
|
+
"""
|
|
54
|
+
def test_first(neon_branch_readwrite):
|
|
55
|
+
assert neon_branch_readwrite.branch_id == "br-test"
|
|
56
|
+
|
|
57
|
+
def test_second(neon_branch_readwrite):
|
|
58
|
+
assert neon_branch_readwrite.branch_id == "br-test"
|
|
59
|
+
"""
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
result = pytester.runpytest("-v")
|
|
63
|
+
result.assert_outcomes(passed=2)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestReadonlyFixture:
|
|
67
|
+
"""Test neon_branch_readonly fixture behavior."""
|
|
68
|
+
|
|
69
|
+
def test_readonly_does_not_reset(self, pytester):
|
|
70
|
+
"""Verify that neon_branch_readonly does NOT reset after tests."""
|
|
71
|
+
pytester.makeconftest(
|
|
72
|
+
"""
|
|
73
|
+
import os
|
|
74
|
+
import pytest
|
|
75
|
+
from dataclasses import dataclass
|
|
76
|
+
|
|
77
|
+
reset_count = [0]
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class FakeNeonBranch:
|
|
81
|
+
branch_id: str
|
|
82
|
+
project_id: str
|
|
83
|
+
connection_string: str
|
|
84
|
+
host: str
|
|
85
|
+
parent_id: str
|
|
86
|
+
|
|
87
|
+
@pytest.fixture(scope="session")
|
|
88
|
+
def _neon_branch_for_reset():
|
|
89
|
+
branch = FakeNeonBranch(
|
|
90
|
+
branch_id="br-test",
|
|
91
|
+
project_id="proj-test",
|
|
92
|
+
connection_string="postgresql://test",
|
|
93
|
+
host="test.neon.tech",
|
|
94
|
+
parent_id="br-parent",
|
|
95
|
+
)
|
|
96
|
+
os.environ["DATABASE_URL"] = branch.connection_string
|
|
97
|
+
try:
|
|
98
|
+
yield branch
|
|
99
|
+
finally:
|
|
100
|
+
os.environ.pop("DATABASE_URL", None)
|
|
101
|
+
|
|
102
|
+
@pytest.fixture(scope="function")
|
|
103
|
+
def neon_branch_readonly(_neon_branch_for_reset):
|
|
104
|
+
# No reset - just return the branch
|
|
105
|
+
return _neon_branch_for_reset
|
|
106
|
+
|
|
107
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
108
|
+
# Verify NO resets happened
|
|
109
|
+
assert reset_count[0] == 0, f"Expected 0 resets, got {reset_count[0]}"
|
|
110
|
+
"""
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
pytester.makepyfile(
|
|
114
|
+
"""
|
|
115
|
+
def test_first(neon_branch_readonly):
|
|
116
|
+
assert neon_branch_readonly.branch_id == "br-test"
|
|
117
|
+
|
|
118
|
+
def test_second(neon_branch_readonly):
|
|
119
|
+
assert neon_branch_readonly.branch_id == "br-test"
|
|
120
|
+
"""
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
result = pytester.runpytest("-v")
|
|
124
|
+
result.assert_outcomes(passed=2)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestDeprecatedNeonBranch:
|
|
128
|
+
"""Test that neon_branch emits deprecation warning."""
|
|
129
|
+
|
|
130
|
+
def test_neon_branch_emits_deprecation_warning(self, pytester):
|
|
131
|
+
"""Verify that using neon_branch emits a deprecation warning."""
|
|
132
|
+
pytester.makeconftest(
|
|
133
|
+
"""
|
|
134
|
+
import os
|
|
135
|
+
import pytest
|
|
136
|
+
from dataclasses import dataclass
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class FakeNeonBranch:
|
|
140
|
+
branch_id: str
|
|
141
|
+
project_id: str
|
|
142
|
+
connection_string: str
|
|
143
|
+
host: str
|
|
144
|
+
parent_id: str
|
|
145
|
+
|
|
146
|
+
@pytest.fixture(scope="session")
|
|
147
|
+
def _neon_branch_for_reset():
|
|
148
|
+
branch = FakeNeonBranch(
|
|
149
|
+
branch_id="br-test",
|
|
150
|
+
project_id="proj-test",
|
|
151
|
+
connection_string="postgresql://test",
|
|
152
|
+
host="test.neon.tech",
|
|
153
|
+
parent_id="br-parent",
|
|
154
|
+
)
|
|
155
|
+
os.environ["DATABASE_URL"] = branch.connection_string
|
|
156
|
+
try:
|
|
157
|
+
yield branch
|
|
158
|
+
finally:
|
|
159
|
+
os.environ.pop("DATABASE_URL", None)
|
|
160
|
+
|
|
161
|
+
@pytest.fixture(scope="function")
|
|
162
|
+
def neon_branch_readwrite(_neon_branch_for_reset):
|
|
163
|
+
yield _neon_branch_for_reset
|
|
164
|
+
|
|
165
|
+
@pytest.fixture(scope="function")
|
|
166
|
+
def neon_branch(neon_branch_readwrite):
|
|
167
|
+
import warnings
|
|
168
|
+
warnings.warn(
|
|
169
|
+
"neon_branch is deprecated. Use neon_branch_readwrite or "
|
|
170
|
+
"neon_branch_readonly instead.",
|
|
171
|
+
DeprecationWarning,
|
|
172
|
+
stacklevel=2,
|
|
173
|
+
)
|
|
174
|
+
yield neon_branch_readwrite
|
|
175
|
+
"""
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
pytester.makepyfile(
|
|
179
|
+
"""
|
|
180
|
+
def test_deprecated(neon_branch):
|
|
181
|
+
assert neon_branch.branch_id == "br-test"
|
|
182
|
+
"""
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
result = pytester.runpytest("-v", "-W", "error::DeprecationWarning")
|
|
186
|
+
# Should error during fixture setup (deprecation warning treated as error)
|
|
187
|
+
result.assert_outcomes(errors=1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TestFixtureUseTogether:
|
|
191
|
+
"""Test using both fixtures in the same test session."""
|
|
192
|
+
|
|
193
|
+
def test_readwrite_and_readonly_can_coexist(self, pytester):
|
|
194
|
+
"""Verify both fixtures can be used in the same test module."""
|
|
195
|
+
pytester.makeconftest(
|
|
196
|
+
"""
|
|
197
|
+
import os
|
|
198
|
+
import pytest
|
|
199
|
+
from dataclasses import dataclass
|
|
200
|
+
|
|
201
|
+
reset_count = [0]
|
|
202
|
+
|
|
203
|
+
@dataclass
|
|
204
|
+
class FakeNeonBranch:
|
|
205
|
+
branch_id: str
|
|
206
|
+
project_id: str
|
|
207
|
+
connection_string: str
|
|
208
|
+
host: str
|
|
209
|
+
parent_id: str
|
|
210
|
+
|
|
211
|
+
@pytest.fixture(scope="session")
|
|
212
|
+
def _neon_branch_for_reset():
|
|
213
|
+
branch = FakeNeonBranch(
|
|
214
|
+
branch_id="br-test",
|
|
215
|
+
project_id="proj-test",
|
|
216
|
+
connection_string="postgresql://test",
|
|
217
|
+
host="test.neon.tech",
|
|
218
|
+
parent_id="br-parent",
|
|
219
|
+
)
|
|
220
|
+
os.environ["DATABASE_URL"] = branch.connection_string
|
|
221
|
+
try:
|
|
222
|
+
yield branch
|
|
223
|
+
finally:
|
|
224
|
+
os.environ.pop("DATABASE_URL", None)
|
|
225
|
+
|
|
226
|
+
@pytest.fixture(scope="function")
|
|
227
|
+
def neon_branch_readwrite(_neon_branch_for_reset):
|
|
228
|
+
yield _neon_branch_for_reset
|
|
229
|
+
reset_count[0] += 1
|
|
230
|
+
|
|
231
|
+
@pytest.fixture(scope="function")
|
|
232
|
+
def neon_branch_readonly(_neon_branch_for_reset):
|
|
233
|
+
return _neon_branch_for_reset
|
|
234
|
+
|
|
235
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
236
|
+
# Only readwrite tests should trigger reset
|
|
237
|
+
assert reset_count[0] == 1, f"Expected 1 reset, got {reset_count[0]}"
|
|
238
|
+
"""
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
pytester.makepyfile(
|
|
242
|
+
"""
|
|
243
|
+
def test_readonly_first(neon_branch_readonly):
|
|
244
|
+
'''Read-only test - no reset after.'''
|
|
245
|
+
assert neon_branch_readonly.branch_id == "br-test"
|
|
246
|
+
|
|
247
|
+
def test_readonly_second(neon_branch_readonly):
|
|
248
|
+
'''Another read-only test - still no reset.'''
|
|
249
|
+
assert neon_branch_readonly.branch_id == "br-test"
|
|
250
|
+
|
|
251
|
+
def test_readwrite(neon_branch_readwrite):
|
|
252
|
+
'''Read-write test - reset after this one.'''
|
|
253
|
+
assert neon_branch_readwrite.branch_id == "br-test"
|
|
254
|
+
"""
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
result = pytester.runpytest("-v")
|
|
258
|
+
result.assert_outcomes(passed=3)
|
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
"""Tests for branch reset behavior."""
|
|
2
2
|
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from pytest_neon.plugin import NeonBranch, _reset_branch_to_parent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestResetRetryBehavior:
|
|
9
|
+
"""Test that branch reset retries on transient failures."""
|
|
10
|
+
|
|
11
|
+
def test_reset_succeeds_after_transient_failures(self, mocker):
|
|
12
|
+
"""Verify reset retries and succeeds after transient API errors."""
|
|
13
|
+
branch = NeonBranch(
|
|
14
|
+
branch_id="br-test",
|
|
15
|
+
project_id="proj-test",
|
|
16
|
+
connection_string="postgresql://test",
|
|
17
|
+
host="test.neon.tech",
|
|
18
|
+
parent_id="br-parent",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Mock requests.post to fail twice, then succeed
|
|
22
|
+
mock_response = mocker.Mock()
|
|
23
|
+
mock_response.raise_for_status = mocker.Mock()
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
call_count = [0]
|
|
28
|
+
|
|
29
|
+
def mock_post(*args, **kwargs):
|
|
30
|
+
call_count[0] += 1
|
|
31
|
+
if call_count[0] < 3:
|
|
32
|
+
raise requests.RequestException("API rate limited")
|
|
33
|
+
return mock_response
|
|
34
|
+
|
|
35
|
+
mocker.patch("pytest_neon.plugin.requests.post", side_effect=mock_post)
|
|
36
|
+
mocker.patch("pytest_neon.plugin.time.sleep") # Don't actually sleep
|
|
37
|
+
|
|
38
|
+
# Should succeed after retries
|
|
39
|
+
_reset_branch_to_parent(branch, "fake-api-key")
|
|
40
|
+
|
|
41
|
+
assert call_count[0] == 3 # 2 failures + 1 success
|
|
42
|
+
|
|
43
|
+
def test_reset_fails_after_max_retries(self, mocker):
|
|
44
|
+
"""Verify reset raises after exhausting all retries."""
|
|
45
|
+
branch = NeonBranch(
|
|
46
|
+
branch_id="br-test",
|
|
47
|
+
project_id="proj-test",
|
|
48
|
+
connection_string="postgresql://test",
|
|
49
|
+
host="test.neon.tech",
|
|
50
|
+
parent_id="br-parent",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Mock requests.post to always fail
|
|
54
|
+
import requests
|
|
55
|
+
|
|
56
|
+
mocker.patch(
|
|
57
|
+
"pytest_neon.plugin.requests.post",
|
|
58
|
+
side_effect=requests.RequestException("API error"),
|
|
59
|
+
)
|
|
60
|
+
mock_sleep = mocker.patch("pytest_neon.plugin.time.sleep")
|
|
61
|
+
|
|
62
|
+
# Should raise after max retries
|
|
63
|
+
with pytest.raises(requests.RequestException, match="API error"):
|
|
64
|
+
_reset_branch_to_parent(branch, "fake-api-key")
|
|
65
|
+
|
|
66
|
+
# Should have slept between retries (3 retries = 3 sleeps)
|
|
67
|
+
assert mock_sleep.call_count == 3
|
|
68
|
+
|
|
69
|
+
def test_reset_uses_exponential_backoff(self, mocker):
|
|
70
|
+
"""Verify reset uses exponential backoff between retries."""
|
|
71
|
+
branch = NeonBranch(
|
|
72
|
+
branch_id="br-test",
|
|
73
|
+
project_id="proj-test",
|
|
74
|
+
connection_string="postgresql://test",
|
|
75
|
+
host="test.neon.tech",
|
|
76
|
+
parent_id="br-parent",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
import requests
|
|
80
|
+
|
|
81
|
+
mocker.patch(
|
|
82
|
+
"pytest_neon.plugin.requests.post",
|
|
83
|
+
side_effect=requests.RequestException("API error"),
|
|
84
|
+
)
|
|
85
|
+
mock_sleep = mocker.patch("pytest_neon.plugin.time.sleep")
|
|
86
|
+
|
|
87
|
+
with pytest.raises(requests.RequestException):
|
|
88
|
+
_reset_branch_to_parent(branch, "fake-api-key")
|
|
89
|
+
|
|
90
|
+
# Check exponential backoff: 1s, 2s, 4s
|
|
91
|
+
sleep_calls = [call[0][0] for call in mock_sleep.call_args_list]
|
|
92
|
+
assert sleep_calls == [1, 2, 4]
|
|
93
|
+
|
|
94
|
+
def test_reset_no_retry_on_success(self, mocker):
|
|
95
|
+
"""Verify reset doesn't retry when successful."""
|
|
96
|
+
branch = NeonBranch(
|
|
97
|
+
branch_id="br-test",
|
|
98
|
+
project_id="proj-test",
|
|
99
|
+
connection_string="postgresql://test",
|
|
100
|
+
host="test.neon.tech",
|
|
101
|
+
parent_id="br-parent",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
mock_response = mocker.Mock()
|
|
105
|
+
mock_response.raise_for_status = mocker.Mock()
|
|
106
|
+
mock_post = mocker.patch(
|
|
107
|
+
"pytest_neon.plugin.requests.post", return_value=mock_response
|
|
108
|
+
)
|
|
109
|
+
mock_sleep = mocker.patch("pytest_neon.plugin.time.sleep")
|
|
110
|
+
|
|
111
|
+
_reset_branch_to_parent(branch, "fake-api-key")
|
|
112
|
+
|
|
113
|
+
assert mock_post.call_count == 1
|
|
114
|
+
assert mock_sleep.call_count == 0
|
|
115
|
+
|
|
3
116
|
|
|
4
117
|
class TestResetBehavior:
|
|
5
118
|
"""Test that branch reset happens between tests."""
|
|
@@ -1200,20 +1200,25 @@ wheels = [
|
|
|
1200
1200
|
|
|
1201
1201
|
[[package]]
|
|
1202
1202
|
name = "pytest-neon"
|
|
1203
|
-
version = "
|
|
1203
|
+
version = "1.0.0"
|
|
1204
1204
|
source = { editable = "." }
|
|
1205
1205
|
dependencies = [
|
|
1206
1206
|
{ name = "neon-api" },
|
|
1207
1207
|
{ name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
|
1208
1208
|
{ name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
|
1209
|
+
{ name = "requests" },
|
|
1209
1210
|
]
|
|
1210
1211
|
|
|
1211
1212
|
[package.optional-dependencies]
|
|
1212
1213
|
dev = [
|
|
1213
1214
|
{ name = "mypy" },
|
|
1215
|
+
{ name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, extra = ["binary"], marker = "python_full_version < '3.10'" },
|
|
1216
|
+
{ name = "psycopg", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, extra = ["binary"], marker = "python_full_version >= '3.10'" },
|
|
1217
|
+
{ name = "psycopg2-binary" },
|
|
1214
1218
|
{ name = "pytest-cov" },
|
|
1215
1219
|
{ name = "pytest-mock" },
|
|
1216
1220
|
{ name = "ruff" },
|
|
1221
|
+
{ name = "sqlalchemy" },
|
|
1217
1222
|
]
|
|
1218
1223
|
psycopg = [
|
|
1219
1224
|
{ name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, extra = ["binary"], marker = "python_full_version < '3.10'" },
|
|
@@ -1230,12 +1235,16 @@ sqlalchemy = [
|
|
|
1230
1235
|
requires-dist = [
|
|
1231
1236
|
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0" },
|
|
1232
1237
|
{ name = "neon-api", specifier = ">=0.1.0" },
|
|
1238
|
+
{ name = "psycopg", extras = ["binary"], marker = "extra == 'dev'", specifier = ">=3.1" },
|
|
1233
1239
|
{ name = "psycopg", extras = ["binary"], marker = "extra == 'psycopg'", specifier = ">=3.1" },
|
|
1240
|
+
{ name = "psycopg2-binary", marker = "extra == 'dev'", specifier = ">=2.9" },
|
|
1234
1241
|
{ name = "psycopg2-binary", marker = "extra == 'psycopg2'", specifier = ">=2.9" },
|
|
1235
1242
|
{ name = "pytest", specifier = ">=7.0" },
|
|
1236
1243
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" },
|
|
1237
1244
|
{ name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.0" },
|
|
1245
|
+
{ name = "requests", specifier = ">=2.20" },
|
|
1238
1246
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" },
|
|
1247
|
+
{ name = "sqlalchemy", marker = "extra == 'dev'", specifier = ">=2.0" },
|
|
1239
1248
|
{ name = "sqlalchemy", marker = "extra == 'sqlalchemy'", specifier = ">=2.0" },
|
|
1240
1249
|
]
|
|
1241
1250
|
provides-extras = ["psycopg2", "psycopg", "sqlalchemy", "dev"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|