测试提交表单
最后修改于 2025 年 3 月 23 日
本教程将展示如何构建一个带有用户表单的 Flask 应用,将数据存储在 SQLite 中,并使用 Selenium 和 unittest 进行测试。
简介
Flask 是一个轻量级的 Python Web 框架。在这里,我们创建一个简单的应用,通过表单收集用户数据——名、姓、职业和薪水——将其保存到数据库,并通过自动化测试验证其功能。
项目结构
user_form_app/
├── requirements.txt # Dependencies
├── run.py # App entry point
├── holy_grail_app/
│ ├── config.py # Configuration
│ ├── db.py # Database setup
│ ├── forms.py # Form definition
│ ├── models.py # Database model
│ ├── routes.py # Routes
│ ├── __init__.py # App factory
│ ├── static/
│ │ └── style.css # CSS styling
│ └── templates/
│ └── index.html # Form template
├── instance/
│ └── users.db # SQLite database
└── tests/
└── test_app.py # Selenium tests
该项目结构旨在保持应用程序的模块化和组织性。根目录包含用于依赖项的 requirements.txt 和启动应用的 run.py。在 holy_grail_app/ 目录下,config.py、db.py 和 routes.py 等核心文件分别处理设置、数据库和路由逻辑。
static/ 文件夹包含用于样式设置的 style.css,而 templates/ 包含用于表单 UI 的 index.html。instance/ 文件夹存储 SQLite 数据库 (users.db),而 tests/ 包含用于使用 Selenium 进行自动化测试的 test_app.py。
Flask 应用设置
from flask import Flask
import os
def create_app(app_config=None):
app = Flask(__name__, instance_relative_config=True)
try:
os.makedirs(app.instance_path)
except OSError:
pass
if app_config is None:
env_config = os.getenv("FLASK_ENV", "development")
if env_config == "testing":
app.config.from_object("holy_grail_app.config.TestingConfig")
else:
app.config.from_object("holy_grail_app.config.DevelopmentConfig")
elif isinstance(app_config, dict):
app.config.from_mapping(app_config)
else:
app.config.from_object(app_config)
app.config.from_pyfile("config.py", silent=True)
from . import db
db.init_app(app)
from . import routes
app.register_blueprint(routes.bp)
return app
此文件定义了应用程序工厂函数 create_app,该函数初始化 Flask 应用。instance_relative_config=True 参数指示 Flask 在 instance/ 文件夹(users.db 所在位置)中查找配置文件。os.makedirs 调用确保此文件夹存在,如果已存在则静默跳过。
配置是动态加载的:如果没有提供 app_config,它会检查 FLASK_ENV 环境变量(默认为“development”),并选择 DevelopmentConfig 或 TestingConfig。它还可以接受一个字典或配置对象。数据库通过 db.init_app 初始化,并且使用来自 routes.py 的 Blueprint 注册路由。
配置
class Config:
SECRET_KEY = 'your_default_secret_key'
SQLALCHEMY_TRACK_MODIFICATIONS = False
class DevelopmentConfig(Config):
DEBUG = True
DATABASE = 'users.db'
SQLALCHEMY_DATABASE_URI = 'sqlite:///users.db'
class TestingConfig(Config):
TESTING = True
DATABASE = 'test_users.db'
SQLALCHEMY_DATABASE_URI = 'sqlite:///test_users.db'
WTF_CSRF_ENABLED = False
Config 基类设置了用于安全性的 SECRET_KEY(在生产环境中应是唯一的),并禁用 SQLALCHEMY_TRACK_MODIFICATIONS 以优化性能。DevelopmentConfig 继承了这些设置,启用了用于开发功能(如自动重新加载)的 DEBUG,并通过 SQLALCHEMY_DATABASE_URI 指向 users.db。
TestingConfig 是为测试量身定制的,将 TESTING 设置为 True,使用单独的 test_users.db 来隔离测试数据,并禁用 WTF_CSRF_ENABLED 以简化测试中的表单提交。这些设置由 create_app 根据环境加载。
数据库设置
此模块将 SQLite 与 Flask-SQLAlchemy 集成。
import sqlite3
import click
from flask import current_app, g
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text
db = SQLAlchemy()
def get_db():
if 'db' not in g:
g.db = sqlite3.connect(
current_app.config['DATABASE'],
detect_types=sqlite3.PARSE_DECLTYPES
)
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
db = g.pop('db', None)
if db is not None:
db.close()
def init_db():
db_sql = db.get_engine()
with db_sql.connect() as conn:
conn.execute(text('DROP TABLE IF EXISTS users'))
conn.execute(text(
'CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, '
'last_name TEXT, occupation TEXT, salary INTEGER)'))
conn.commit()
def init_app(app):
app.teardown_appcontext(close_db)
app.cli.add_command(init_db_command)
db.init_app(app)
@click.command('init-db')
def init_db_command():
init_db()
click.echo('Initialized the database.')
db = SQLAlchemy() 行创建了一个在整个应用中使用的 ORM 实例。get_db 管理 SQLite 连接,将其存储在 g(Flask 的请求上下文)中以避免在请求期间重新打开,并设置 row_factory 以实现类似字典的行访问。close_db 确保请求结束后连接关闭。
init_db 删除并重新创建 users 表,该表包含 id、first_name、last_name、occupation 和 salary 列,与应用程序的数据模型匹配。init_app 将此设置与 Flask 应用关联,通过 click 添加一个 teardown 函数和一个 CLI 命令(flask init-db)以手动初始化数据库。
用户模型
User 类使用 Flask-SQLAlchemy 定义数据库模型,映射到 db.py 中创建的 users 表。
from .db import db
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
first_name = db.Column(db.String(50), nullable=False)
last_name = db.Column(db.String(50), nullable=False)
occupation = db.Column(db.String(100), nullable=False)
salary = db.Column(db.Integer, nullable=False)
id 列是自增整数主键,而 first_name 和 last_name 是限制为 50 个字符的字符串,occupation 限制为 100 个字符,salary 是整数——所有这些都标记为 nullable=False 以要求输入值。
此模型直接对应于表单字段和数据库模式,确保通过表单提交的数据能够一致地存储和查询。它在 routes.py 中用于创建和检索用户记录。
用户表单
UserForm 利用 Flask-WTF 和 WTForms 来定义用户输入表单。每个字段——first_name、last_name、occupation 和 salary——都与 User 模型中的列相对应。StringField 处理文本输入,而 IntegerField 确保 salary 是数字。SubmitField 创建提交按钮。
from flask_wtf import FlaskForm
from wtforms import StringField, IntegerField, SubmitField
from wtforms.validators import DataRequired, Length
class UserForm(FlaskForm):
first_name = StringField('First Name', validators=[
DataRequired(), Length(max=50)])
last_name = StringField('Last Name', validators=[
DataRequired(), Length(max=50)])
occupation = StringField('Occupation', validators=[
DataRequired(), Length(max=100)])
salary = IntegerField('Salary', validators=[DataRequired()])
submit = SubmitField('Submit')
像 DataRequired() 这样的验证器确保字段不能为空,而 Length(max=...) 强制执行与模型相同的字符限制(名称为 50,职业为 100)。此表单在 index.html 中渲染,并在 routes.py 中进行验证,然后保存数据。
HTML 模板
此 HTML 模板渲染了 forms.py 中定义的 UserForm。
<!DOCTYPE html>
<html>
<head>
<title>User Form</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="form-container">
<form method="POST" class="pure-form">
<h1>User Form</h1>
{{ form.hidden_tag() }}
<div>
{{ form.first_name.label }}
{{ form.first_name(size=20) }}
{% if form.first_name.errors %}
<ul class="errors">
{% for error in form.first_name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div>
{{ form.last_name.label }}
{{ form.last_name(size=20) }}
{% if form.last_name.errors %}
<ul class="errors">
{% for error in form.last_name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div>
{{ form.occupation.label }}
{{ form.occupation(size=20) }}
{% if form.occupation.errors %}
<ul class="errors">
{% for error in form.occupation.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div>
{{ form.salary.label }}
{{ form.salary(size=20) }}
{% if form.salary.errors %}
<ul class="errors">
{% for error in form.salary.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{{ form.submit() }}
</form>
</div>
</body>
</html>
<link> 标签使用 Flask 的 url_for 从 static/ 文件夹加载 style.css。表单包装在 <div class="form-container"> 中用于居中,而 <form> 标签使用 method="POST" 将数据提交到 / 路由。
Jinja2 语法集成了表单字段(form.first_name 等),显示标签和输入字段,并带有 size=20 属性来控制宽度。hidden_tag() 添加了 CSRF 令牌以确保安全。错误处理使用条件语句在 <ul class="errors"> 中显示验证错误,该错误由 CSS 渲染为红色,确保用户看到无效输入的反馈。
CSS 样式
CSS 样式化了表单,使其外观简洁、用户友好。
.errors {
color: red;
list-style-type: none;
padding: 0;
}
.form-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
background-color: #f5f5f5;
margin: 0;
}
.pure-form {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
width: 300px;
}
.pure-form label {
margin: 0 0 5px 0;
display: block;
font-weight: bold;
}
.pure-form input {
margin-bottom: 10px;
padding: 5px;
width: 100%;
border: 1px solid #ccc;
border-radius: 4px;
}
.pure-form button {
padding: 10px 15px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
width: 100%;
}
.pure-form button:hover {
background-color: #45a049;
}
.errors 类将验证消息格式化为红色,并删除列表的项目符号以简化显示。.form-container 使用 Flexbox 将表单水平和垂直居中,填充视口(100vh、100vw),并带有浅灰色背景。
.pure-form 使用白色背景、内边距、圆角和微妙的阴影来样式化表单本身,并将其宽度固定为 300px。标签为粗体且为块级元素,输入字段占据整个宽度并带有浅边框,按钮为绿色并带有悬停效果,从而提高了可用性和视觉吸引力。
路由
此模块使用一个名为 main 的 Blueprint 来定义应用程序的路由。
from flask import Blueprint, render_template, redirect, url_for
from .models import User
from .forms import UserForm
from .db import db
bp = Blueprint('main', __name__)
@bp.route('/', methods=['GET', 'POST'])
def index():
form = UserForm()
if form.validate_on_submit():
user = User(
first_name=form.first_name.data,
last_name=form.last_name.data,
occupation=form.occupation.data,
salary=form.salary.data
)
db.session.add(user)
db.session.commit()
return redirect(url_for('main.success', user_id=user.id))
return render_template('index.html', form=form)
@bp.route('/success/<int:user_id>')
def success(user_id):
user = User.query.get_or_404(user_id)
return f'User {user.first_name} {user.last_name} added successfully!'
/ 路由同时处理 GET(显示表单)和 POST(提交表单)。对于 GET 请求,它创建一个 UserForm 实例并渲染 index.html。对于有效的 POST 请求,它根据表单数据构建一个 User 对象,将其添加到数据库,然后重定向到 success 路由。
/success/<int:user_id> 路由接受一个用户 ID,检索相应的 User 记录(如果找不到则返回 404),并显示一个简单的成功消息。此逻辑将表单、模型和数据库连接在一起,完成了应用程序的工作流程。
Selenium 测试
此测试文件使用 unittest 和 Selenium 来验证应用程序的功能。
# tests/test_app.py
import unittest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import time
import threading
import os
from holy_grail_app.db import db
from holy_grail_app.models import User
from holy_grail_app import create_app
from holy_grail_app.config import TestingConfig
class TestUserForm(unittest.TestCase):
def setUp(self):
self.app = create_app(TestingConfig)
self.client = self.app.test_client()
# Initialize database
with self.app.app_context():
from holy_grail_app.db import init_db
init_db()
# Start Flask server in a thread
self.server_thread = threading.Thread(
target=self.app.run, kwargs={'port': 5000})
self.server_thread.daemon = True
self.server_thread.start()
time.sleep(1) # Wait for server to start
# Set up Selenium
self.driver = webdriver.Chrome(
service=Service(ChromeDriverManager().install()))
self.driver.get('https://:5000')
time.sleep(1) # Wait for page to load
def tearDown(self):
self.driver.quit()
with self.app.app_context():
# Get the absolute path to the database file
db_path = os.path.abspath(
os.path.join(self.app.instance_path,
self.app.config['DATABASE'])
)
print(f"Absolute database path: {db_path}") # Debugging output
try:
db.engine.dispose() # Dispose of database connections
if os.path.exists(db_path):
os.remove(db_path)
print(f"Database file '{db_path}' removed successfully.")
else:
print(f"Database file '{db_path}' does not exist.")
except Exception as e:
print(f"Error during cleanup: {e}")
def test_index_get(self):
"""Test the index page loads with all form fields."""
driver = self.driver
# Wait for form to load
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, "pure-form"))
)
# Check form elements
self.assertIn("User Form", driver.page_source)
self.assertTrue(driver.find_element(By.ID, "first_name"))
self.assertTrue(driver.find_element(By.ID, "last_name"))
self.assertTrue(driver.find_element(By.ID, "occupation"))
self.assertTrue(driver.find_element(By.ID, "salary"))
self.assertTrue(driver.find_element(By.ID, "submit"))
def test_user_model(self):
"""Test creating and querying a User in the database."""
with self.app.app_context():
user = User(first_name="Alice", last_name="Johnson",
occupation="Designer", salary=75000.0)
db.session.add(user)
db.session.commit()
queried_user = User.query.filter_by(first_name="Alice").first()
self.assertIsNotNone(queried_user)
self.assertEqual(queried_user.last_name, "Johnson")
self.assertEqual(queried_user.occupation, "Designer")
self.assertEqual(queried_user.salary, 75000.0)
def test_form_submission_valid(self):
"""Test submitting valid form data redirects to success."""
driver = self.driver
# driver.get('https://:5001/')
# Wait for form to be interactive
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.ID, "submit"))
)
# Fill form
driver.find_element(By.ID, "first_name").send_keys("Jane")
driver.find_element(By.ID, "last_name").send_keys("Smith")
driver.find_element(By.ID, "occupation").send_keys("Developer")
driver.find_element(By.ID, "salary").send_keys("60000")
# Submit form
driver.find_element(By.ID, "submit").click()
# Wait for redirect
WebDriverWait(driver, 10).until(
EC.text_to_be_present_in_element((By.TAG_NAME, "body"), "Jane Smith")
)
# Verify success page
self.assertIn("User Jane Smith added successfully!", driver.page_source)
# Verify database
with self.app.app_context():
user = User.query.filter_by(first_name="Jane").first()
self.assertIsNotNone(user)
self.assertEqual(user.last_name, "Smith")
self.assertEqual(user.occupation, "Developer")
self.assertEqual(user.salary, 60000.0)
def test_form_submission_missing_field(self):
"""Test submitting with a missing field shows validation error."""
driver = self.driver
# Wait for form to be interactive
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, "pure-form"))
)
# Remove 'required' attributes to bypass client-side validation
driver.execute_script("""
document.querySelectorAll('input[required]').forEach(input => {
input.removeAttribute('required');
});
""")
# Fill partial form (missing first_name)
driver.find_element(By.ID, "last_name").send_keys("Smith")
driver.find_element(By.ID, "occupation").send_keys("Developer")
driver.find_element(By.ID, "salary").send_keys("60000")
# Submit form
driver.find_element(By.ID, "submit").click()
# Wait for error message
WebDriverWait(driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, "//ul[@class='errors']/li"))
)
self.assertIn("This field is required", driver.page_source)
self.assertIn("User Form", driver.page_source)
def test_form_submission_invalid_salary(self):
"""Test submitting a non-numeric salary shows an error."""
driver = self.driver
# Wait for form to be interactive
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, "pure-form"))
)
# Remove 'required' attributes to bypass client-side validation
driver.execute_script("""
document.querySelectorAll('input[required]').forEach(input => {
input.removeAttribute('required');
});
""")
# Fill form with invalid salary
driver.find_element(By.ID, "first_name").send_keys("Jane")
driver.find_element(By.ID, "last_name").send_keys("Smith")
driver.find_element(By.ID, "occupation").send_keys("Developer")
driver.find_element(By.ID, "salary").send_keys("invalid")
# Submit form
driver.find_element(By.ID, "submit").click()
# Wait for error message
WebDriverWait(driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, "//ul[@class='errors']/li"))
)
self.assertIn("This field is required.", driver.page_source)
self.assertIn("User Form", driver.page_source)
def test_form_submission_length_exceeded(self):
"""Test submitting a too-long field shows an error."""
driver = self.driver
# Wait for form to be interactive
WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.CLASS_NAME, "pure-form"))
)
# Remove 'required' attributes to bypass client-side validation
driver.execute_script("""
document.querySelectorAll('input[required]').forEach(input => {
input.removeAttribute('required');
});
""")
# Fill form with oversized first_name
driver.execute_script(
"document.getElementById('first_name').value = 'a'.repeat(51);"
)
driver.find_element(By.ID, "last_name").send_keys("Smith")
driver.find_element(By.ID, "occupation").send_keys("Developer")
driver.find_element(By.ID, "salary").send_keys("60000")
# Submit form
driver.find_element(By.ID, "submit").click()
# Wait for error message
WebDriverWait(driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, "//ul[@class='errors']/li"))
)
self.assertIn("Field cannot be longer than 50 characters",
driver.page_source)
self.assertIn("User Form", driver.page_source)
if __name__ == '__main__':
unittest.main()
setUp 方法通过创建带有测试配置的 Flask 应用程序、初始化数据库、在单独的线程中启动 Flask 服务器以及启动 Chrome WebDriver 实例来与应用程序进行交互,从而初始化测试环境。这确保了每个测试都有一个全新且功能齐全的环境。
tearDown 方法负责在每次测试后清理测试环境。它会关闭 WebDriver、关闭活动数据库连接、处置数据库引擎,并尝试删除测试数据库文件,以在测试之间保持隔离并避免遗留资源。
test_index_get 方法验证索引页面是否正确加载并包含所有必需的表单字段。它检查表单是否存在,并确保所有预期的元素(如输入字段和提交按钮)都在页面源代码中可用。
test_user_model 方法通过将用户添加到数据库然后查询它来测试 User 模型的功能。它确认用户已成功添加并且所有属性都与预期值匹配,从而验证了模型的行为。
test_form_submission_valid 方法测试提交有效表单的完整工作流程。它用有效数据填写所有必填字段,提交表单,并验证用户是否被重定向到成功页面。它还确保提交的数据已正确存储在数据库中。
test_form_submission_missing_field 方法评估当缺少必填字段时表单的验证机制。它提交一个未填写 first_name 字段的表单,并验证页面上是否显示了适当的验证错误消息。
test_form_submission_invalid_salary 方法检查表单如何处理薪水字段中的无效数据。它提交一个薪水值为非数字的表单,并验证应用程序是否显示了适当的错误消息以指导用户。
test_form_submission_length_exceeded 方法确保表单正确验证字段长度。它提交一个 first_name 字段值过长的表单,并验证是否正确显示了指示最大长度限制的错误消息。
运行应用和测试
$ pip install -r requirements.txt $ flask init-db $ python run.py
运行测试
$ python -m unittest tests/test_app.py -v
首先,使用 pip 安装 requirements.txt 中列出的依赖项。然后,运行 flask init-db 来设置 users.db 数据库。最后,使用 python run.py 启动应用程序以启动开发服务器。对于测试,使用带 -v 标志(用于详细输出)的 unittest,确保 Selenium 测试针对应用程序运行。
作者
列出 所有 Python 教程。