mirror of https://github.com/encode/starlette.git
Update database docs
This commit is contained in:
parent
654a65b649
commit
e4db34c363
143
docs/database.md
143
docs/database.md
|
@ -11,7 +11,6 @@ Here's a complete example, that includes table definitions, installing the
|
||||||
`DatabaseMiddleware`, and a couple of endpoints that interact with the database.
|
`DatabaseMiddleware`, and a couple of endpoints that interact with the database.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import os
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.config import Config
|
from starlette.config import Config
|
||||||
|
@ -131,37 +130,153 @@ async def populate_note(request):
|
||||||
transaction.commit()
|
transaction.commit()
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test isolation:
|
## Test isolation
|
||||||
|
|
||||||
Use rollback_on_shutdown when instantiating DatabaseMiddleware to support test-isolated sessions.
|
There are a few things that we want to ensure when running tests against
|
||||||
|
a service that uses a database. Our requirements should be:
|
||||||
|
|
||||||
|
* Use a separate database for testing.
|
||||||
|
* Create a new test database every time we run the tests.
|
||||||
|
* Ensure that the database state is isolated between each test case.
|
||||||
|
|
||||||
|
Here's how we need to structure our application and tests in order to
|
||||||
|
meet those requirements:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.config import Config
|
||||||
|
|
||||||
|
config = Config(".env")
|
||||||
|
|
||||||
|
TESTING = config('TESTING', cast=bool, default=False)
|
||||||
|
DATABASE_URL = config('DATABASE_URL', cast=DatabaseURL)
|
||||||
|
if TESTING:
|
||||||
|
# Use a database name like "test_myapplication" for tests.
|
||||||
|
DATABASE_URL = DATABASE_URL.replace(database='test_' + DATABASE_URL.database)
|
||||||
|
|
||||||
|
|
||||||
|
# Use 'rollback_on_shutdown' during testing, to ensure we have
|
||||||
|
app = Starlette()
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
DatabaseMiddleware,
|
DatabaseMiddleware,
|
||||||
database_url=os.environ['DATABASE_URL'],
|
database_url=DATABASE_URL,
|
||||||
rollback_on_shutdown=os.environ['TESTING']
|
rollback_on_shutdown=TESTING
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll need to use TestClient as a context manager, in order to perform application startup/shutdown.
|
We still need to set `TESTING` during a test run, and setup the test database.
|
||||||
|
Assuming we're using `py.test`, here's how our `conftest.py` might look:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
with TestClient(app) as client:
|
import pytest
|
||||||
# Entering the block performs application startup.
|
from starlette.config import environ
|
||||||
...
|
from starlette.testclient import TestClient
|
||||||
# Exiting the block performs application shutdown.
|
from sqlalchemy import create_engine
|
||||||
```
|
from sqlalchemy_utils import database_exists, create_database
|
||||||
|
|
||||||
If you're using `py.test` you can create a fixture for the test client, like so:
|
# This sets `os.environ`, but provides some additional protection.
|
||||||
|
# If we placed it below the application import, it would raise an error
|
||||||
|
# informing us that 'TESTING' had already been read from the environment.
|
||||||
|
environ['TESTING'] = 'True'
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def create_test_database():
|
||||||
|
"""
|
||||||
|
Create a clean database on every test case.
|
||||||
|
For safety, we should abort if a database already exists.
|
||||||
|
|
||||||
|
We use the `sqlalchemy_utils` package here for a few helpers in consistently
|
||||||
|
creating and dropping the database.
|
||||||
|
"""
|
||||||
|
url = str(app.DATABASE_URL)
|
||||||
|
engine = create_engine(url)
|
||||||
|
assert not database_exists(url), 'Test database already exists. Aborting tests.'
|
||||||
|
create_database(url) # Create the test database.
|
||||||
|
metadata.create_all(engine) # Create the tables.
|
||||||
|
yield # Run the tests.
|
||||||
|
drop_database(url) # Drop the test database.
|
||||||
|
|
||||||
**conftest.py**:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client():
|
def client():
|
||||||
|
"""
|
||||||
|
When using the 'client' fixture in test cases, we'll get full database
|
||||||
|
rollbacks between test cases:
|
||||||
|
|
||||||
|
def test_homepage(client):
|
||||||
|
url = app.url_path_for('homepage')
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
"""
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
yield client
|
yield client
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
You'll almost certainly need to be using database migrations in order to manage
|
||||||
|
incremental changes to the database. For this we'd strongly recommend
|
||||||
|
[Alembic][alembic], which is written by the author of SQLAlchemy.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
$ pip install alembic
|
||||||
|
$ pip install psycopg2-binary # Install an appropriate database driver.
|
||||||
|
$ alembic init migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
Now, you'll want to set things up so that Alembic references the configured
|
||||||
|
DATABASE_URL, and uses your table metadata.
|
||||||
|
|
||||||
|
In `alembic.ini` remove the following line:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
```
|
||||||
|
|
||||||
|
In `migrations/env.py`, you need to set the ``'sqlalchemy.url'`` configuration key,
|
||||||
|
and the `target_metadata` variable. You'll want something like this:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# The Alembic Config object.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Configure Alembic to use our DATABASE_URL and our table definitions...
|
||||||
|
import app
|
||||||
|
config.set_main_option('sqlalchemy.url', str(app.DATABASE_URL))
|
||||||
|
target_metadata = app.metadata
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Running migrations during testing**
|
||||||
|
|
||||||
|
It is good practice to ensure that your test suite runs the database migrations
|
||||||
|
every time it creates the test database. This will help catch any issues in your
|
||||||
|
migration scripts, and will help ensure that the tests are running against
|
||||||
|
a database that's in a consistent state with your live database.
|
||||||
|
|
||||||
|
We can adjust the `create_test_database` fixture slightly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def create_test_database():
|
||||||
|
url = str(app.DATABASE_URL)
|
||||||
|
engine = create_engine(url)
|
||||||
|
assert not database_exists(url), 'Test database already exists. Aborting tests.'
|
||||||
|
create_database(url) # Create the test database.
|
||||||
|
config = Config("alembic.ini") # Run the migrations.
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
yield # Run the tests.
|
||||||
|
drop_database(url) # Drop the test database.
|
||||||
|
```
|
||||||
|
|
||||||
[sqlalchemy-core]: https://docs.sqlalchemy.org/en/latest/core/
|
[sqlalchemy-core]: https://docs.sqlalchemy.org/en/latest/core/
|
||||||
|
[alembic]: https://alembic.sqlalchemy.org/en/latest/
|
||||||
|
|
Loading…
Reference in New Issue