我在测试逻辑时遇到测试隔离问题,涉及 SQLAlchemy 中的事务回滚。
型号:
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
company = db.Column(db.Text)
subtype = db.Column(db.Text)
__table_args__ = (db.UniqueConstraint(company, subtype),)
查看:
def create():
instance = Product(**request.json)
db.session.add(instance)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return {"detail": "Product object already exists", "status": 406, "title": "Duplicate object"}, 406
return {"uri": f"/products/{instance.id}"}, 201
测试:
DEFAULT_DATA = {"company": "Test", "subtype": "Sub"}
def test_create(client):
response = client.post("/products", json=DEFAULT_DATA)
assert response.status_code == 201
instance = Product.query.one()
assert response.json == {"uri": f"/products/{instance.id}"}
def test_create_duplicate(client):
response = client.post("/products", json=DEFAULT_DATA)
assert response.status_code == 201
instance = Product.query.one()
assert response.json == {"uri": f"/products/{instance.id}"}
response = client.post("/products", json=DEFAULT_DATA)
assert response.status_code == 406
assert response.json == {"detail": "Product object already exists", "status": 406, "title": "Duplicate object"}
conftest.py:
import flask_migrate
import pytest
from sqlalchemy import event
from project.app import create_connexion_app
from project.models import db
@pytest.fixture(scope="session")
def connexion_app():
return create_connexion_app("project.settings.TestSettings")
@pytest.fixture(scope="session")
def app(connexion_app):
app = connexion_app.app
with app.app_context():
yield app
@pytest.fixture(scope="session", name="db")
def db_setup(app):
flask_migrate.upgrade()
yield db
flask_migrate.downgrade()
db.engine.execute("DROP TABLE IF EXISTS alembic_version")
@pytest.fixture(autouse=True)
def session(db):
with db.engine.connect() as connection:
@event.listens_for(db.session, "after_transaction_end")
def restart_savepoint(session, transaction):
if transaction.nested and not transaction._parent.nested:
# ensure that state is expired the way
# session.commit() at the top level normally does
# (optional step)
session.expire_all()
session.begin_nested()
transaction = connection.begin()
db.session.begin_nested()
yield db.session
db.session.rollback()
db.session.close()
if transaction.is_active:
transaction.rollback()
SQLALCHEMY_COMMIT_ON_TEARDOWN
设置为 False
第二个测试失败,输出如下:
def test_create_duplicate(client):
response = client.post("/products", json=DEFAULT_DATA)
> assert response.status_code == 201
E AssertionError: assert 406 == 201
E + where 406 = <<class 'pytest_flask.plugin.JSONResponse'> streamed [406 NOT ACCEPTABLE]>.status_code
相关PG日志:
LOG: statement: BEGIN
LOG: statement: INSERT INTO product (company, subtype) VALUES ('Test', 'Sub') RETURNING product.id
LOG: statement: COMMIT
LOG: statement: BEGIN
LOG: statement: SELECT product.id AS product_id, product.company AS product_company, product.subtype AS product_subtype
FROM product
WHERE product.id = 1
LOG: statement: SELECT product.id AS product_id, product.company AS product_company, product.subtype AS product_subtype
FROM product
LOG: statement: ROLLBACK
LOG: statement: BEGIN
LOG: statement: INSERT INTO product (company, subtype) VALUES ('Test', 'Sub') RETURNING product.id
ERROR: duplicate key value violates unique constraint "product_company_subtype_key"
DETAIL: Key (company, subtype)=(Test, Sub) already exists.
STATEMENT: INSERT INTO product (company, subtype) VALUES ('Test', 'Sub') RETURNING product.id
LOG: statement: ROLLBACK
因此,第一个测试将一行提交到数据库中,并且在测试之间不会回滚,因此在运行之间不会恢复数据库状态。
其他测试,不涉及显式回滚,工作正常。尝试将
SQLALCHEMY_COMMIT_ON_TEARDOWN
更改为True
并使用flush
代替commit
,但在这种情况下,test_create_duplicate
之后的测试会受到影响。
如何设置测试套件来测试此类代码,其中涉及手动提交/回滚?
套餐:
Python版本:3.6.6
RDBMS:PostgreSQL 10.4
注意:问题提到“RDBMS:PostgreSQL 10.4”,但这无法使用 PostgreSQL 重现。
看来您实际上正在使用 SQLite 和默认的 pysqlite 驱动程序进行测试。
在conftest.py中添加以下内容:
@event.listens_for(db.engine, "connect")
def do_connect(dbapi_connection, connection_record):
# disable pysqlite's emitting of the BEGIN statement entirely.
# also stops it from emitting COMMIT before any DDL.
dbapi_connection.isolation_level = None
@event.listens_for(db.engine, "begin")
def do_begin(conn):
# emit our own BEGIN
conn.exec_driver_sql("BEGIN")
来自 https://docs.sqlalchemy.org/en/20/dialects/sqlite.html#pysqlite-serialized:
pysqlite DBAPI 驱动程序有几个长期存在的错误,影响其事务行为的正确性。在其默认操作模式下,SQLite 功能(例如 SERIALIZABLE 隔离、事务性 DDL 和SAVEPOINT 支持)是不起作用的,为了使用这些功能,必须采取解决方法。
...
好消息是,通过一些事件,我们可以通过完全禁用 pysqlite 的功能并自己发出 BEGIN 来完全实现事务支持。这是使用两个事件侦听器实现的:
(如上所述;以及强调我的)