单元测试时Flask上下文外无法使用SQLAlchemy Session求助
问题场景
Flask项目采用应用工厂模式,flaskapp/__init__.py创建应用实例,flaskapp/db.py基于current_app配置初始化SQLAlchemy的engine和Session,业务模块使用Session操作数据库正常。但执行unittest单元测试时,导入Session初始化测试数据库时触发错误:
RuntimeError: Working outside of application context. This typically means that you attempted to use functionality that needed the current application. To solve this, set up an application context with app.app_context(). See the documentation for more information.
关键代码
flaskapp/init.py
import os from flask import Flask def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) app.config.from_mapping( SECRET_KEY=b'some_secret_key', DEBUG=True, SQLALCHEMY_DATABASE_URI=f'sqlite:///{os.path.join(app.instance_path, "flaskapp.sqlite")}', ) if test_config is None: app.config.from_pyfile('config.py', silent=True) else: app.config.from_mapping(test_config) try: os.makedirs(app.instance_path) except OSError: pass with app.app_context(): from flaskapp.routes import home, auth from flaskapp.db import init_db init_db() return app
flaskapp/db.py
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from flask import current_app engine = create_engine(current_app.config['SQLALCHEMY_DATABASE_URI'], echo=True) Session = sessionmaker(bind=engine) def init_db(): import flaskapp.models as models models.Base.metadata.create_all(engine) def drop_db(): import flaskapp.models as models models.Base.metadata.drop_all(engine)
tests/init.py
import unittest from flaskapp import create_app from flaskapp.db import Session from flaskapp.models import User class BaseTestClass(unittest.TestCase): def setUp(self): self.app = create_app(test_config={ 'TESTING': True, 'DEBUG': True, 'APP_ENV': 'testing', # 传入测试数据库URI 'SQLALCHEMY_DATABASE_URI': 'sqlite:///testdb.sqlite', }) self.client = self.app.test_client() # 上下文 with self.app.app_context(): self.populate_db() def tearDown(self): pass def populate_db(self): with Session() as db_session: db_session.add(User( username='Harry', email='harry@yahoo.es', password = 'Harry123.' )) db_session.commit()
问题根源
flaskapp/db.py在模块加载阶段(即被import时)就直接执行create_engine(current_app.config['...']),此时Flask应用实例尚未创建,也没有激活应用上下文,current_app无法获取到有效应用对象,因此抛出错误。业务模块能正常运行是因为它们在应用上下文激活后才被导入(create_app的with app.app_context()块内),而测试代码在导入Session时,应用还未初始化,直接触发了模块级别的错误代码。
解决方案
1. 修改数据库初始化逻辑,延迟创建engine和Session
将flaskapp/db.py中的模块级别初始化改为通过函数触发,确保只有在应用上下文存在时才创建数据库连接:
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker # 初始化为空,后续通过函数赋值 engine = None Session = None def init_engine_and_session(db_uri): """根据传入的数据库URI初始化engine和Session""" global engine, Session engine = create_engine(db_uri, echo=True) Session = sessionmaker(bind=engine) def init_db(): """创建数据库表""" import flaskapp.models as models models.Base.metadata.create_all(engine) def drop_db(): """删除数据库表""" import flaskapp.models as models models.Base.metadata.drop_all(engine)
2. 在应用工厂中触发数据库初始化
修改flaskapp/__init__.py,在创建应用并激活上下文后,调用init_engine_and_session初始化数据库连接:
import os from flask import Flask def create_app(test_config=None): app = Flask(__name__, instance_relative_config=True) app.config.from_mapping( SECRET_KEY=b'some_secret_key', DEBUG=True, SQLALCHEMY_DATABASE_URI=f'sqlite:///{os.path.join(app.instance_path, "flaskapp.sqlite")}', ) if test_config is None: app.config.from_pyfile('config.py', silent=True) else: app.config.from_mapping(test_config) try: os.makedirs(app.instance_path) except OSError: pass with app.app_context(): # 先初始化数据库连接 from flaskapp.db import init_engine_and_session, init_db init_engine_and_session(app.config['SQLALCHEMY_DATABASE_URI']) init_db() # 再导入路由 from flaskapp.routes import home, auth return app
3. 完善测试代码的清理逻辑
修改tests/__init__.py的tearDown方法,测试结束后清理测试数据库:
import unittest import os from flaskapp import create_app from flaskapp.db import Session, drop_db from flaskapp.models import User class BaseTestClass(unittest.TestCase): def setUp(self): self.app = create_app(test_config={ 'TESTING': True, 'DEBUG': True, 'APP_ENV': 'testing', 'SQLALCHEMY_DATABASE_URI': 'sqlite:///testdb.sqlite', }) self.client = self.app.test_client() with self.app.app_context(): self.populate_db() def tearDown(self): with self.app.app_context(): drop_db() # 删除测试数据库文件 if os.path.exists('testdb.sqlite'): os.remove('testdb.sqlite') def populate_db(self): with Session() as db_session: db_session.add(User( username='Harry', email='harry@yahoo.es', password='Harry123.' )) db_session.commit()
核心思路
避免在模块加载阶段依赖current_app这类需要应用上下文的对象,将数据库连接的初始化逻辑延迟到应用实例创建并激活上下文之后,通过应用工厂统一触发。这样无论是正常运行还是测试场景,都能保证数据库连接初始化时拥有有效的应用配置。
内容的提问来源于stack exchange,提问作者Chus

