pytest-neon 0.5.0__tar.gz → 0.6.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.5.0 → pytest_neon-0.6.0}/.github/workflows/release.yml +23 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.github/workflows/tests.yml +12 -2
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/CLAUDE.md +30 -2
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/PKG-INFO +70 -10
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/README.md +63 -6
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/pyproject.toml +7 -4
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/src/pytest_neon/__init__.py +1 -1
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/src/pytest_neon/plugin.py +173 -21
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_integration.py +65 -0
- pytest_neon-0.6.0/tests/test_migrations.py +156 -0
- pytest_neon-0.5.0/tests/test_migrations.py +0 -77
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.env.example +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.gitignore +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/.neon +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/LICENSE +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/src/pytest_neon/py.typed +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/conftest.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_branch_lifecycle.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_cli_options.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_env_var.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_fixture_errors.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_reset_behavior.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/tests/test_skip_behavior.py +0 -0
- {pytest_neon-0.5.0 → pytest_neon-0.6.0}/uv.lock +0 -0
|
@@ -23,6 +23,29 @@ jobs:
|
|
|
23
23
|
with:
|
|
24
24
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
25
25
|
|
|
26
|
+
- name: Check CI status
|
|
27
|
+
env:
|
|
28
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
29
|
+
REPO: ${{ github.repository }}
|
|
30
|
+
run: |
|
|
31
|
+
SHA=$(git rev-parse HEAD)
|
|
32
|
+
echo "Checking CI status for commit: $SHA"
|
|
33
|
+
|
|
34
|
+
# Get check runs excluding this release workflow
|
|
35
|
+
FAILED=$(gh api "repos/$REPO/commits/$SHA/check-runs" \
|
|
36
|
+
--jq '[.check_runs[] | select(.name != "release") | select(.conclusion != "success" and .conclusion != "skipped")] | length')
|
|
37
|
+
|
|
38
|
+
if [ "$FAILED" != "0" ]; then
|
|
39
|
+
echo "❌ CI checks have not passed on this commit"
|
|
40
|
+
echo ""
|
|
41
|
+
echo "Check runs:"
|
|
42
|
+
gh api "repos/$REPO/commits/$SHA/check-runs" \
|
|
43
|
+
--jq '.check_runs[] | select(.name != "release") | " - \(.name): \(.conclusion // .status)"'
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
echo "✅ All CI checks passed"
|
|
48
|
+
|
|
26
49
|
- name: Set up Python
|
|
27
50
|
uses: actions/setup-python@v5
|
|
28
51
|
with:
|
|
@@ -3,8 +3,18 @@ name: Tests
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- "src/**"
|
|
8
|
+
- "tests/**"
|
|
9
|
+
- "pyproject.toml"
|
|
10
|
+
- ".github/workflows/tests.yml"
|
|
6
11
|
pull_request:
|
|
7
12
|
branches: [main]
|
|
13
|
+
paths:
|
|
14
|
+
- "src/**"
|
|
15
|
+
- "tests/**"
|
|
16
|
+
- "pyproject.toml"
|
|
17
|
+
- ".github/workflows/tests.yml"
|
|
8
18
|
|
|
9
19
|
jobs:
|
|
10
20
|
test:
|
|
@@ -25,7 +35,7 @@ jobs:
|
|
|
25
35
|
- name: Install dependencies
|
|
26
36
|
run: |
|
|
27
37
|
python -m pip install --upgrade pip
|
|
28
|
-
pip install -e ".[dev
|
|
38
|
+
pip install -e ".[dev]"
|
|
29
39
|
|
|
30
40
|
- name: Run unit tests
|
|
31
41
|
run: pytest tests/ --ignore=tests/test_integration.py -v
|
|
@@ -46,7 +56,7 @@ jobs:
|
|
|
46
56
|
- name: Install dependencies
|
|
47
57
|
run: |
|
|
48
58
|
python -m pip install --upgrade pip
|
|
49
|
-
pip install -e ".[dev
|
|
59
|
+
pip install -e ".[dev]"
|
|
50
60
|
|
|
51
61
|
- name: Run integration tests
|
|
52
62
|
env:
|
|
@@ -13,7 +13,7 @@ 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 fixture**: `neon_branch` - Creates branch (
|
|
16
|
+
- **Core fixture**: `neon_branch` - Creates branch (session-scoped), resets after each test (function-scoped wrapper), sets `DATABASE_URL`, yields `NeonBranch` dataclass
|
|
17
17
|
- **Shared fixture**: `neon_branch_shared` - Module-scoped, no reset between tests
|
|
18
18
|
- **Convenience fixtures**: `neon_connection`, `neon_connection_psycopg`, `neon_engine` - Optional, require extras
|
|
19
19
|
|
|
@@ -27,7 +27,7 @@ This is a pytest plugin that provides isolated Neon database branches for integr
|
|
|
27
27
|
### Fixture Scopes
|
|
28
28
|
- `_neon_migration_branch`: `scope="session"` - internal, parent for all test branches, migrations run here
|
|
29
29
|
- `neon_apply_migrations`: `scope="session"` - user overrides to run migrations
|
|
30
|
-
- `_neon_branch_for_reset`: `scope="
|
|
30
|
+
- `_neon_branch_for_reset`: `scope="session"` - internal, creates one branch per session from migration branch
|
|
31
31
|
- `neon_branch`: `scope="function"` - wraps the above, resets branch after each test
|
|
32
32
|
- `neon_branch_shared`: `scope="module"` - one branch per test file, no reset
|
|
33
33
|
- Connection fixtures: `scope="function"` (default) - fresh connection per test
|
|
@@ -35,15 +35,43 @@ This is a pytest plugin that provides isolated Neon database branches for integr
|
|
|
35
35
|
### Environment Variable Handling
|
|
36
36
|
The `_create_neon_branch` function sets `DATABASE_URL` (or configured env var) during the fixture lifecycle and restores the original value in the finally block. This is critical for not polluting other tests.
|
|
37
37
|
|
|
38
|
+
### Smart Migration Detection (Cost Optimization)
|
|
39
|
+
The plugin avoids creating unnecessary branches through a two-layer detection strategy:
|
|
40
|
+
|
|
41
|
+
1. **Sentinel detection**: If `neon_apply_migrations` is not overridden, it returns `_MIGRATIONS_NOT_DEFINED` sentinel. No child branch is created.
|
|
42
|
+
|
|
43
|
+
2. **Schema fingerprint comparison**: If migrations are defined, the plugin captures `information_schema.columns` before migrations run and compares after. Only creates a child branch if the schema actually changed.
|
|
44
|
+
|
|
45
|
+
**Design philosophy**: Users who define a migration fixture but rarely have actual pending migrations shouldn't pay for an extra branch every test run. The schema fingerprint approach detects actual changes, not just "migration code ran."
|
|
46
|
+
|
|
47
|
+
**Implementation notes**:
|
|
48
|
+
- Pre-migration fingerprint is captured in `_neon_migration_branch` and stored on `request.config`
|
|
49
|
+
- Post-migration comparison happens in `_neon_branch_for_reset`
|
|
50
|
+
- Falls back to assuming changes if no psycopg/psycopg2 is available for fingerprinting
|
|
51
|
+
- Only checks schema (tables, columns), not data - this is intentional since seeding is not the use case
|
|
52
|
+
|
|
38
53
|
### Error Messages
|
|
39
54
|
Convenience fixtures use `pytest.fail()` with detailed, formatted error messages when dependencies are missing. Keep this pattern - users need clear guidance on how to fix import errors.
|
|
40
55
|
|
|
56
|
+
## Documentation
|
|
57
|
+
|
|
58
|
+
Important help text should be documented in BOTH:
|
|
59
|
+
1. **README.md** - Full user-facing documentation
|
|
60
|
+
2. **Module/fixture docstrings** - So `help(pytest_neon)` shows useful info
|
|
61
|
+
|
|
62
|
+
The module docstring in `plugin.py` should include key usage notes (like the SQLAlchemy `pool_pre_ping=True` requirement). Keep docstrings and README in sync.
|
|
63
|
+
|
|
41
64
|
## Commit Messages
|
|
42
65
|
- Do NOT add Claude attribution or Co-Authored-By lines
|
|
43
66
|
- Keep commits clean and descriptive
|
|
44
67
|
|
|
45
68
|
## Testing
|
|
46
69
|
|
|
70
|
+
Run tests with:
|
|
71
|
+
```bash
|
|
72
|
+
uv run pytest tests/ -v
|
|
73
|
+
```
|
|
74
|
+
|
|
47
75
|
Tests in `tests/` use `pytester` for testing pytest plugins. The plugin itself can be tested without a real Neon connection by mocking `NeonAPI`.
|
|
48
76
|
|
|
49
77
|
## Publishing
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-neon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Pytest plugin for Neon database branch isolation in tests
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Repository, https://github.com/
|
|
7
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/ZainRizvi/pytest-neon
|
|
6
|
+
Project-URL: Repository, https://github.com/ZainRizvi/pytest-neon
|
|
7
|
+
Project-URL: Issues, https://github.com/ZainRizvi/pytest-neon/issues
|
|
8
8
|
Author: Zain Rizvi
|
|
9
9
|
License-Expression: MIT
|
|
10
10
|
License-File: LICENSE
|
|
@@ -29,9 +29,12 @@ Requires-Dist: pytest>=7.0
|
|
|
29
29
|
Requires-Dist: requests>=2.20
|
|
30
30
|
Provides-Extra: dev
|
|
31
31
|
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == 'dev'
|
|
33
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == 'dev'
|
|
32
34
|
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
33
35
|
Requires-Dist: pytest-mock>=3.0; extra == 'dev'
|
|
34
36
|
Requires-Dist: ruff>=0.8; extra == 'dev'
|
|
37
|
+
Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
|
|
35
38
|
Provides-Extra: psycopg
|
|
36
39
|
Requires-Dist: psycopg[binary]>=3.1; extra == 'psycopg'
|
|
37
40
|
Provides-Extra: psycopg2
|
|
@@ -42,6 +45,8 @@ Description-Content-Type: text/markdown
|
|
|
42
45
|
|
|
43
46
|
# pytest-neon
|
|
44
47
|
|
|
48
|
+
[](https://github.com/ZainRizvi/pytest-neon/actions/workflows/tests.yml)
|
|
49
|
+
|
|
45
50
|
Pytest plugin for [Neon](https://neon.tech) database branch isolation in tests.
|
|
46
51
|
|
|
47
52
|
Each test gets its own isolated database state via Neon's instant branching and reset features. Branches are automatically cleaned up after tests complete.
|
|
@@ -112,7 +117,7 @@ pytest
|
|
|
112
117
|
|
|
113
118
|
### `neon_branch` (default, recommended)
|
|
114
119
|
|
|
115
|
-
The primary fixture for database testing. Creates one branch per test
|
|
120
|
+
The primary fixture for database testing. 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.
|
|
116
121
|
|
|
117
122
|
Returns a `NeonBranch` dataclass with:
|
|
118
123
|
|
|
@@ -134,7 +139,7 @@ def test_branch_info(neon_branch):
|
|
|
134
139
|
conn = psycopg.connect(neon_branch.connection_string)
|
|
135
140
|
```
|
|
136
141
|
|
|
137
|
-
**Performance**: ~1.5s initial setup per
|
|
142
|
+
**Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 tests, expect ~6.5s total overhead.
|
|
138
143
|
|
|
139
144
|
### `neon_branch_shared` (fastest, no isolation)
|
|
140
145
|
|
|
@@ -200,22 +205,65 @@ def test_query(neon_engine):
|
|
|
200
205
|
assert result.scalar() == 1
|
|
201
206
|
```
|
|
202
207
|
|
|
208
|
+
### Using Your Own SQLAlchemy Engine
|
|
209
|
+
|
|
210
|
+
If you have a module-level SQLAlchemy engine (common pattern), you **must** use `pool_pre_ping=True`:
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
# database.py
|
|
214
|
+
from sqlalchemy import create_engine
|
|
215
|
+
from config import DATABASE_URL
|
|
216
|
+
|
|
217
|
+
# pool_pre_ping=True is REQUIRED for pytest-neon
|
|
218
|
+
# It verifies connections are alive before using them
|
|
219
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Why?** After each test, pytest-neon 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.
|
|
223
|
+
|
|
224
|
+
This is also a best practice for any cloud database (Neon, RDS, etc.) where connections can be terminated externally.
|
|
225
|
+
|
|
203
226
|
## Migrations
|
|
204
227
|
|
|
205
228
|
pytest-neon supports running migrations once before tests, with all test resets preserving the migrated state.
|
|
206
229
|
|
|
230
|
+
### Smart Migration Detection
|
|
231
|
+
|
|
232
|
+
The plugin automatically detects whether migrations actually modified the database schema. This optimization:
|
|
233
|
+
|
|
234
|
+
- **Saves Neon costs**: No extra branch created when migrations don't change anything
|
|
235
|
+
- **Saves branch slots**: Neon projects have branch limits; this avoids wasting them
|
|
236
|
+
- **Zero configuration**: Works automatically with any migration tool
|
|
237
|
+
|
|
238
|
+
**When a second branch is created:**
|
|
239
|
+
- Only when `neon_apply_migrations` is overridden AND the schema actually changes
|
|
240
|
+
|
|
241
|
+
**When only one branch is used:**
|
|
242
|
+
- If you don't override `neon_apply_migrations` (no migrations defined)
|
|
243
|
+
- If your migrations are already applied (schema unchanged)
|
|
244
|
+
|
|
245
|
+
The detection works by comparing a fingerprint of `information_schema.columns` before and after migrations run.
|
|
246
|
+
|
|
207
247
|
### How It Works
|
|
208
248
|
|
|
209
|
-
When
|
|
249
|
+
When migrations actually modify the schema, the plugin uses a two-branch architecture:
|
|
210
250
|
|
|
211
251
|
```
|
|
212
252
|
Parent Branch (your configured parent)
|
|
213
253
|
└── Migration Branch (session-scoped)
|
|
214
254
|
│ ↑ migrations run here ONCE
|
|
215
|
-
└── Test Branch (
|
|
255
|
+
└── Test Branch (session-scoped)
|
|
216
256
|
↑ resets to migration branch after each test
|
|
217
257
|
```
|
|
218
258
|
|
|
259
|
+
When no schema changes occur, the plugin uses a single-branch architecture:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
Parent Branch (your configured parent)
|
|
263
|
+
└── Migration/Test Branch (session-scoped)
|
|
264
|
+
↑ resets to parent after each test
|
|
265
|
+
```
|
|
266
|
+
|
|
219
267
|
This means:
|
|
220
268
|
- Migrations run **once per test session** (not per test or per module)
|
|
221
269
|
- Each test reset restores to the **post-migration state**
|
|
@@ -376,11 +424,11 @@ jobs:
|
|
|
376
424
|
|
|
377
425
|
## How It Works
|
|
378
426
|
|
|
379
|
-
1.
|
|
427
|
+
1. At the start of the test session, the plugin creates a new Neon branch from your parent branch
|
|
380
428
|
2. `DATABASE_URL` is set to point to the new branch
|
|
381
429
|
3. Tests run against this isolated branch with full access to your schema and data
|
|
382
430
|
4. After each test, the branch is reset to its parent state (~0.5s)
|
|
383
|
-
5. After all tests
|
|
431
|
+
5. After all tests complete, the branch is deleted
|
|
384
432
|
6. As a safety net, branches auto-expire after 10 minutes even if cleanup fails
|
|
385
433
|
|
|
386
434
|
Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
|
|
@@ -440,6 +488,18 @@ Set the `NEON_API_KEY` environment variable or use the `--neon-api-key` CLI opti
|
|
|
440
488
|
|
|
441
489
|
Set the `NEON_PROJECT_ID` environment variable or use the `--neon-project-id` CLI option.
|
|
442
490
|
|
|
491
|
+
### "SSL connection has been closed unexpectedly" (SQLAlchemy)
|
|
492
|
+
|
|
493
|
+
This happens when SQLAlchemy tries to reuse a pooled connection after a branch reset. The reset terminates server-side connections, but SQLAlchemy's pool doesn't know.
|
|
494
|
+
|
|
495
|
+
**Fix:** Add `pool_pre_ping=True` to your engine:
|
|
496
|
+
|
|
497
|
+
```python
|
|
498
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
This makes SQLAlchemy verify connections before using them, automatically discarding stale ones.
|
|
502
|
+
|
|
443
503
|
## License
|
|
444
504
|
|
|
445
505
|
MIT
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pytest-neon
|
|
2
2
|
|
|
3
|
+
[](https://github.com/ZainRizvi/pytest-neon/actions/workflows/tests.yml)
|
|
4
|
+
|
|
3
5
|
Pytest plugin for [Neon](https://neon.tech) database branch isolation in tests.
|
|
4
6
|
|
|
5
7
|
Each test gets its own isolated database state via Neon's instant branching and reset features. Branches are automatically cleaned up after tests complete.
|
|
@@ -70,7 +72,7 @@ pytest
|
|
|
70
72
|
|
|
71
73
|
### `neon_branch` (default, recommended)
|
|
72
74
|
|
|
73
|
-
The primary fixture for database testing. Creates one branch per test
|
|
75
|
+
The primary fixture for database testing. 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.
|
|
74
76
|
|
|
75
77
|
Returns a `NeonBranch` dataclass with:
|
|
76
78
|
|
|
@@ -92,7 +94,7 @@ def test_branch_info(neon_branch):
|
|
|
92
94
|
conn = psycopg.connect(neon_branch.connection_string)
|
|
93
95
|
```
|
|
94
96
|
|
|
95
|
-
**Performance**: ~1.5s initial setup per
|
|
97
|
+
**Performance**: ~1.5s initial setup per session + ~0.5s reset per test. For 10 tests, expect ~6.5s total overhead.
|
|
96
98
|
|
|
97
99
|
### `neon_branch_shared` (fastest, no isolation)
|
|
98
100
|
|
|
@@ -158,22 +160,65 @@ def test_query(neon_engine):
|
|
|
158
160
|
assert result.scalar() == 1
|
|
159
161
|
```
|
|
160
162
|
|
|
163
|
+
### Using Your Own SQLAlchemy Engine
|
|
164
|
+
|
|
165
|
+
If you have a module-level SQLAlchemy engine (common pattern), you **must** use `pool_pre_ping=True`:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
# database.py
|
|
169
|
+
from sqlalchemy import create_engine
|
|
170
|
+
from config import DATABASE_URL
|
|
171
|
+
|
|
172
|
+
# pool_pre_ping=True is REQUIRED for pytest-neon
|
|
173
|
+
# It verifies connections are alive before using them
|
|
174
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Why?** After each test, pytest-neon 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.
|
|
178
|
+
|
|
179
|
+
This is also a best practice for any cloud database (Neon, RDS, etc.) where connections can be terminated externally.
|
|
180
|
+
|
|
161
181
|
## Migrations
|
|
162
182
|
|
|
163
183
|
pytest-neon supports running migrations once before tests, with all test resets preserving the migrated state.
|
|
164
184
|
|
|
185
|
+
### Smart Migration Detection
|
|
186
|
+
|
|
187
|
+
The plugin automatically detects whether migrations actually modified the database schema. This optimization:
|
|
188
|
+
|
|
189
|
+
- **Saves Neon costs**: No extra branch created when migrations don't change anything
|
|
190
|
+
- **Saves branch slots**: Neon projects have branch limits; this avoids wasting them
|
|
191
|
+
- **Zero configuration**: Works automatically with any migration tool
|
|
192
|
+
|
|
193
|
+
**When a second branch is created:**
|
|
194
|
+
- Only when `neon_apply_migrations` is overridden AND the schema actually changes
|
|
195
|
+
|
|
196
|
+
**When only one branch is used:**
|
|
197
|
+
- If you don't override `neon_apply_migrations` (no migrations defined)
|
|
198
|
+
- If your migrations are already applied (schema unchanged)
|
|
199
|
+
|
|
200
|
+
The detection works by comparing a fingerprint of `information_schema.columns` before and after migrations run.
|
|
201
|
+
|
|
165
202
|
### How It Works
|
|
166
203
|
|
|
167
|
-
When
|
|
204
|
+
When migrations actually modify the schema, the plugin uses a two-branch architecture:
|
|
168
205
|
|
|
169
206
|
```
|
|
170
207
|
Parent Branch (your configured parent)
|
|
171
208
|
└── Migration Branch (session-scoped)
|
|
172
209
|
│ ↑ migrations run here ONCE
|
|
173
|
-
└── Test Branch (
|
|
210
|
+
└── Test Branch (session-scoped)
|
|
174
211
|
↑ resets to migration branch after each test
|
|
175
212
|
```
|
|
176
213
|
|
|
214
|
+
When no schema changes occur, the plugin uses a single-branch architecture:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
Parent Branch (your configured parent)
|
|
218
|
+
└── Migration/Test Branch (session-scoped)
|
|
219
|
+
↑ resets to parent after each test
|
|
220
|
+
```
|
|
221
|
+
|
|
177
222
|
This means:
|
|
178
223
|
- Migrations run **once per test session** (not per test or per module)
|
|
179
224
|
- Each test reset restores to the **post-migration state**
|
|
@@ -334,11 +379,11 @@ jobs:
|
|
|
334
379
|
|
|
335
380
|
## How It Works
|
|
336
381
|
|
|
337
|
-
1.
|
|
382
|
+
1. At the start of the test session, the plugin creates a new Neon branch from your parent branch
|
|
338
383
|
2. `DATABASE_URL` is set to point to the new branch
|
|
339
384
|
3. Tests run against this isolated branch with full access to your schema and data
|
|
340
385
|
4. After each test, the branch is reset to its parent state (~0.5s)
|
|
341
|
-
5. After all tests
|
|
386
|
+
5. After all tests complete, the branch is deleted
|
|
342
387
|
6. As a safety net, branches auto-expire after 10 minutes even if cleanup fails
|
|
343
388
|
|
|
344
389
|
Branches use copy-on-write storage, so you only pay for data that differs from the parent branch.
|
|
@@ -398,6 +443,18 @@ Set the `NEON_API_KEY` environment variable or use the `--neon-api-key` CLI opti
|
|
|
398
443
|
|
|
399
444
|
Set the `NEON_PROJECT_ID` environment variable or use the `--neon-project-id` CLI option.
|
|
400
445
|
|
|
446
|
+
### "SSL connection has been closed unexpectedly" (SQLAlchemy)
|
|
447
|
+
|
|
448
|
+
This happens when SQLAlchemy tries to reuse a pooled connection after a branch reset. The reset terminates server-side connections, but SQLAlchemy's pool doesn't know.
|
|
449
|
+
|
|
450
|
+
**Fix:** Add `pool_pre_ping=True` to your engine:
|
|
451
|
+
|
|
452
|
+
```python
|
|
453
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
This makes SQLAlchemy verify connections before using them, automatically discarding stale ones.
|
|
457
|
+
|
|
401
458
|
## License
|
|
402
459
|
|
|
403
460
|
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pytest-neon"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.0"
|
|
8
8
|
description = "Pytest plugin for Neon database branch isolation in tests"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -42,12 +42,15 @@ dev = [
|
|
|
42
42
|
"mypy>=1.0",
|
|
43
43
|
"pytest-cov>=4.0",
|
|
44
44
|
"pytest-mock>=3.0",
|
|
45
|
+
"psycopg2-binary>=2.9",
|
|
46
|
+
"psycopg[binary]>=3.1",
|
|
47
|
+
"sqlalchemy>=2.0",
|
|
45
48
|
]
|
|
46
49
|
|
|
47
50
|
[project.urls]
|
|
48
|
-
Homepage = "https://github.com/
|
|
49
|
-
Repository = "https://github.com/
|
|
50
|
-
Issues = "https://github.com/
|
|
51
|
+
Homepage = "https://github.com/ZainRizvi/pytest-neon"
|
|
52
|
+
Repository = "https://github.com/ZainRizvi/pytest-neon"
|
|
53
|
+
Issues = "https://github.com/ZainRizvi/pytest-neon/issues"
|
|
51
54
|
|
|
52
55
|
[project.entry-points.pytest11]
|
|
53
56
|
neon = "pytest_neon.plugin"
|
|
@@ -1,7 +1,36 @@
|
|
|
1
|
-
"""Pytest plugin providing Neon database branch fixtures.
|
|
1
|
+
"""Pytest plugin providing Neon database branch fixtures.
|
|
2
|
+
|
|
3
|
+
This plugin provides fixtures for isolated database testing using Neon's
|
|
4
|
+
instant branching feature. Each test gets a clean database state via
|
|
5
|
+
branch reset after each test.
|
|
6
|
+
|
|
7
|
+
Main fixtures:
|
|
8
|
+
neon_branch: Primary fixture - one branch per session, reset after each test
|
|
9
|
+
neon_branch_shared: Shared branch without reset (fastest, no isolation)
|
|
10
|
+
neon_connection: psycopg2 connection (requires psycopg2 extra)
|
|
11
|
+
neon_connection_psycopg: psycopg v3 connection (requires psycopg extra)
|
|
12
|
+
neon_engine: SQLAlchemy engine (requires sqlalchemy extra)
|
|
13
|
+
|
|
14
|
+
SQLAlchemy Users:
|
|
15
|
+
If you create your own SQLAlchemy engine (not using neon_engine fixture),
|
|
16
|
+
you MUST use pool_pre_ping=True:
|
|
17
|
+
|
|
18
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
19
|
+
|
|
20
|
+
This is required because branch resets terminate server-side connections.
|
|
21
|
+
Without pool_pre_ping, SQLAlchemy may try to reuse dead pooled connections,
|
|
22
|
+
causing "SSL connection has been closed unexpectedly" errors.
|
|
23
|
+
|
|
24
|
+
Configuration:
|
|
25
|
+
Set NEON_API_KEY and NEON_PROJECT_ID environment variables, or use
|
|
26
|
+
--neon-api-key and --neon-project-id CLI options.
|
|
27
|
+
|
|
28
|
+
For full documentation, see: https://github.com/ZainRizvi/pytest-neon
|
|
29
|
+
"""
|
|
2
30
|
|
|
3
31
|
from __future__ import annotations
|
|
4
32
|
|
|
33
|
+
import contextlib
|
|
5
34
|
import os
|
|
6
35
|
import time
|
|
7
36
|
from collections.abc import Generator
|
|
@@ -17,6 +46,41 @@ from neon_api.schema import EndpointState
|
|
|
17
46
|
# Default branch expiry in seconds (10 minutes)
|
|
18
47
|
DEFAULT_BRANCH_EXPIRY_SECONDS = 600
|
|
19
48
|
|
|
49
|
+
# Sentinel value to detect when neon_apply_migrations was not overridden
|
|
50
|
+
_MIGRATIONS_NOT_DEFINED = object()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_schema_fingerprint(connection_string: str) -> tuple[tuple[Any, ...], ...]:
|
|
54
|
+
"""
|
|
55
|
+
Get a fingerprint of the database schema for change detection.
|
|
56
|
+
|
|
57
|
+
Queries information_schema for all tables, columns, and their properties
|
|
58
|
+
in the public schema. Returns a hashable tuple that can be compared
|
|
59
|
+
before/after migrations to detect if the schema actually changed.
|
|
60
|
+
|
|
61
|
+
This is used to avoid creating unnecessary migration branches when
|
|
62
|
+
no actual schema changes occurred.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
import psycopg
|
|
66
|
+
except ImportError:
|
|
67
|
+
try:
|
|
68
|
+
import psycopg2 as psycopg # type: ignore[import-not-found]
|
|
69
|
+
except ImportError:
|
|
70
|
+
# No driver available - can't fingerprint, assume migrations changed things
|
|
71
|
+
return ()
|
|
72
|
+
|
|
73
|
+
with psycopg.connect(connection_string) as conn, conn.cursor() as cur:
|
|
74
|
+
cur.execute("""
|
|
75
|
+
SELECT table_name, column_name, data_type, is_nullable,
|
|
76
|
+
column_default, ordinal_position
|
|
77
|
+
FROM information_schema.columns
|
|
78
|
+
WHERE table_schema = 'public'
|
|
79
|
+
ORDER BY table_name, ordinal_position
|
|
80
|
+
""")
|
|
81
|
+
rows = cur.fetchall()
|
|
82
|
+
return tuple(tuple(row) for row in rows)
|
|
83
|
+
|
|
20
84
|
|
|
21
85
|
@dataclass
|
|
22
86
|
class NeonBranch:
|
|
@@ -339,23 +403,47 @@ def _neon_migration_branch(
|
|
|
339
403
|
Note: The migration branch cannot have an expiry because Neon doesn't
|
|
340
404
|
allow creating child branches from branches with expiration dates.
|
|
341
405
|
Cleanup relies on the fixture teardown at session end.
|
|
406
|
+
|
|
407
|
+
Smart Migration Detection:
|
|
408
|
+
Before yielding, this fixture captures a schema fingerprint and stores
|
|
409
|
+
it on request.config. After migrations run, _neon_branch_for_reset
|
|
410
|
+
compares the fingerprint to detect if the schema actually changed.
|
|
342
411
|
"""
|
|
343
412
|
# No expiry - Neon doesn't allow children from branches with expiry
|
|
344
|
-
|
|
413
|
+
branch_gen = _create_neon_branch(
|
|
345
414
|
request,
|
|
346
415
|
branch_expiry_override=0,
|
|
347
416
|
branch_name_suffix="-migrated",
|
|
348
417
|
)
|
|
418
|
+
branch = next(branch_gen)
|
|
419
|
+
|
|
420
|
+
# Capture schema fingerprint BEFORE migrations run
|
|
421
|
+
# This is stored on config so _neon_branch_for_reset can compare after
|
|
422
|
+
pre_migration_fingerprint = _get_schema_fingerprint(branch.connection_string)
|
|
423
|
+
request.config._neon_pre_migration_fingerprint = pre_migration_fingerprint # type: ignore[attr-defined]
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
yield branch
|
|
427
|
+
finally:
|
|
428
|
+
# Clean up by exhausting the generator (triggers branch deletion)
|
|
429
|
+
with contextlib.suppress(StopIteration):
|
|
430
|
+
next(branch_gen)
|
|
349
431
|
|
|
350
432
|
|
|
351
433
|
@pytest.fixture(scope="session")
|
|
352
|
-
def neon_apply_migrations(_neon_migration_branch: NeonBranch) ->
|
|
434
|
+
def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> Any:
|
|
353
435
|
"""
|
|
354
436
|
Override this fixture to run migrations on the test database.
|
|
355
437
|
|
|
356
438
|
The migration branch is already created and DATABASE_URL is set.
|
|
357
439
|
Migrations run once per test session, before any tests execute.
|
|
358
440
|
|
|
441
|
+
Smart Migration Detection:
|
|
442
|
+
The plugin automatically detects whether migrations actually modified
|
|
443
|
+
the database schema. If no schema changes occurred (or this fixture
|
|
444
|
+
isn't overridden), the plugin skips creating a separate migration
|
|
445
|
+
branch, saving Neon costs and branch slots.
|
|
446
|
+
|
|
359
447
|
Example in conftest.py:
|
|
360
448
|
|
|
361
449
|
@pytest.fixture(scope="session")
|
|
@@ -384,27 +472,69 @@ def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> None:
|
|
|
384
472
|
_neon_migration_branch: The migration branch with connection details.
|
|
385
473
|
Use _neon_migration_branch.connection_string to connect directly,
|
|
386
474
|
or rely on DATABASE_URL which is already set.
|
|
475
|
+
|
|
476
|
+
Returns:
|
|
477
|
+
Any value (ignored). The default returns a sentinel to indicate
|
|
478
|
+
the fixture was not overridden.
|
|
387
479
|
"""
|
|
388
|
-
|
|
480
|
+
return _MIGRATIONS_NOT_DEFINED
|
|
389
481
|
|
|
390
482
|
|
|
391
|
-
@pytest.fixture(scope="
|
|
483
|
+
@pytest.fixture(scope="session")
|
|
392
484
|
def _neon_branch_for_reset(
|
|
393
485
|
request: pytest.FixtureRequest,
|
|
394
486
|
_neon_migration_branch: NeonBranch,
|
|
395
|
-
neon_apply_migrations:
|
|
487
|
+
neon_apply_migrations: Any, # Ensures migrations run first; value for detection
|
|
396
488
|
) -> Generator[NeonBranch, None, None]:
|
|
397
489
|
"""
|
|
398
490
|
Internal fixture that creates a test branch from the migration branch.
|
|
399
491
|
|
|
400
|
-
|
|
401
|
-
|
|
492
|
+
This is session-scoped so DATABASE_URL remains stable throughout the test
|
|
493
|
+
session, avoiding issues with Python's module caching (e.g., SQLAlchemy
|
|
494
|
+
engines created at import time would otherwise point to stale branches).
|
|
495
|
+
|
|
496
|
+
Smart Migration Detection:
|
|
497
|
+
This fixture implements a cost-optimization strategy:
|
|
498
|
+
|
|
499
|
+
1. If neon_apply_migrations was not overridden (returns sentinel),
|
|
500
|
+
skip creating a separate test branch - use the migration branch directly.
|
|
501
|
+
|
|
502
|
+
2. If neon_apply_migrations was overridden, compare schema fingerprints
|
|
503
|
+
before/after migrations. Only create a child branch if the schema
|
|
504
|
+
actually changed.
|
|
505
|
+
|
|
506
|
+
This avoids unnecessary Neon costs and branch slots when:
|
|
507
|
+
- No migration fixture is defined
|
|
508
|
+
- Migrations exist but are already applied (no schema changes)
|
|
402
509
|
"""
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
)
|
|
510
|
+
# Check if migrations fixture was overridden
|
|
511
|
+
migrations_defined = neon_apply_migrations is not _MIGRATIONS_NOT_DEFINED
|
|
512
|
+
|
|
513
|
+
# Check if schema actually changed (if we have a pre-migration fingerprint)
|
|
514
|
+
pre_fingerprint = getattr(request.config, "_neon_pre_migration_fingerprint", ())
|
|
515
|
+
schema_changed = False
|
|
516
|
+
|
|
517
|
+
if migrations_defined and pre_fingerprint:
|
|
518
|
+
# Compare with current schema
|
|
519
|
+
conn_str = _neon_migration_branch.connection_string
|
|
520
|
+
post_fingerprint = _get_schema_fingerprint(conn_str)
|
|
521
|
+
schema_changed = pre_fingerprint != post_fingerprint
|
|
522
|
+
elif migrations_defined and not pre_fingerprint:
|
|
523
|
+
# No fingerprint available (no psycopg/psycopg2 installed)
|
|
524
|
+
# Assume migrations changed something to be safe
|
|
525
|
+
schema_changed = True
|
|
526
|
+
|
|
527
|
+
# Only create a child branch if migrations actually modified the schema
|
|
528
|
+
if schema_changed:
|
|
529
|
+
yield from _create_neon_branch(
|
|
530
|
+
request,
|
|
531
|
+
parent_branch_id_override=_neon_migration_branch.branch_id,
|
|
532
|
+
branch_name_suffix="-test",
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
# No schema changes - reuse the migration branch directly
|
|
536
|
+
# This saves creating an unnecessary branch
|
|
537
|
+
yield _neon_migration_branch
|
|
408
538
|
|
|
409
539
|
|
|
410
540
|
@pytest.fixture(scope="function")
|
|
@@ -416,25 +546,35 @@ def neon_branch(
|
|
|
416
546
|
Provide an isolated Neon database branch for each test.
|
|
417
547
|
|
|
418
548
|
This is the primary fixture for database testing. It creates one branch per
|
|
419
|
-
test
|
|
549
|
+
test session, then resets it to the parent branch's state after each test.
|
|
420
550
|
This provides test isolation with ~0.5s overhead per test.
|
|
421
551
|
|
|
422
|
-
The branch is automatically deleted after all tests
|
|
423
|
-
|
|
552
|
+
The branch is automatically deleted after all tests complete, unless
|
|
553
|
+
--neon-keep-branches is specified. Branches also auto-expire after
|
|
424
554
|
10 minutes by default (configurable via --neon-branch-expiry) as a safety net
|
|
425
555
|
for interrupted test runs.
|
|
426
556
|
|
|
427
557
|
The connection string is automatically set in the DATABASE_URL environment
|
|
428
558
|
variable (configurable via --neon-env-var).
|
|
429
559
|
|
|
560
|
+
SQLAlchemy Users:
|
|
561
|
+
If you create your own engine (not using the neon_engine fixture),
|
|
562
|
+
you MUST use pool_pre_ping=True::
|
|
563
|
+
|
|
564
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
565
|
+
|
|
566
|
+
Branch resets terminate server-side connections. Without pool_pre_ping,
|
|
567
|
+
SQLAlchemy may reuse dead pooled connections, causing SSL errors.
|
|
568
|
+
|
|
430
569
|
Requires either:
|
|
431
|
-
|
|
432
|
-
|
|
570
|
+
- NEON_API_KEY and NEON_PROJECT_ID environment variables, or
|
|
571
|
+
- --neon-api-key and --neon-project-id command line options
|
|
433
572
|
|
|
434
573
|
Yields:
|
|
435
574
|
NeonBranch: Object with branch_id, project_id, connection_string, and host.
|
|
436
575
|
|
|
437
|
-
Example
|
|
576
|
+
Example::
|
|
577
|
+
|
|
438
578
|
def test_database_operation(neon_branch):
|
|
439
579
|
# DATABASE_URL is automatically set
|
|
440
580
|
conn_string = os.environ["DATABASE_URL"]
|
|
@@ -602,12 +742,24 @@ def neon_engine(neon_branch: NeonBranch):
|
|
|
602
742
|
Requires the sqlalchemy optional dependency:
|
|
603
743
|
pip install pytest-neon[sqlalchemy]
|
|
604
744
|
|
|
605
|
-
The engine is disposed after each test
|
|
745
|
+
The engine is disposed after each test, which handles stale connections
|
|
746
|
+
after branch resets automatically.
|
|
747
|
+
|
|
748
|
+
Note:
|
|
749
|
+
If you create your own module-level engine instead of using this
|
|
750
|
+
fixture, you MUST use pool_pre_ping=True::
|
|
751
|
+
|
|
752
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
|
753
|
+
|
|
754
|
+
This is required because branch resets terminate server-side
|
|
755
|
+
connections, and without pool_pre_ping SQLAlchemy may reuse dead
|
|
756
|
+
pooled connections.
|
|
606
757
|
|
|
607
758
|
Yields:
|
|
608
759
|
SQLAlchemy Engine object
|
|
609
760
|
|
|
610
|
-
Example
|
|
761
|
+
Example::
|
|
762
|
+
|
|
611
763
|
def test_query(neon_engine):
|
|
612
764
|
with neon_engine.connect() as conn:
|
|
613
765
|
result = conn.execute(text("SELECT 1"))
|
|
@@ -206,3 +206,68 @@ class TestRealDatabaseConnectivity:
|
|
|
206
206
|
)
|
|
207
207
|
result = cur.fetchone()
|
|
208
208
|
assert result[0] == "test_value"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class TestSQLAlchemyPooledConnections:
|
|
212
|
+
"""Test SQLAlchemy connection pooling behavior with branch resets.
|
|
213
|
+
|
|
214
|
+
Branch resets terminate server-side connections. SQLAlchemy's connection
|
|
215
|
+
pool doesn't know this, so pooled connections become stale.
|
|
216
|
+
|
|
217
|
+
Solution: Use pool_pre_ping=True (recommended for any cloud database).
|
|
218
|
+
This pings connections before use and discards stale ones automatically.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def test_pool_pre_ping_handles_stale_connections(self, pytester):
|
|
222
|
+
"""
|
|
223
|
+
Verify that pool_pre_ping=True handles stale connections after reset.
|
|
224
|
+
|
|
225
|
+
Pattern:
|
|
226
|
+
1. database.py creates engine with pool_pre_ping=True at import time
|
|
227
|
+
2. test_one uses it, connection goes to pool
|
|
228
|
+
3. Branch resets after test_one (server terminates connection)
|
|
229
|
+
4. test_two gets pooled connection, pool_pre_ping detects it's dead,
|
|
230
|
+
automatically gets fresh connection
|
|
231
|
+
"""
|
|
232
|
+
pytester.makepyfile(
|
|
233
|
+
database="""
|
|
234
|
+
import os
|
|
235
|
+
from sqlalchemy import create_engine
|
|
236
|
+
|
|
237
|
+
DATABASE_URL = os.environ.get("DATABASE_URL")
|
|
238
|
+
# pool_pre_ping=True is REQUIRED for pytest-neon (and recommended for any cloud DB)
|
|
239
|
+
# It verifies connections are alive before using them
|
|
240
|
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True) if DATABASE_URL else None
|
|
241
|
+
"""
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
pytester.makepyfile(
|
|
245
|
+
test_sqlalchemy_reset="""
|
|
246
|
+
from sqlalchemy import text
|
|
247
|
+
|
|
248
|
+
def test_first_query(neon_branch):
|
|
249
|
+
'''First test - creates pooled connection.'''
|
|
250
|
+
from database import engine
|
|
251
|
+
with engine.connect() as conn:
|
|
252
|
+
result = conn.execute(text("SELECT 1"))
|
|
253
|
+
assert result.scalar() == 1
|
|
254
|
+
# Connection returned to pool
|
|
255
|
+
|
|
256
|
+
def test_second_query_after_reset(neon_branch):
|
|
257
|
+
'''Second test - branch was reset, but pool_pre_ping handles it.'''
|
|
258
|
+
from database import engine
|
|
259
|
+
with engine.connect() as conn:
|
|
260
|
+
result = conn.execute(text("SELECT 2"))
|
|
261
|
+
assert result.scalar() == 2
|
|
262
|
+
|
|
263
|
+
def test_third_query_after_another_reset(neon_branch):
|
|
264
|
+
'''Third test - still works thanks to pool_pre_ping.'''
|
|
265
|
+
from database import engine
|
|
266
|
+
with engine.connect() as conn:
|
|
267
|
+
result = conn.execute(text("SELECT 3"))
|
|
268
|
+
assert result.scalar() == 3
|
|
269
|
+
"""
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
result = pytester.runpytest("-v")
|
|
273
|
+
result.assert_outcomes(passed=3)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Tests for migration support."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
from pytest_neon.plugin import _MIGRATIONS_NOT_DEFINED, neon_apply_migrations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestSmartMigrationDetection:
|
|
9
|
+
"""Test the sentinel-based detection for skipping unnecessary branches."""
|
|
10
|
+
|
|
11
|
+
def test_sentinel_returned_when_migrations_not_overridden(self):
|
|
12
|
+
"""Default neon_apply_migrations returns sentinel to signal no override."""
|
|
13
|
+
# Get the default implementation's return behavior from source
|
|
14
|
+
source = inspect.getsource(neon_apply_migrations)
|
|
15
|
+
assert "_MIGRATIONS_NOT_DEFINED" in source
|
|
16
|
+
|
|
17
|
+
def test_sentinel_is_unique_object(self):
|
|
18
|
+
"""Sentinel should be a unique object that won't match normal returns."""
|
|
19
|
+
assert _MIGRATIONS_NOT_DEFINED is not None
|
|
20
|
+
assert _MIGRATIONS_NOT_DEFINED is not False
|
|
21
|
+
assert _MIGRATIONS_NOT_DEFINED != ()
|
|
22
|
+
|
|
23
|
+
def test_user_override_does_not_return_sentinel(self, pytester):
|
|
24
|
+
"""When user overrides neon_apply_migrations, it returns None (not sentinel)."""
|
|
25
|
+
pytester.makeconftest(
|
|
26
|
+
"""
|
|
27
|
+
import os
|
|
28
|
+
import pytest
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pytest_neon.plugin import _MIGRATIONS_NOT_DEFINED
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FakeNeonBranch:
|
|
34
|
+
branch_id: str
|
|
35
|
+
project_id: str
|
|
36
|
+
connection_string: str
|
|
37
|
+
host: str
|
|
38
|
+
parent_id: str
|
|
39
|
+
|
|
40
|
+
@pytest.fixture(scope="session")
|
|
41
|
+
def _neon_migration_branch(request):
|
|
42
|
+
branch = FakeNeonBranch(
|
|
43
|
+
branch_id="br-migration",
|
|
44
|
+
project_id="proj-test",
|
|
45
|
+
connection_string="postgresql://fake",
|
|
46
|
+
host="test.neon.tech",
|
|
47
|
+
parent_id="br-parent",
|
|
48
|
+
)
|
|
49
|
+
os.environ["DATABASE_URL"] = branch.connection_string
|
|
50
|
+
request.config._neon_pre_migration_fingerprint = ()
|
|
51
|
+
yield branch
|
|
52
|
+
|
|
53
|
+
@pytest.fixture(scope="session")
|
|
54
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
55
|
+
# User override - returns None implicitly, not the sentinel
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@pytest.fixture(scope="session")
|
|
59
|
+
def _neon_branch_for_reset(_neon_migration_branch, neon_apply_migrations):
|
|
60
|
+
# Verify the detection logic
|
|
61
|
+
sentinel = _MIGRATIONS_NOT_DEFINED
|
|
62
|
+
migrations_defined = neon_apply_migrations is not sentinel
|
|
63
|
+
assert migrations_defined, "User override should NOT return sentinel"
|
|
64
|
+
yield _neon_migration_branch
|
|
65
|
+
|
|
66
|
+
@pytest.fixture(scope="function")
|
|
67
|
+
def neon_branch(_neon_branch_for_reset):
|
|
68
|
+
yield _neon_branch_for_reset
|
|
69
|
+
"""
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
pytester.makepyfile(
|
|
73
|
+
"""
|
|
74
|
+
def test_migration_override_detected(neon_branch):
|
|
75
|
+
assert neon_branch.branch_id == "br-migration"
|
|
76
|
+
"""
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
result = pytester.runpytest("-v")
|
|
80
|
+
result.assert_outcomes(passed=1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestMigrationFixtureOrder:
|
|
84
|
+
"""Test that migrations run before test branches are created."""
|
|
85
|
+
|
|
86
|
+
def test_migrations_run_before_test_branch_created(self, pytester):
|
|
87
|
+
"""Verify neon_apply_migrations is called before test branch exists."""
|
|
88
|
+
pytester.makeconftest(
|
|
89
|
+
"""
|
|
90
|
+
import os
|
|
91
|
+
import pytest
|
|
92
|
+
from dataclasses import dataclass
|
|
93
|
+
|
|
94
|
+
execution_order = []
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class FakeNeonBranch:
|
|
98
|
+
branch_id: str
|
|
99
|
+
project_id: str
|
|
100
|
+
connection_string: str
|
|
101
|
+
host: str
|
|
102
|
+
parent_id: str
|
|
103
|
+
|
|
104
|
+
@pytest.fixture(scope="session")
|
|
105
|
+
def _neon_migration_branch():
|
|
106
|
+
execution_order.append("migration_branch_created")
|
|
107
|
+
branch = FakeNeonBranch(
|
|
108
|
+
branch_id="br-migration",
|
|
109
|
+
project_id="proj-test",
|
|
110
|
+
connection_string="postgresql://migration",
|
|
111
|
+
host="test.neon.tech",
|
|
112
|
+
parent_id="br-parent",
|
|
113
|
+
)
|
|
114
|
+
os.environ["DATABASE_URL"] = branch.connection_string
|
|
115
|
+
yield branch
|
|
116
|
+
|
|
117
|
+
@pytest.fixture(scope="session")
|
|
118
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
119
|
+
execution_order.append("migrations_applied")
|
|
120
|
+
# User would run migrations here
|
|
121
|
+
|
|
122
|
+
@pytest.fixture(scope="module")
|
|
123
|
+
def _neon_branch_for_reset(_neon_migration_branch, neon_apply_migrations):
|
|
124
|
+
execution_order.append("test_branch_created")
|
|
125
|
+
branch = FakeNeonBranch(
|
|
126
|
+
branch_id="br-test",
|
|
127
|
+
project_id="proj-test",
|
|
128
|
+
connection_string="postgresql://test",
|
|
129
|
+
host="test.neon.tech",
|
|
130
|
+
parent_id=_neon_migration_branch.branch_id,
|
|
131
|
+
)
|
|
132
|
+
yield branch
|
|
133
|
+
|
|
134
|
+
@pytest.fixture(scope="function")
|
|
135
|
+
def neon_branch(_neon_branch_for_reset):
|
|
136
|
+
yield _neon_branch_for_reset
|
|
137
|
+
|
|
138
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
139
|
+
# Verify order: migration branch -> migrations -> test branch
|
|
140
|
+
assert execution_order == [
|
|
141
|
+
"migration_branch_created",
|
|
142
|
+
"migrations_applied",
|
|
143
|
+
"test_branch_created",
|
|
144
|
+
], f"Wrong order: {execution_order}"
|
|
145
|
+
"""
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
pytester.makepyfile(
|
|
149
|
+
"""
|
|
150
|
+
def test_uses_branch(neon_branch):
|
|
151
|
+
assert neon_branch.parent_id == "br-migration"
|
|
152
|
+
"""
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
result = pytester.runpytest("-v")
|
|
156
|
+
result.assert_outcomes(passed=1)
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"""Tests for migration support."""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class TestMigrationFixtureOrder:
|
|
5
|
-
"""Test that migrations run before test branches are created."""
|
|
6
|
-
|
|
7
|
-
def test_migrations_run_before_test_branch_created(self, pytester):
|
|
8
|
-
"""Verify neon_apply_migrations is called before test branch exists."""
|
|
9
|
-
pytester.makeconftest(
|
|
10
|
-
"""
|
|
11
|
-
import os
|
|
12
|
-
import pytest
|
|
13
|
-
from dataclasses import dataclass
|
|
14
|
-
|
|
15
|
-
execution_order = []
|
|
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_migration_branch():
|
|
27
|
-
execution_order.append("migration_branch_created")
|
|
28
|
-
branch = FakeNeonBranch(
|
|
29
|
-
branch_id="br-migration",
|
|
30
|
-
project_id="proj-test",
|
|
31
|
-
connection_string="postgresql://migration",
|
|
32
|
-
host="test.neon.tech",
|
|
33
|
-
parent_id="br-parent",
|
|
34
|
-
)
|
|
35
|
-
os.environ["DATABASE_URL"] = branch.connection_string
|
|
36
|
-
yield branch
|
|
37
|
-
|
|
38
|
-
@pytest.fixture(scope="session")
|
|
39
|
-
def neon_apply_migrations(_neon_migration_branch):
|
|
40
|
-
execution_order.append("migrations_applied")
|
|
41
|
-
# User would run migrations here
|
|
42
|
-
|
|
43
|
-
@pytest.fixture(scope="module")
|
|
44
|
-
def _neon_branch_for_reset(_neon_migration_branch, neon_apply_migrations):
|
|
45
|
-
execution_order.append("test_branch_created")
|
|
46
|
-
branch = FakeNeonBranch(
|
|
47
|
-
branch_id="br-test",
|
|
48
|
-
project_id="proj-test",
|
|
49
|
-
connection_string="postgresql://test",
|
|
50
|
-
host="test.neon.tech",
|
|
51
|
-
parent_id=_neon_migration_branch.branch_id,
|
|
52
|
-
)
|
|
53
|
-
yield branch
|
|
54
|
-
|
|
55
|
-
@pytest.fixture(scope="function")
|
|
56
|
-
def neon_branch(_neon_branch_for_reset):
|
|
57
|
-
yield _neon_branch_for_reset
|
|
58
|
-
|
|
59
|
-
def pytest_sessionfinish(session, exitstatus):
|
|
60
|
-
# Verify order: migration branch -> migrations -> test branch
|
|
61
|
-
assert execution_order == [
|
|
62
|
-
"migration_branch_created",
|
|
63
|
-
"migrations_applied",
|
|
64
|
-
"test_branch_created",
|
|
65
|
-
], f"Wrong order: {execution_order}"
|
|
66
|
-
"""
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
pytester.makepyfile(
|
|
70
|
-
"""
|
|
71
|
-
def test_uses_branch(neon_branch):
|
|
72
|
-
assert neon_branch.parent_id == "br-migration"
|
|
73
|
-
"""
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
result = pytester.runpytest("-v")
|
|
77
|
-
result.assert_outcomes(passed=1)
|
|
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
|