ZetCode

测试提交表单

最后修改于 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.pydb.pyroutes.py 等核心文件分别处理设置、数据库和路由逻辑。

static/ 文件夹包含用于样式设置的 style.css,而 templates/ 包含用于表单 UI 的 index.htmlinstance/ 文件夹存储 SQLite 数据库 (users.db),而 tests/ 包含用于使用 Selenium 进行自动化测试的 test_app.py

Flask 应用设置

holy_grail_app/__init__.py
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”),并选择 DevelopmentConfigTestingConfig。它还可以接受一个字典或配置对象。数据库通过 db.init_app 初始化,并且使用来自 routes.py 的 Blueprint 注册路由。

配置

holy_grail_app/config.py
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 集成。

holy_grail_app/db.py
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 表,该表包含 idfirst_namelast_nameoccupationsalary 列,与应用程序的数据模型匹配。init_app 将此设置与 Flask 应用关联,通过 click 添加一个 teardown 函数和一个 CLI 命令(flask init-db)以手动初始化数据库。

用户模型

User 类使用 Flask-SQLAlchemy 定义数据库模型,映射到 db.py 中创建的 users 表。

holy_grail_app/models.py
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_namelast_name 是限制为 50 个字符的字符串,occupation 限制为 100 个字符,salary 是整数——所有这些都标记为 nullable=False 以要求输入值。

此模型直接对应于表单字段和数据库模式,确保通过表单提交的数据能够一致地存储和查询。它在 routes.py 中用于创建和检索用户记录。

用户表单

UserForm 利用 Flask-WTF 和 WTForms 来定义用户输入表单。每个字段——first_namelast_nameoccupationsalary——都与 User 模型中的列相对应。StringField 处理文本输入,而 IntegerField 确保 salary 是数字。SubmitField 创建提交按钮。

holy_grail_app/forms.py
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

templates/index.html
<!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_forstatic/ 文件夹加载 style.css。表单包装在 <div class="form-container"> 中用于居中,而 <form> 标签使用 method="POST" 将数据提交到 / 路由。

Jinja2 语法集成了表单字段(form.first_name 等),显示标签和输入字段,并带有 size=20 属性来控制宽度。hidden_tag() 添加了 CSRF 令牌以确保安全。错误处理使用条件语句在 <ul class="errors"> 中显示验证错误,该错误由 CSS 渲染为红色,确保用户看到无效输入的反馈。

CSS 样式

CSS 样式化了表单,使其外观简洁、用户友好。

static/style.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 将表单水平和垂直居中,填充视口(100vh100vw),并带有浅灰色背景。

.pure-form 使用白色背景、内边距、圆角和微妙的阴影来样式化表单本身,并将其宽度固定为 300px。标签为粗体且为块级元素,输入字段占据整个宽度并带有浅边框,按钮为绿色并带有悬停效果,从而提高了可用性和视觉吸引力。

路由

此模块使用一个名为 mainBlueprint 来定义应用程序的路由。

holy_grail_app/routes.py
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
# 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 测试针对应用程序运行。

作者

我叫 Jan Bodnar,我是一名充满热情的程序员,拥有多年的丰富经验。自 2007 年以来,我撰写了超过 1400 篇编程文章和 8 本电子书。此外,我还在教授编程概念方面拥有超过八年的经验。

列出 所有 Python 教程