いるもん
pip install pytest
p/ に移動し PYTHONPATH=app pytest app/tests
# [ルートディレクトリで]テスト実行
pytest -s
テスト実行されなかったら
pytest -v
ディレクトリ構造
tests/
└── conftest.py
└── test_auth.py
app
├──init__.py
p
├──pytest.ini
pytest.ini中身
[pytest]
pythonpath = .
inite_py中身
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_login import LoginManager
# 拡張機能の初期化(アプリとはまだ紐づけない)
db = SQLAlchemy()
bcrypt = Bcrypt()
login_manager = LoginManager()
def create_app():
app = Flask(__name__)
# 設定
app.config['SECRET_KEY'] = 'your-secret-key' # フォームなどで必要
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///test.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# 拡張機能とアプリを紐づけ
db.init_app(app)
bcrypt.init_app(app)
login_manager.init_app(app)
# Blueprint やルーティングを後から登録するならここ
# from .routes import main as main_blueprint
# app.register_blueprint(main_blueprint)
return app
テストユーザーを作っておき、作ったtestユーザーは@pytest.fixtureで呼び出す
@pytest.fixture
def test_user():
pytest.ini
[pytest]
testpaths = tests
python_files = conftest.py, test_auth.py
addopts = --maxfail=1 --disable-warnings -q
conftest.py(お決まり文 テンプレ)
import pytest
from app import app, db, User
from app import bcrypt
bcrypt = Bcrypt(app)
import pytest
from app import app, db、User
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt()
@pytest.fixture(scope='session')
def app():
app.config['TESTING'] = True
app.config['WTF_CSRF_ENABLED'] = False
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture(scope='function')
def client(app):
return app.test_client()
@pytest.fixture(scope='function')
def db_session(app):
# トランザクション制御用
with app.app_context():
db.session.begin(subtransactions=True)
yield db.session
db.session.rollback()
@pytest.fixture
def create_user(db_session):
def _create_user(username, email, raw_password):
hashed_pw = bcrypt.generate_password_hash(raw_password).decode('utf-8')
user = User(username=username, email=email, password=hashed_pw)
db_session.add(user)
db_session.commit()
return user
return _create_user
[ログインさせるテスト] "test_auth.py"
def test_login_success(client, init_db):
create_user('testuser', 'test@example.com', 'testpassword')
login_data = {
'username': 'testuser',
'password': 'testpassword'
}
# ログインフォームにデータを送信
response = client.post('/login', data=login_data)
# レスポンスがリダイレクトされることを確認(成功時の挙動)
assert response.status_code == 302
assert '/mypage' in response.location
ログインさせたら、文章表示
response = client.post('/login', data={
'username': 'testuser',
'password': 'testpassword'
}, follow_redirects=True)
# 成功したか確認(リダイレクトやメッセージ)
assert 'ログイン成功' in response.data.decode('utf-8')
テスト考え方
def test_login_success(client, init_db):
# なんのデータを?
入れたいデータの配列名
= 入れたいデータ変数名 + 入れたいデータ
# どこのURLに?
response = client.post('/行きたいURL', data=入れたいデータの配列名)
# 処理の結果、どこに行きたい?
assert response.status_code == 302
assert '/行きたいページ' in response.location
リダイレクトしたい
response = client.post('/login', data={
なんちゃらなんちゃら,
}, follow_redirects=True)
新規登録成功 → ログインページへ
def test_register_success(client, db):
response = client.post('/register', data={
'username': 'newuser',
'email': 'new@example.com',
'password': 'password',
'confirm': 'password'
}, follow_redirects=True)
assert 'ログインしてください' in response.data.decode('utf-8')
新規登録失敗
def test_register_fail_password_mismatch(client, db):
response = client.post('/register', data={
'username': 'user',
'email': 'bad@example.com',
'password': 'pass1',
'confirm': 'pass2'
}, follow_redirects=True)
assert 'パスワードが一致しません' in response.data.decode('utf-8')
ログイン成功 → mypageへ
def test_login_success_redirect_to_mypage(client, db, create_user):
create_user('testuser', 'test@example.com', 'testpassword')
response = client.post('/login', data={
'username': 'testuser',
'password': 'testpassword'
}, follow_redirects=True)
assert 'ようこそ' in response.data.decode('utf-8') # mypageに「ようこそ」などが書かれている前提
ログイン失敗 [パスワエラー]
def test_login_fail_wrong_password(client, db, create_user):
create_user('testuser', 'test@example.com', 'correctpass')
response = client.post('/login', data={
'username': 'testuser',
'password': 'wrongpass'
}, follow_redirects=True)
assert 'ログインに失敗しました' in response.data.decode('utf-8')
大規模アプリ
アプリが大規模になったら、pytest.iniファイル作成
p/
└──pytest.ini
データ編集
from pathlib import Path
# get the resources folder in the tests folder
resources = Path(__file__).parent / "resources"
def test_edit_user(client):
response = client.post("/user/2/edit", data={
"name": "Flask",
"theme": "dark",
"picture": (resources / "picture.png").open("rb"),
})
assert response.status_code == 200
ログアウトリンクに行ったら、リダイレクトさせる
assert len(response.history) == 1
# Check that the second request was to the index page.
assert response.request.path == "/index"
文法間違い
SyntaxError: unterminated string literal (detected at line 13)
# 内容 assert response.location == ''http://127.0.0.1:5000/mypage'
SyntaxError: bytes can only contain ASCII literal characters
# 修正前
assert b'ログインに失敗しました' in response.data
# 修正後
assert 'ログインに失敗しました' in response.data.decode('utf-8')
初期設定エラー
tests/conftest.py:10: AttributeError
_ ERROR at setup of test_register_fail_password_mismatch _
@pytest.fixture()
def app():
> app.config['TESTING'] = True
E AttributeError: 'function' object has no attribute 'config'
# 解決策
1.[conftest.pyにこれ追記]
from app.app import create_app
2.[app.pyにこれ追記]
from app import create_app
3.conftest.py app関数 変更
flask_app = create_app()
app.config['TESTING'] = Trueを全部、
flask_appにする
init.pyに初期設定 + app.pyはこう書く
[app.py]
from flask import Blueprint,
bcrypt = Bcrypt()
# Blueprint の作成
main = Blueprint('main', __name__)
[init.py]
# __init__.py の create_app 関数内に追加
from app.app import main # Blueprint を import
app.register_blueprint(main)
ログイン機能全部
from app import db, bcrypt, login_manager
from flask import Flask, render_template, redirect, url_for, flash, request, send_from_directory, Blueprint
from flask_sqlalchemy import SQLAlchemy
from flask_mail import Mail, Message
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField, BooleanField
from wtforms.validators import InputRequired, Length, Email, EqualTo, ValidationError, Optional
from flask_login import login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt
from itsdangerous import URLSafeTimedSerializer, SignatureExpired
from pathlib import Path
from werkzeug.utils import secure_filename
main = Blueprint('main', __name__)
# ユーザーモデル
class User(db.Model, UserMixin):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), nullable=False, unique=True)
email = db.Column(db.String(120), unique=True, nullable=True)
password = db.Column(db.String(150), nullable=False)
email_verified = db.Column(db.Boolean, default=False)
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
class RegisterForm(FlaskForm):
username = StringField('ユーザー名', validators=[
InputRequired(message="ユーザー名は必須です。"),
Length(min=4, max=150, message="ユーザー名は4文字以上150文字以下にしてください。")
])
email = StringField('Email', validators=[Optional(), Email()])
password = PasswordField('パスワード', validators=[
InputRequired(message="パスワードは必須です。"),
Length(min=4, max=150)
])
submit = SubmitField('登録')
def validate_username(self, username):
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('このユーザー名は既に使用されています。')
def validate_email(self, email):
if not email.data:
return # メールが空なら重複チェックしない
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('このメールアドレスは既に登録されています')
class LoginForm(FlaskForm):
username = StringField('ユーザー名', validators=[
InputRequired(message="ユーザー名は必須です。")
])
password = PasswordField('パスワード', validators=[
InputRequired(message="パスワードは必須です。")
])
email = StringField('ユーザー名', validators=[
InputRequired(message="ユーザー名は必須です。")
])
remember = BooleanField('ログイン状態を保持する')
submit = SubmitField('ログイン')
class PasswordResetRequestForm(FlaskForm):
email = StringField('メールアドレス', validators=[
InputRequired(message="メールアドレスは必須です。"),
Email()
])
submit = SubmitField('パスワードリセットリンク送信')
# ログイン
@app.route('/login', methods=['GET', 'POST'])
def login():
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user and bcrypt.check_password_hash(user.password, form.password.data):
login_user(user, remember=form.remember.data)
return redirect(url_for('mypage'))
else:
flash('ユーザー名 / パスワ違ってるよん。', 'error')
return render_template('login.html', form=form)
# マイページ
@app.route("/mypage")
@login_required
def mypage():
print(f"ようこそ、{current_user.username}さん!")
return render_template('mypage.html', username=current_user.username, images=[p.relative_to(BASE_DIR) for p in list(UPLOAD_FOLDER.glob('*.*'))])
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def upload():
if request.method == 'POST':
if 'file' not in request.files:
# ファイルが選択されていない場合
flash('ファイルがありません')
return redirect(url_for('mypage'))
file = request.files['image']
# 画像として読み込み
if file.filename == "":
# ファイル名がついていない場合
flash("ファイル not found")
return redirect(url_for('mypage'))
if file and allowed_file(img.filename):
filename = secure_filename(file.filename)
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path) # 保存処理
return redirect(url_for('uploaded_file', filename=filename))
# GET時はアップロードフォームを表示
return render_template('mypage.html')
@app.route('/mypage/<filename>')
@login_required
def uploaded_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
# ログアウト
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
# 新規登録
@app.route('/register', methods=['GET', 'POST'])
def register():
form = RegisterForm()
if form.validate_on_submit():
hashed_pw = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
# メールアドレスが入力されていたら「メール認証あり」
if form.email.data:
user = User(username=form.username.data, email=form.email.data, password=hashed_pw)
db.session.add(user)
db.session.commit()
s = URLSafeTimedSerializer(app.config['SECRET_KEY'])
token = os.dumps(user.email, salt='email-confirm')
link = url_for('confirm_email', token=token, _external=True)
print(link)
msg = Message('メール認証を完了してください', sender='あなたのメールアドレス', recipients=[user.email])
msg.body = f'こちらのリンクをクリックして認証を完了してください): {link}'
mail.send(msg)
flash('認証メールを送りました。メールを確認してください!', 'success')
# メールアドレスなし → 通常登録
else:
new_user = User(username=form.username.data, password=hashed_pw)
db.session.add(new_user)
db.session.commit()
flash('登録が完了しました!ログインしてください。', 'success')
return redirect(url_for('register_complete'))
return render_template('register.html', form=form)
@app.route('/confirm_email/<token>')
def confirm_email(token):
if user.email_verified:
flash('すでに認証済みです。', 'info')
else:
user.email_verified = True
db.session.commit()
flash('メール認証が完了しました!', 'success')
return redirect(url_for('login'))
@app.route('/register_complete')
def register_complete():
return render_template('register_complete.html')
@app.route('/reset_password', methods=['GET', 'POST'])
def reset_request():
form = PasswordResetRequestForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
token = s.dumps(user.email, salt='password-reset')
link = url_for('reset_token', token=token, _external=True)
msg = Message('パスワードリセット', sender='あなたのメールアドレス', recipients=[user.email])
msg.body = f'パスワードリセットはこちら: {link}'
mail.send(msg)
flash('リセットリンクを送信しました。メールを確認してください。', 'success')
return redirect(url_for('login'))
return render_template('reset_request.html', form=form)
@app.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_token(token):
user = User.query.filter_by(email=email).first_or_404()
form = RegisterForm()
if form.validate_on_submit():
hashed_pw = bcrypt.generate_password_hash(form.password.data).decode('utf-8')
user.password = hashed_pw
db.session.commit()
flash('パスワードをリセットしました。', 'success')
return redirect(url_for('login'))
[Loginform]
class LoginForm(FlaskForm):
username = StringField('ユーザー名', validators=[
InputRequired(message="ユーザー名は必須です。")
])
password = PasswordField('パスワード', validators=[
InputRequired(message="パスワードは必須です。")
])
remember = BooleanField('ログイン状態を保持する')
submit = SubmitField('ログイン')