pytest-neon 0.3.0__tar.gz → 0.5.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.3.0 → pytest_neon-0.5.0}/.github/workflows/release.yml +7 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/CLAUDE.md +9 -1
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/PKG-INFO +110 -1
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/README.md +109 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/pyproject.toml +1 -1
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/src/pytest_neon/__init__.py +1 -1
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/src/pytest_neon/plugin.py +173 -23
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/conftest.py +7 -2
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_integration.py +75 -0
- pytest_neon-0.5.0/tests/test_migrations.py +77 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/.env.example +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/.github/workflows/tests.yml +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/.gitignore +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/.neon +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/LICENSE +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/src/pytest_neon/py.typed +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_branch_lifecycle.py +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_cli_options.py +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_env_var.py +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_fixture_errors.py +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_reset_behavior.py +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/tests/test_skip_behavior.py +0 -0
- {pytest_neon-0.3.0 → pytest_neon-0.5.0}/uv.lock +0 -0
|
@@ -80,6 +80,13 @@ jobs:
|
|
|
80
80
|
git tag "v$VERSION"
|
|
81
81
|
git push origin main --tags
|
|
82
82
|
|
|
83
|
+
- name: Create GitHub Release
|
|
84
|
+
env:
|
|
85
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
86
|
+
VERSION: ${{ steps.bump.outputs.version }}
|
|
87
|
+
run: |
|
|
88
|
+
gh release create "v$VERSION" --generate-notes
|
|
89
|
+
|
|
83
90
|
- name: Build and publish
|
|
84
91
|
env:
|
|
85
92
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Claude Code Instructions for pytest-neon
|
|
2
2
|
|
|
3
|
+
## Understanding the Plugin
|
|
4
|
+
|
|
5
|
+
Read `README.md` for complete documentation on how to use this plugin, including fixtures, configuration options, and migration support.
|
|
6
|
+
|
|
3
7
|
## Project Overview
|
|
4
8
|
|
|
5
9
|
This is a pytest plugin that provides isolated Neon database branches for integration testing. Each test gets isolated database state via branch reset after each test.
|
|
@@ -7,6 +11,8 @@ This is a pytest plugin that provides isolated Neon database branches for integr
|
|
|
7
11
|
## Key Architecture
|
|
8
12
|
|
|
9
13
|
- **Entry point**: `src/pytest_neon/plugin.py` - Contains all fixtures and pytest hooks
|
|
14
|
+
- **Migration fixture**: `_neon_migration_branch` - Session-scoped, parent for all test branches
|
|
15
|
+
- **User migration hook**: `neon_apply_migrations` - Session-scoped no-op, users override to run migrations
|
|
10
16
|
- **Core fixture**: `neon_branch` - Creates branch (module-scoped), resets after each test (function-scoped wrapper), sets `DATABASE_URL`, yields `NeonBranch` dataclass
|
|
11
17
|
- **Shared fixture**: `neon_branch_shared` - Module-scoped, no reset between tests
|
|
12
18
|
- **Convenience fixtures**: `neon_connection`, `neon_connection_psycopg`, `neon_engine` - Optional, require extras
|
|
@@ -19,7 +25,9 @@ This is a pytest plugin that provides isolated Neon database branches for integr
|
|
|
19
25
|
## Important Patterns
|
|
20
26
|
|
|
21
27
|
### Fixture Scopes
|
|
22
|
-
- `
|
|
28
|
+
- `_neon_migration_branch`: `scope="session"` - internal, parent for all test branches, migrations run here
|
|
29
|
+
- `neon_apply_migrations`: `scope="session"` - user overrides to run migrations
|
|
30
|
+
- `_neon_branch_for_reset`: `scope="module"` - internal, creates one branch per test file from migration branch
|
|
23
31
|
- `neon_branch`: `scope="function"` - wraps the above, resets branch after each test
|
|
24
32
|
- `neon_branch_shared`: `scope="module"` - one branch per test file, no reset
|
|
25
33
|
- Connection fixtures: `scope="function"` (default) - fresh connection per test
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-neon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Pytest plugin for Neon database branch isolation in tests
|
|
5
5
|
Project-URL: Homepage, https://github.com/zain/pytest-neon
|
|
6
6
|
Project-URL: Repository, https://github.com/zain/pytest-neon
|
|
@@ -120,6 +120,7 @@ Returns a `NeonBranch` dataclass with:
|
|
|
120
120
|
- `project_id`: The Neon project ID
|
|
121
121
|
- `connection_string`: Full PostgreSQL connection URI
|
|
122
122
|
- `host`: The database host
|
|
123
|
+
- `parent_id`: The parent branch ID (used for resets)
|
|
123
124
|
|
|
124
125
|
```python
|
|
125
126
|
import os
|
|
@@ -199,6 +200,90 @@ def test_query(neon_engine):
|
|
|
199
200
|
assert result.scalar() == 1
|
|
200
201
|
```
|
|
201
202
|
|
|
203
|
+
## Migrations
|
|
204
|
+
|
|
205
|
+
pytest-neon supports running migrations once before tests, with all test resets preserving the migrated state.
|
|
206
|
+
|
|
207
|
+
### How It Works
|
|
208
|
+
|
|
209
|
+
When you override the `neon_apply_migrations` fixture, the plugin uses a two-branch architecture:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
Parent Branch (your configured parent)
|
|
213
|
+
└── Migration Branch (session-scoped)
|
|
214
|
+
│ ↑ migrations run here ONCE
|
|
215
|
+
└── Test Branch (module-scoped)
|
|
216
|
+
↑ resets to migration branch after each test
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
This means:
|
|
220
|
+
- Migrations run **once per test session** (not per test or per module)
|
|
221
|
+
- Each test reset restores to the **post-migration state**
|
|
222
|
+
- Tests always see your migrated schema
|
|
223
|
+
|
|
224
|
+
### Setup
|
|
225
|
+
|
|
226
|
+
Override the `neon_apply_migrations` fixture in your `conftest.py`:
|
|
227
|
+
|
|
228
|
+
**Alembic:**
|
|
229
|
+
```python
|
|
230
|
+
# conftest.py
|
|
231
|
+
import subprocess
|
|
232
|
+
import pytest
|
|
233
|
+
|
|
234
|
+
@pytest.fixture(scope="session")
|
|
235
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
236
|
+
"""Run Alembic migrations before tests."""
|
|
237
|
+
# DATABASE_URL is already set to the migration branch
|
|
238
|
+
subprocess.run(["alembic", "upgrade", "head"], check=True)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
**Django:**
|
|
242
|
+
```python
|
|
243
|
+
# conftest.py
|
|
244
|
+
import pytest
|
|
245
|
+
|
|
246
|
+
@pytest.fixture(scope="session")
|
|
247
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
248
|
+
"""Run Django migrations before tests."""
|
|
249
|
+
from django.core.management import call_command
|
|
250
|
+
call_command("migrate", "--noinput")
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Raw SQL:**
|
|
254
|
+
```python
|
|
255
|
+
# conftest.py
|
|
256
|
+
import pytest
|
|
257
|
+
|
|
258
|
+
@pytest.fixture(scope="session")
|
|
259
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
260
|
+
"""Apply schema from SQL file."""
|
|
261
|
+
import psycopg
|
|
262
|
+
with psycopg.connect(_neon_migration_branch.connection_string) as conn:
|
|
263
|
+
with open("schema.sql") as f:
|
|
264
|
+
conn.execute(f.read())
|
|
265
|
+
conn.commit()
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Custom migration tool:**
|
|
269
|
+
```python
|
|
270
|
+
# conftest.py
|
|
271
|
+
import pytest
|
|
272
|
+
|
|
273
|
+
@pytest.fixture(scope="session")
|
|
274
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
275
|
+
"""Run custom migrations."""
|
|
276
|
+
from myapp.migrations import run_migrations
|
|
277
|
+
run_migrations(_neon_migration_branch.connection_string)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Important Notes
|
|
281
|
+
|
|
282
|
+
- The `_neon_migration_branch` parameter gives you access to the `NeonBranch` object with `connection_string`, `branch_id`, etc.
|
|
283
|
+
- `DATABASE_URL` (or your configured env var) is automatically set when the fixture runs
|
|
284
|
+
- If you don't override `neon_apply_migrations`, no migrations run (the fixture is a no-op by default)
|
|
285
|
+
- Migrations run before any test branches are created, so all tests see the same migrated schema
|
|
286
|
+
|
|
202
287
|
## Configuration
|
|
203
288
|
|
|
204
289
|
### Environment Variables
|
|
@@ -237,6 +322,30 @@ pytest --neon-branch-expiry=0
|
|
|
237
322
|
pytest --neon-env-var=TEST_DATABASE_URL
|
|
238
323
|
```
|
|
239
324
|
|
|
325
|
+
### pyproject.toml / pytest.ini
|
|
326
|
+
|
|
327
|
+
You can also configure options in your `pyproject.toml`:
|
|
328
|
+
|
|
329
|
+
```toml
|
|
330
|
+
[tool.pytest.ini_options]
|
|
331
|
+
neon_database = "mydb"
|
|
332
|
+
neon_role = "myrole"
|
|
333
|
+
neon_keep_branches = true
|
|
334
|
+
neon_branch_expiry = "300"
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Or in `pytest.ini`:
|
|
338
|
+
|
|
339
|
+
```ini
|
|
340
|
+
[pytest]
|
|
341
|
+
neon_database = mydb
|
|
342
|
+
neon_role = myrole
|
|
343
|
+
neon_keep_branches = true
|
|
344
|
+
neon_branch_expiry = 300
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
**Priority order**: CLI options > environment variables > ini settings > defaults
|
|
348
|
+
|
|
240
349
|
## CI/CD Integration
|
|
241
350
|
|
|
242
351
|
### GitHub Actions
|
|
@@ -78,6 +78,7 @@ Returns a `NeonBranch` dataclass with:
|
|
|
78
78
|
- `project_id`: The Neon project ID
|
|
79
79
|
- `connection_string`: Full PostgreSQL connection URI
|
|
80
80
|
- `host`: The database host
|
|
81
|
+
- `parent_id`: The parent branch ID (used for resets)
|
|
81
82
|
|
|
82
83
|
```python
|
|
83
84
|
import os
|
|
@@ -157,6 +158,90 @@ def test_query(neon_engine):
|
|
|
157
158
|
assert result.scalar() == 1
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
## Migrations
|
|
162
|
+
|
|
163
|
+
pytest-neon supports running migrations once before tests, with all test resets preserving the migrated state.
|
|
164
|
+
|
|
165
|
+
### How It Works
|
|
166
|
+
|
|
167
|
+
When you override the `neon_apply_migrations` fixture, the plugin uses a two-branch architecture:
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
Parent Branch (your configured parent)
|
|
171
|
+
└── Migration Branch (session-scoped)
|
|
172
|
+
│ ↑ migrations run here ONCE
|
|
173
|
+
└── Test Branch (module-scoped)
|
|
174
|
+
↑ resets to migration branch after each test
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This means:
|
|
178
|
+
- Migrations run **once per test session** (not per test or per module)
|
|
179
|
+
- Each test reset restores to the **post-migration state**
|
|
180
|
+
- Tests always see your migrated schema
|
|
181
|
+
|
|
182
|
+
### Setup
|
|
183
|
+
|
|
184
|
+
Override the `neon_apply_migrations` fixture in your `conftest.py`:
|
|
185
|
+
|
|
186
|
+
**Alembic:**
|
|
187
|
+
```python
|
|
188
|
+
# conftest.py
|
|
189
|
+
import subprocess
|
|
190
|
+
import pytest
|
|
191
|
+
|
|
192
|
+
@pytest.fixture(scope="session")
|
|
193
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
194
|
+
"""Run Alembic migrations before tests."""
|
|
195
|
+
# DATABASE_URL is already set to the migration branch
|
|
196
|
+
subprocess.run(["alembic", "upgrade", "head"], check=True)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Django:**
|
|
200
|
+
```python
|
|
201
|
+
# conftest.py
|
|
202
|
+
import pytest
|
|
203
|
+
|
|
204
|
+
@pytest.fixture(scope="session")
|
|
205
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
206
|
+
"""Run Django migrations before tests."""
|
|
207
|
+
from django.core.management import call_command
|
|
208
|
+
call_command("migrate", "--noinput")
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Raw SQL:**
|
|
212
|
+
```python
|
|
213
|
+
# conftest.py
|
|
214
|
+
import pytest
|
|
215
|
+
|
|
216
|
+
@pytest.fixture(scope="session")
|
|
217
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
218
|
+
"""Apply schema from SQL file."""
|
|
219
|
+
import psycopg
|
|
220
|
+
with psycopg.connect(_neon_migration_branch.connection_string) as conn:
|
|
221
|
+
with open("schema.sql") as f:
|
|
222
|
+
conn.execute(f.read())
|
|
223
|
+
conn.commit()
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**Custom migration tool:**
|
|
227
|
+
```python
|
|
228
|
+
# conftest.py
|
|
229
|
+
import pytest
|
|
230
|
+
|
|
231
|
+
@pytest.fixture(scope="session")
|
|
232
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
233
|
+
"""Run custom migrations."""
|
|
234
|
+
from myapp.migrations import run_migrations
|
|
235
|
+
run_migrations(_neon_migration_branch.connection_string)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Important Notes
|
|
239
|
+
|
|
240
|
+
- The `_neon_migration_branch` parameter gives you access to the `NeonBranch` object with `connection_string`, `branch_id`, etc.
|
|
241
|
+
- `DATABASE_URL` (or your configured env var) is automatically set when the fixture runs
|
|
242
|
+
- If you don't override `neon_apply_migrations`, no migrations run (the fixture is a no-op by default)
|
|
243
|
+
- Migrations run before any test branches are created, so all tests see the same migrated schema
|
|
244
|
+
|
|
160
245
|
## Configuration
|
|
161
246
|
|
|
162
247
|
### Environment Variables
|
|
@@ -195,6 +280,30 @@ pytest --neon-branch-expiry=0
|
|
|
195
280
|
pytest --neon-env-var=TEST_DATABASE_URL
|
|
196
281
|
```
|
|
197
282
|
|
|
283
|
+
### pyproject.toml / pytest.ini
|
|
284
|
+
|
|
285
|
+
You can also configure options in your `pyproject.toml`:
|
|
286
|
+
|
|
287
|
+
```toml
|
|
288
|
+
[tool.pytest.ini_options]
|
|
289
|
+
neon_database = "mydb"
|
|
290
|
+
neon_role = "myrole"
|
|
291
|
+
neon_keep_branches = true
|
|
292
|
+
neon_branch_expiry = "300"
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Or in `pytest.ini`:
|
|
296
|
+
|
|
297
|
+
```ini
|
|
298
|
+
[pytest]
|
|
299
|
+
neon_database = mydb
|
|
300
|
+
neon_role = myrole
|
|
301
|
+
neon_keep_branches = true
|
|
302
|
+
neon_branch_expiry = 300
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
**Priority order**: CLI options > environment variables > ini settings > defaults
|
|
306
|
+
|
|
198
307
|
## CI/CD Integration
|
|
199
308
|
|
|
200
309
|
### GitHub Actions
|
|
@@ -30,9 +30,10 @@ class NeonBranch:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
33
|
-
"""Add Neon-specific command line options."""
|
|
33
|
+
"""Add Neon-specific command line options and ini settings."""
|
|
34
34
|
group = parser.getgroup("neon", "Neon database branching")
|
|
35
35
|
|
|
36
|
+
# CLI options
|
|
36
37
|
group.addoption(
|
|
37
38
|
"--neon-api-key",
|
|
38
39
|
dest="neon_api_key",
|
|
@@ -51,13 +52,11 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
51
52
|
group.addoption(
|
|
52
53
|
"--neon-database",
|
|
53
54
|
dest="neon_database",
|
|
54
|
-
default="neondb",
|
|
55
55
|
help="Database name (default: neondb)",
|
|
56
56
|
)
|
|
57
57
|
group.addoption(
|
|
58
58
|
"--neon-role",
|
|
59
59
|
dest="neon_role",
|
|
60
|
-
default="neondb_owner",
|
|
61
60
|
help="Database role (default: neondb_owner)",
|
|
62
61
|
)
|
|
63
62
|
group.addoption(
|
|
@@ -70,7 +69,6 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
70
69
|
"--neon-branch-expiry",
|
|
71
70
|
dest="neon_branch_expiry",
|
|
72
71
|
type=int,
|
|
73
|
-
default=DEFAULT_BRANCH_EXPIRY_SECONDS,
|
|
74
72
|
help=(
|
|
75
73
|
f"Branch auto-expiry in seconds "
|
|
76
74
|
f"(default: {DEFAULT_BRANCH_EXPIRY_SECONDS}). Set to 0 to disable."
|
|
@@ -79,45 +77,114 @@ def pytest_addoption(parser: pytest.Parser) -> None:
|
|
|
79
77
|
group.addoption(
|
|
80
78
|
"--neon-env-var",
|
|
81
79
|
dest="neon_env_var",
|
|
82
|
-
default="DATABASE_URL",
|
|
83
80
|
help="Environment variable to set with connection string (default: DATABASE_URL)", # noqa: E501
|
|
84
81
|
)
|
|
85
82
|
|
|
83
|
+
# INI file settings (pytest.ini, pyproject.toml, etc.)
|
|
84
|
+
parser.addini("neon_api_key", "Neon API key", default=None)
|
|
85
|
+
parser.addini("neon_project_id", "Neon project ID", default=None)
|
|
86
|
+
parser.addini("neon_parent_branch", "Parent branch ID", default=None)
|
|
87
|
+
parser.addini("neon_database", "Database name", default="neondb")
|
|
88
|
+
parser.addini("neon_role", "Database role", default="neondb_owner")
|
|
89
|
+
parser.addini(
|
|
90
|
+
"neon_keep_branches",
|
|
91
|
+
"Don't delete branches after tests",
|
|
92
|
+
type="bool",
|
|
93
|
+
default=False,
|
|
94
|
+
)
|
|
95
|
+
parser.addini(
|
|
96
|
+
"neon_branch_expiry",
|
|
97
|
+
"Branch auto-expiry in seconds",
|
|
98
|
+
default=str(DEFAULT_BRANCH_EXPIRY_SECONDS),
|
|
99
|
+
)
|
|
100
|
+
parser.addini(
|
|
101
|
+
"neon_env_var",
|
|
102
|
+
"Environment variable for connection string",
|
|
103
|
+
default="DATABASE_URL",
|
|
104
|
+
)
|
|
105
|
+
|
|
86
106
|
|
|
87
107
|
def _get_config_value(
|
|
88
|
-
config: pytest.Config,
|
|
108
|
+
config: pytest.Config,
|
|
109
|
+
option: str,
|
|
110
|
+
env_var: str,
|
|
111
|
+
ini_name: str | None = None,
|
|
112
|
+
default: str | None = None,
|
|
89
113
|
) -> str | None:
|
|
90
|
-
"""Get config value from CLI option, env var, or default.
|
|
114
|
+
"""Get config value from CLI option, env var, ini setting, or default.
|
|
115
|
+
|
|
116
|
+
Priority order: CLI option > environment variable > ini setting > default
|
|
117
|
+
"""
|
|
118
|
+
# 1. CLI option (highest priority)
|
|
91
119
|
value = config.getoption(option, default=None)
|
|
92
120
|
if value is not None:
|
|
93
121
|
return value
|
|
94
|
-
|
|
122
|
+
|
|
123
|
+
# 2. Environment variable
|
|
124
|
+
env_value = os.environ.get(env_var)
|
|
125
|
+
if env_value is not None:
|
|
126
|
+
return env_value
|
|
127
|
+
|
|
128
|
+
# 3. INI setting (pytest.ini, pyproject.toml, etc.)
|
|
129
|
+
if ini_name is not None:
|
|
130
|
+
ini_value = config.getini(ini_name)
|
|
131
|
+
if ini_value:
|
|
132
|
+
return ini_value
|
|
133
|
+
|
|
134
|
+
# 4. Default
|
|
135
|
+
return default
|
|
95
136
|
|
|
96
137
|
|
|
97
138
|
def _create_neon_branch(
|
|
98
139
|
request: pytest.FixtureRequest,
|
|
140
|
+
parent_branch_id_override: str | None = None,
|
|
141
|
+
branch_expiry_override: int | None = None,
|
|
142
|
+
branch_name_suffix: str = "",
|
|
99
143
|
) -> Generator[NeonBranch, None, None]:
|
|
100
144
|
"""
|
|
101
145
|
Internal helper that creates and manages a Neon branch lifecycle.
|
|
102
146
|
|
|
103
147
|
This is the core implementation used by branch fixtures.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
request: Pytest fixture request
|
|
151
|
+
parent_branch_id_override: If provided, use this as parent instead of config
|
|
152
|
+
branch_expiry_override: If provided, use this expiry instead of config
|
|
153
|
+
branch_name_suffix: Optional suffix for branch name (e.g., "-migrated", "-test")
|
|
104
154
|
"""
|
|
105
155
|
config = request.config
|
|
106
156
|
|
|
107
|
-
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY")
|
|
108
|
-
project_id = _get_config_value(
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY", "neon_api_key")
|
|
158
|
+
project_id = _get_config_value(
|
|
159
|
+
config, "neon_project_id", "NEON_PROJECT_ID", "neon_project_id"
|
|
160
|
+
)
|
|
161
|
+
# Use override if provided, otherwise read from config
|
|
162
|
+
parent_branch_id = parent_branch_id_override or _get_config_value(
|
|
163
|
+
config, "neon_parent_branch", "NEON_PARENT_BRANCH_ID", "neon_parent_branch"
|
|
111
164
|
)
|
|
112
165
|
database_name = _get_config_value(
|
|
113
|
-
config, "neon_database", "NEON_DATABASE", "neondb"
|
|
166
|
+
config, "neon_database", "NEON_DATABASE", "neon_database", "neondb"
|
|
114
167
|
)
|
|
115
|
-
role_name = _get_config_value(
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
168
|
+
role_name = _get_config_value(
|
|
169
|
+
config, "neon_role", "NEON_ROLE", "neon_role", "neondb_owner"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# For boolean/int options, check CLI first, then ini
|
|
173
|
+
keep_branches = config.getoption("neon_keep_branches", default=None)
|
|
174
|
+
if keep_branches is None:
|
|
175
|
+
keep_branches = config.getini("neon_keep_branches")
|
|
176
|
+
|
|
177
|
+
# Use override if provided, otherwise read from config
|
|
178
|
+
if branch_expiry_override is not None:
|
|
179
|
+
branch_expiry = branch_expiry_override
|
|
180
|
+
else:
|
|
181
|
+
branch_expiry = config.getoption("neon_branch_expiry", default=None)
|
|
182
|
+
if branch_expiry is None:
|
|
183
|
+
branch_expiry = int(config.getini("neon_branch_expiry"))
|
|
184
|
+
|
|
185
|
+
env_var_name = _get_config_value(
|
|
186
|
+
config, "neon_env_var", "", "neon_env_var", "DATABASE_URL"
|
|
119
187
|
)
|
|
120
|
-
env_var_name = config.getoption("neon_env_var", default="DATABASE_URL")
|
|
121
188
|
|
|
122
189
|
if not api_key:
|
|
123
190
|
pytest.skip(
|
|
@@ -132,7 +199,7 @@ def _create_neon_branch(
|
|
|
132
199
|
neon = NeonAPI(api_key=api_key)
|
|
133
200
|
|
|
134
201
|
# Generate unique branch name
|
|
135
|
-
branch_name = f"pytest-{os.urandom(4).hex()}"
|
|
202
|
+
branch_name = f"pytest-{os.urandom(4).hex()}{branch_name_suffix}"
|
|
136
203
|
|
|
137
204
|
# Build branch creation payload
|
|
138
205
|
branch_config: dict[str, Any] = {"name": branch_name}
|
|
@@ -258,12 +325,86 @@ def _reset_branch_to_parent(branch: NeonBranch, api_key: str) -> None:
|
|
|
258
325
|
response.raise_for_status()
|
|
259
326
|
|
|
260
327
|
|
|
328
|
+
@pytest.fixture(scope="session")
|
|
329
|
+
def _neon_migration_branch(
|
|
330
|
+
request: pytest.FixtureRequest,
|
|
331
|
+
) -> Generator[NeonBranch, None, None]:
|
|
332
|
+
"""
|
|
333
|
+
Session-scoped branch where migrations are applied.
|
|
334
|
+
|
|
335
|
+
This branch is created from the configured parent and serves as
|
|
336
|
+
the parent for all test branches. Migrations run once per session
|
|
337
|
+
on this branch.
|
|
338
|
+
|
|
339
|
+
Note: The migration branch cannot have an expiry because Neon doesn't
|
|
340
|
+
allow creating child branches from branches with expiration dates.
|
|
341
|
+
Cleanup relies on the fixture teardown at session end.
|
|
342
|
+
"""
|
|
343
|
+
# No expiry - Neon doesn't allow children from branches with expiry
|
|
344
|
+
yield from _create_neon_branch(
|
|
345
|
+
request,
|
|
346
|
+
branch_expiry_override=0,
|
|
347
|
+
branch_name_suffix="-migrated",
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@pytest.fixture(scope="session")
|
|
352
|
+
def neon_apply_migrations(_neon_migration_branch: NeonBranch) -> None:
|
|
353
|
+
"""
|
|
354
|
+
Override this fixture to run migrations on the test database.
|
|
355
|
+
|
|
356
|
+
The migration branch is already created and DATABASE_URL is set.
|
|
357
|
+
Migrations run once per test session, before any tests execute.
|
|
358
|
+
|
|
359
|
+
Example in conftest.py:
|
|
360
|
+
|
|
361
|
+
@pytest.fixture(scope="session")
|
|
362
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
363
|
+
import subprocess
|
|
364
|
+
subprocess.run(["alembic", "upgrade", "head"], check=True)
|
|
365
|
+
|
|
366
|
+
Or with Django:
|
|
367
|
+
|
|
368
|
+
@pytest.fixture(scope="session")
|
|
369
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
370
|
+
from django.core.management import call_command
|
|
371
|
+
call_command("migrate", "--noinput")
|
|
372
|
+
|
|
373
|
+
Or with raw SQL:
|
|
374
|
+
|
|
375
|
+
@pytest.fixture(scope="session")
|
|
376
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
377
|
+
import psycopg
|
|
378
|
+
with psycopg.connect(_neon_migration_branch.connection_string) as conn:
|
|
379
|
+
with open("schema.sql") as f:
|
|
380
|
+
conn.execute(f.read())
|
|
381
|
+
conn.commit()
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
_neon_migration_branch: The migration branch with connection details.
|
|
385
|
+
Use _neon_migration_branch.connection_string to connect directly,
|
|
386
|
+
or rely on DATABASE_URL which is already set.
|
|
387
|
+
"""
|
|
388
|
+
pass # No-op by default - users override this fixture to run migrations
|
|
389
|
+
|
|
390
|
+
|
|
261
391
|
@pytest.fixture(scope="module")
|
|
262
392
|
def _neon_branch_for_reset(
|
|
263
393
|
request: pytest.FixtureRequest,
|
|
394
|
+
_neon_migration_branch: NeonBranch,
|
|
395
|
+
neon_apply_migrations: None, # Ensures migrations run first
|
|
264
396
|
) -> Generator[NeonBranch, None, None]:
|
|
265
|
-
"""
|
|
266
|
-
|
|
397
|
+
"""
|
|
398
|
+
Internal fixture that creates a test branch from the migration branch.
|
|
399
|
+
|
|
400
|
+
The test branch is created as a child of the migration branch, so resets
|
|
401
|
+
restore to post-migration state rather than the original parent state.
|
|
402
|
+
"""
|
|
403
|
+
yield from _create_neon_branch(
|
|
404
|
+
request,
|
|
405
|
+
parent_branch_id_override=_neon_migration_branch.branch_id,
|
|
406
|
+
branch_name_suffix="-test",
|
|
407
|
+
)
|
|
267
408
|
|
|
268
409
|
|
|
269
410
|
@pytest.fixture(scope="function")
|
|
@@ -301,7 +442,7 @@ def neon_branch(
|
|
|
301
442
|
conn_string = neon_branch.connection_string
|
|
302
443
|
"""
|
|
303
444
|
config = request.config
|
|
304
|
-
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY")
|
|
445
|
+
api_key = _get_config_value(config, "neon_api_key", "NEON_API_KEY", "neon_api_key")
|
|
305
446
|
|
|
306
447
|
# Validate that branch has a parent for reset functionality
|
|
307
448
|
if not _neon_branch_for_reset.parent_id:
|
|
@@ -330,6 +471,8 @@ def neon_branch(
|
|
|
330
471
|
@pytest.fixture(scope="module")
|
|
331
472
|
def neon_branch_shared(
|
|
332
473
|
request: pytest.FixtureRequest,
|
|
474
|
+
_neon_migration_branch: NeonBranch,
|
|
475
|
+
neon_apply_migrations: None, # Ensures migrations run first
|
|
333
476
|
) -> Generator[NeonBranch, None, None]:
|
|
334
477
|
"""
|
|
335
478
|
Provide a shared Neon database branch for all tests in a module.
|
|
@@ -338,6 +481,9 @@ def neon_branch_shared(
|
|
|
338
481
|
tests without resetting. This is the fastest option but tests can see
|
|
339
482
|
each other's data modifications.
|
|
340
483
|
|
|
484
|
+
If you override the `neon_apply_migrations` fixture, migrations will run
|
|
485
|
+
once before the first test, and this branch will include the migrated schema.
|
|
486
|
+
|
|
341
487
|
Use this when:
|
|
342
488
|
- Tests are read-only or don't interfere with each other
|
|
343
489
|
- You manually clean up test data within each test
|
|
@@ -355,7 +501,11 @@ def neon_branch_shared(
|
|
|
355
501
|
# Fast: no reset between tests, but be careful about data leakage
|
|
356
502
|
conn_string = neon_branch_shared.connection_string
|
|
357
503
|
"""
|
|
358
|
-
yield from _create_neon_branch(
|
|
504
|
+
yield from _create_neon_branch(
|
|
505
|
+
request,
|
|
506
|
+
parent_branch_id_override=_neon_migration_branch.branch_id,
|
|
507
|
+
branch_name_suffix="-shared",
|
|
508
|
+
)
|
|
359
509
|
|
|
360
510
|
|
|
361
511
|
@pytest.fixture
|
|
@@ -19,8 +19,13 @@ from pytest_neon.plugin import NeonBranch
|
|
|
19
19
|
@pytest.fixture(scope="module")
|
|
20
20
|
def neon_branch(request):
|
|
21
21
|
"""Mock neon_branch fixture for testing."""
|
|
22
|
-
keep_branches = request.config.getoption("neon_keep_branches", default=
|
|
23
|
-
|
|
22
|
+
keep_branches = request.config.getoption("neon_keep_branches", default=None)
|
|
23
|
+
if keep_branches is None:
|
|
24
|
+
keep_branches = False
|
|
25
|
+
|
|
26
|
+
env_var_name = request.config.getoption("neon_env_var", default=None)
|
|
27
|
+
if env_var_name is None:
|
|
28
|
+
env_var_name = "DATABASE_URL"
|
|
24
29
|
|
|
25
30
|
branch_info = NeonBranch(
|
|
26
31
|
branch_id="br-mock-123",
|
|
@@ -81,6 +81,81 @@ class TestRealBranchCreation:
|
|
|
81
81
|
assert os.environ.get("DATABASE_URL") == neon_branch.connection_string
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
class TestMigrations:
|
|
85
|
+
"""Test migration support with real Neon branches."""
|
|
86
|
+
|
|
87
|
+
def test_migrations_persist_across_resets(self, pytester, tmp_path):
|
|
88
|
+
"""Test that migrations run once and persist across test resets."""
|
|
89
|
+
# Write a conftest that tracks migration and verifies table exists
|
|
90
|
+
conftest = """
|
|
91
|
+
import os
|
|
92
|
+
import pytest
|
|
93
|
+
|
|
94
|
+
# Track if migrations ran
|
|
95
|
+
migrations_ran = [False]
|
|
96
|
+
|
|
97
|
+
@pytest.fixture(scope="session")
|
|
98
|
+
def neon_apply_migrations(_neon_migration_branch):
|
|
99
|
+
\"\"\"Create a test table via migration.\"\"\"
|
|
100
|
+
try:
|
|
101
|
+
import psycopg
|
|
102
|
+
except ImportError:
|
|
103
|
+
pytest.skip("psycopg not installed")
|
|
104
|
+
|
|
105
|
+
with psycopg.connect(_neon_migration_branch.connection_string) as conn:
|
|
106
|
+
with conn.cursor() as cur:
|
|
107
|
+
cur.execute(\"\"\"
|
|
108
|
+
CREATE TABLE IF NOT EXISTS migration_test (
|
|
109
|
+
id SERIAL PRIMARY KEY,
|
|
110
|
+
value TEXT NOT NULL
|
|
111
|
+
)
|
|
112
|
+
\"\"\")
|
|
113
|
+
conn.commit()
|
|
114
|
+
migrations_ran[0] = True
|
|
115
|
+
|
|
116
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
117
|
+
assert migrations_ran[0], "Migrations should have run"
|
|
118
|
+
"""
|
|
119
|
+
pytester.makeconftest(conftest)
|
|
120
|
+
|
|
121
|
+
# Write tests that verify the migrated table exists and data resets
|
|
122
|
+
pytester.makepyfile(
|
|
123
|
+
"""
|
|
124
|
+
import psycopg
|
|
125
|
+
|
|
126
|
+
def test_first_insert(neon_branch):
|
|
127
|
+
\"\"\"Insert data - table should exist from migration.\"\"\"
|
|
128
|
+
with psycopg.connect(neon_branch.connection_string) as conn:
|
|
129
|
+
with conn.cursor() as cur:
|
|
130
|
+
cur.execute("INSERT INTO migration_test (value) VALUES ('first')")
|
|
131
|
+
conn.commit()
|
|
132
|
+
|
|
133
|
+
with conn.cursor() as cur:
|
|
134
|
+
cur.execute("SELECT COUNT(*) FROM migration_test")
|
|
135
|
+
count = cur.fetchone()[0]
|
|
136
|
+
assert count == 1
|
|
137
|
+
|
|
138
|
+
def test_second_insert_after_reset(neon_branch):
|
|
139
|
+
\"\"\"After reset, table exists but previous data is gone.\"\"\"
|
|
140
|
+
with psycopg.connect(neon_branch.connection_string) as conn:
|
|
141
|
+
# Table should still exist (from migration)
|
|
142
|
+
with conn.cursor() as cur:
|
|
143
|
+
cur.execute("SELECT COUNT(*) FROM migration_test")
|
|
144
|
+
count = cur.fetchone()[0]
|
|
145
|
+
# Data from first test should be gone after reset
|
|
146
|
+
assert count == 0
|
|
147
|
+
|
|
148
|
+
# Insert new data
|
|
149
|
+
with conn.cursor() as cur:
|
|
150
|
+
cur.execute("INSERT INTO migration_test (value) VALUES ('second')")
|
|
151
|
+
conn.commit()
|
|
152
|
+
"""
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
result = pytester.runpytest("-v")
|
|
156
|
+
result.assert_outcomes(passed=2)
|
|
157
|
+
|
|
158
|
+
|
|
84
159
|
class TestRealDatabaseConnectivity:
|
|
85
160
|
"""Test actual database connectivity."""
|
|
86
161
|
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|