ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Flask Project - MyBlog auth(회원가입, 로그인, 로그아웃) with unittest
    프로젝트 2024. 4. 9. 19:10
    반응형

    지금까지 flask-sqlalchemy를 사용해 DB 스키마 생성 후, admin 페이지를 통해 User 모델을 등록하고 관리해보았습니다.

     

    본격적으로 웹 페이지 개발을 시작하기 전에, admin 페이지에 접근 가능한 관리자용 계정 생성을 해보겠습니다.

     

    플라스크 애플리케이션에서 여러 기능을 테스트해보거나, DB에 접근하는 등의 작업을 할 때 가장 중요한 개념은 app_context입니다. Flask는 요청 처리 동안에만 유효한 current_app, current_user 같은 객체를 제공하는데, 이 객체에 접근하기 위해서는 애플리케이션 컨텍스트(app_context)가 필요합니다. 따라서 관리자용 계정 생성을 위해서는

    with app.app_context(): 를 사용하여 데이터베이스에 접근하는 코드를 감싸야합니다. 

     

    아래처럼 작성하면 flask create_user를 통해 간단하게 user를 생성할 수 있습니다. app에 등록해야 하기 때문에 팩토리 함수 내에서 정의해서 사용하시거나, 별도 모듈로 생성한 뒤에 app.cli에 등록해주시면 됩니다.

    # blog/__init__.py 
    from click import command   		# 커맨드 라인 인터페이스 작성
    from flask.cli import with_appcontext   # Flask 애플리케이션 컨텍스트
    from sqlite3 import IntegrityError 	# unique 제약조건 위배
    @command(name="create_user")
    @with_appcontext
    def create_user():
        username = input("Enter username : ")
        email = input("Enter email : ")
        password = input("Enter password : ")
        post_create_permission = input("Do you want post create permission? (y/n): ")
        admin_check = input("Do you want admin check? (y/n): ")
    
        post_create_permission = post_create_permission.lower() == "y"
        admin_check = admin_check.lower() == "y"
    
        try:
            admin_user = get_model('user')(
                username = username,
                email = email,
                password = password,
                post_create_permission = post_create_permission,
                admin_check = admin_check
            )
            db.session.add(admin_user)
            db.session.commit()
            print(f"User created!\n{admin_user.id}: {admin_user.username}")
        except IntegrityError:
            # 빨간색으로 표시
            print('\033[31m' + "Error : username or email already exists.")
            
    def create_app(config):
    	'''
        이전 코드 생략
        '''
        # app에 등록
        app.cli.add_command(create_user)

    본격적으로 회원가입, 로그인 페이지를 생성하고 필요한 기능들을 구현해보겠습니다.

     

    블로그 전체적으로 공통된 요소들을 상속하기 위해 base.html을 먼저 구성해보았습니다. 일전에 언급했다시피 부트스트랩의 clean-blog 템플릿 코드를 약간 수정하여 사용하겠습니다. (출처: https://startbootstrap.com/theme/clean-blog)

     

    1. base.html 작성

    모든 html파일을 templates(flask app 생성시 기본값) 폴더에 넣어두고, javascript, css, image 파일은 static 폴더에 넣은 뒤, 아래 코드에 맞춰서 폴더를 정리해주시면 됩니다.

    url_for('static', filename='assets/favicon.ico')
    url_for('static', filename='css/styles.css')
    url_for('static', filename='js/scripts.js')

     

    전체 코드는 아래와 같습니다. 필요하신분들은 복사해서 사용하세요.

    <!-- blog/templates/base.html -->
    <!DOCTYPE html>
    <html lang="ko">
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
            <meta name="description" content=""/>
            <meta name="author" content=""/>
    
            <!-- 각 페이지마다 변경 -->
            <title>{% block title %}{% endblock %}</title>
    
            <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='assets/favicon.ico') }}" />
            <!-- Font Awesome icons (free version)-->
            <script src="https://use.fontawesome.com/releases/v6.3.0/js/all.js" crossorigin="anonymous"></script>
            <!-- Google fonts-->
            <link href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic" rel="stylesheet" type="text/css" />
            <link href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800" rel="stylesheet" type="text/css" />
            <!-- Core theme CSS (includes Bootstrap)-->
            <link href="{{ url_for('static', filename='css/styles.css') }}" rel="stylesheet"/>
            <!-- Core theme JS-->
            <script src="{{ url_for('static', filename='js/scripts.js') }}"></script>
            <!-- Bootstrap core JS-->
            <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
        </head>
    
        <!-- Navigation 템플릿 코드 그대로 -->
        <body class='height: 100vh'>
            <nav class="navbar navbar-expand-lg navbar-light" id="mainNav">
                <div class="container px-4 px-lg-5">
                    <a class="navbar-brand" href="/">dolphin's journey</a>
                    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarResponsive"
                            aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation">
                        Menu
                        <i class="fas fa-bars"></i>
                    </button>
                    <div class="collapse navbar-collapse" id="navbarResponsive">
                        <ul class="navbar-nav ms-auto py-4 py-lg-0">
    
                            <!-- 로그인 상태 X일 때 -->
                            {% if not user.is_authenticated %}
                                <li class="nav-item"><a class="nav-link px-lg-3 py-3 py-lg-4" href="{{url_for('auth.login')}}">Login</a></li>
                                <li class="nav-item"><a class="nav-link px-lg-3 py-3 py-lg-4" href="{{url_for('auth.sign_up')}}">Sign Up</a></li>
                            {% else %}
                                <li class="nav-item"><a class="nav-link px-lg-3 py-3 py-lg-4" style="color:red" href="{{url_for('auth.logout')}}">Logout</a></li>
                            {% endif %} 
    
                        </ul>
                    </div>
                </div>
            </nav>
    
            <!-- 각 페이지마다 변경 -->
            {% block header %}{% endblock %}
            
            <!-- 각 페이지마다 변경 -->
            <div class="content-wrapper">
                {% block content %}{% endblock %}
            </div>
    
            <!-- Footer 템플릿 코드 그대로 -->
            <footer class="border-top">
                <div class="container px-4 px-lg-5">
                    <div class="row gx-4 gx-lg-5 justify-content-center">
                        <div class="col-md-10 col-lg-8 col-xl-7">
                            <ul class="list-inline text-center">
                                <li class="list-inline-item">
                                    <!-- 트위터 링크 -->
                                    <a href="#!">
                                        <span class="fa-stack fa-lg">
                                            <i class="fas fa-circle fa-stack-2x"></i>
                                            <i class="fab fa-twitter fa-stack-1x fa-inverse"></i>
                                        </span>
                                    </a>
                                </li>
                                <li class="list-inline-item">
                                    <!-- 페이스북 링크 -->
                                    <a href="#!">
                                        <span class="fa-stack fa-lg">
                                            <i class="fas fa-circle fa-stack-2x"></i>
                                            <i class="fab fa-facebook-f fa-stack-1x fa-inverse"></i>
                                        </span>
                                    </a>
                                </li>
                                <li class="list-inline-item">
                                    <!-- github 링크 -->
                                    <a href="#!">
                                        <span class="fa-stack fa-lg">
                                            <i class="fas fa-circle fa-stack-2x"></i>
                                            <i class="fab fa-github fa-stack-1x fa-inverse"></i>
                                        </span>
                                    </a>
                                </li>
                            </ul>
                            <div class="small text-center text-muted fst-italic">Copyright &copy; dolphin's journey</div>
                        </div>
                    </div>
                </div>
            </footer>
        </body>
    </html>

     

    2. auth.html 작성

    이제 회원가입 + 로그인 페이지를 위해 auth.html을 작성해보겠습니다.

    Jinja2 템플릿 엔진에서는 변수를 {{ }} 로 감싸서 사용할 수 있고, {% %} 를 사용해 statement를 작성할 수 있습니다.

     

    block header에서는 부트스트랩 템플릿에서 받은 contact-bg.jpg를 배경 이미지로 선택했고 h2 태그에는 블로그 이름을 작성해보았습니다.

     

    block content에서는 Login 페이지와 Sign Up 페이지에서 공통적으로 사용할 수 있는 코드를 작성했습니다. 

    확장성을 고려했을 때, 모든 필드에 대해서 개별적인 스타일을 적용하지 않고, 일관된 스타일을 일괄적으로 적용함으로써 코드의 중복을 줄였습니다. 

    <!-- blog/templates/auth/auth.html -->
    {% extends "base.html" %}
    
    {% block title %} MyBlog - {{title_name}} {% endblock %}
    
    {% block header %}
    <header class="masthead" style="background-image: url('../static/assets/img/contact-bg.jpg')">
        <div class="container position-relative px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-7">
                    <div class="site-heading">
                        <h2>dolphin's journey</h2>
                    </div>
                </div>
            </div>
        </div>
    </header>
    {% endblock %}
    
    {% block content %}
    <main class="mb-4">
        <div class="container px-4 px-lg-5">
            <div class="row gx-4 gx-lg-5 justify-content-center">
                <div class="col-md-10 col-lg-8 col-xl-7">
                    <div class="my-5">
                        <form id="{{ title_name.replace(' ', '').lower() }}Form" method="POST">
                            {{form.csrf_token}}
                            {% for field in form if field.name != 'csrf_token' %}
                                <div class="form-floating">
                                    <!-- id = field이름 = field.id -->
                                    <!-- ~ = 문자열 연결 -->
                                    {{ field(class="form-control" ~ (' is-invalid' if field.errors else ''), required=True) }}
                                    {% for error in field.errors %}
                                        <span class="invalid-feedback">{{ error }}</span>
                                    {% endfor %}
                                    <label for="{{ field.id }}">{{ field.label }}</label>
                                </div>
                            {% endfor %}
                            <br />
                            <button class="btn btn-primary text-uppercase" id="{{ title_name.replace(' ', '').lower() }}Button" type="submit">{{title_name}}</button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </main>
    {% endblock %}
    # blog/forms.py
    class SignUpForm(FlaskForm):
        username = StringField('username', validators=[DataRequired(), Length(3,30)])                                
        email = EmailField('email', validators=[DataRequired(), Email()])                        
        password = PasswordField('password', validators=[DataRequired(), Length(6, 30), EqualTo("password_check", message="Password must match...")])
        password_check = PasswordField('password_check', validators=[DataRequired()])
        
    class LoginForm(FlaskForm):
        email = EmailField('email', validators=[DataRequired(), Email()])
        password = PasswordField('password', validators=[DataRequired(), Length(6, 30)])

    3. 엔드포인트 작성(with Blueprint)

    회원가입시 SignUpForm에서 제약조건 검사를 해주기 때문에, form의 validate_on_submit 메소드를 활용하면 간단하게 회원가입 기능을 구현할 수 있습니다. User 모델에서 username과 email은 unique 제약조건이 존재하므로 중복 체크만 해준 뒤 db에 추가해주면 됩니다. 

     

    로그인도 마찬가지로 LoginForm의 validate_on_submit 메소드를 활용한 뒤 db에서 email과 password를 확인해주면 됩니다. login_user를 통해 해당 유저를 세션에 저장해, 로그인 상태를 유지하도록 해줍니다. views.home은 서버측에서 app에 등록된 views의 home 엔드포인트로 라우팅하라는 뜻입니다. 아직 views.home은 작성하지 않았으니, base.html을 상속하는 index.html을 생성해준뒤 블루프린트 등록해주세요.

    # blog/auth.py
    from flask import Blueprint, request, redirect, render_template, flash, url_for
    from flask_login import current_user, login_required, login_user, logout_user
    from werkzeug.security import check_password_hash
    from .forms import LoginForm, SignUpForm
    from .models import db, get_model
    
    auth = Blueprint('auth', __name__)
    BASE_AUTH_DIR = 'auth/'
    
    @auth.route('/login', methods=['GET', 'POST'])
    def login():
        form = LoginForm()
        if request.method == 'POST' and form.validate_on_submit():
            user = get_model('user').query.filter_by(email=form.email.data).first()
            
            # 이메일로 유저 체크
            if not user:
                flash('가입되지 않은 이메일입니다.', category="error")
            # 비밀번호 체크
            elif check_password_hash(form.password.data, user.password):
                flash('비밀번호가 틀렸습니다.', category="error")
            else:
                flash('로그인 성공!', category="success")
                login_user(user, remember=True) # 로그인 처리, session에 저장
                return redirect(url_for('views.home')) # 홈으로 이동
    
        return render_template(BASE_AUTH_DIR + 'auth.html', form=form, user=current_user, title_name='Login')
    
    @auth.route('/logout')
    @login_required # 로그인 상태 체크
    def logout():
        logout_user() # session에서 삭제
        flash('로그아웃 성공!', category="success")
        return redirect(url_for('views.home')) # 홈으로 이동
    
    @auth.route('/sign-up', methods=['GET', 'POST'])
    def sign_up():
        form = SignUpForm()
        if request.method == 'POST' and form.validate_on_submit():
            user = get_model('user')( 
                username = form.username.data,
                email = form.email.data,
                password = form.password.data,
            )
            
            email_check = get_model('user').query.filter_by(email=user.email).first()
            username_check = get_model('user').query.filter_by(username=user.username).first()
    
            if email_check:
                flash('이미 존재하는 이메일입니다.', category="error")
            elif username_check:
                flash('이미 존재하는 이름입니다.', category="error")
            else:
                db.session.add(user)
                db.session.commit() # 변화 적용
                flash('회원가입 완료!', category="success")
                return redirect(url_for('views.home')) # 홈으로 이동
            
        return render_template(BASE_AUTH_DIR + 'auth.html', form=form, user=current_user, title_name="Sign Up")
    # blog/views.py
    from flask import Blueprint
    from flask_login import current_user
    
    views = Blueprint('views', __name__)
    BASE_VIEWS_DIR = 'views/'
    
    @views.route("/")
    @views.route("/home")
    def home():
        return render_template(BASE_VIEWS_DIR + "index.html", user=current_user)
        
    # blog/templates/views/index.html
    {% extends "base.html" %}

    4. 블루프린트 등록, 로그인 매니저 설정

    플라스크 app에 블루프린트 등록해준 뒤, login manager 활용해서 login_required 데코레이터 메소드를 오버라이드 해줍니다. 추가적으로 403, 404 오류 페이지도 처리해주고, favicon.ico 에러 처리도 해줍니다.

     

    웹 개발을 하다보면 종종 favicon.ico error message가 뜹니다. 큰 문제는 없는데, 콘솔에 계속 에러 메세지가 뜨면 불편하니, 서버측에서 에러 처리를 해줍니다. base.html에서 head 태그 내부에 icon의 href도 아래처럼 변경해주세요.

    <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='assets/favicon.ico') }}" />

    # blog/__init__.py
    from flask import Flask, redirect, url_for, flash
    from flask_login import LoginManager
    from flask_admin import Admin
    from blog.admin_models import get_all_admin_models
    from .models import db, migrate, get_model
    
    def create_app(config):
        '''
        플라스크의 팩토리 지정함수(app 객체 생성 함수)
        app 객체를 생성할 때 전역으로 접근하지 못하게 함 
        즉, 순환 참조 방지
        '''
        app = Flask(__name__)
        app.config.from_object(config) # 환경변수 설정 코드
        db.init_app(app)
        migrate.init_app(app, db)
    
        # admin 페이지에 모델뷰 추가
        admin = Admin(app, name='MyBlog', template_mode='bootstrap3')
        for admin_model, model in get_all_admin_models():
            admin.add_view(admin_model(model, db.session))
    
        # blueprint 등록 코드, url_prefix를 기본으로 함
        from .views import views
        app.register_blueprint(views)
        from .auth import auth
        app.register_blueprint(auth, url_prefix='/auth')
    
        # login_manager 설정 코드
        login_manager = LoginManager()
        login_manager.init_app(app) # app 연결
        login_manager.login_view = 'auth.login' # 로그인을 꼭 해야하는 페이지 접근 시 auth.login으로 리다이렉트 설정 
    
        # login_required 실행 전 사용자 정보 조회 메소드
        @login_manager.user_loader
        def user_loader(user_id):
            return db.session.get(get_model('user'), int(user_id))
        
        # 403(Forbidden) 오류 발생 시 로그인 페이지로 리디렉션
        @app.errorhandler(403)
        def handle_forbidden_error(e):
            flash('권한이 없습니다', category="error")
            return redirect(url_for('auth.login'))
        
        # 404(Not Found) 오류 발생 시 홈페이지로 리디렉션
        @app.errorhandler(404)
        def handle_not_found_error(e):
            flash('잘못된 경로입니다.', category="error")
            return redirect(url_for('views.home'))
        
        @app.route('/favicon.ico') 
        def favicon(): 
            return url_for('static', filename='assets/favicon.ico')

    5. Flash 메세지 출력(in base.html)

    flash는 메세지를 저장해두었다가 get_flashed_messages 메소드를 통해 출력 가능합니다. 해당 메소드를 활용해 header block 아래에 출력하도록 구현해봅시다.

    <!-- blog/templates/base.html -->
    <!-- block header와 blcok content 사이 -->
    {% with messages = get_flashed_messages(with_categories=True) %}
        {% if messages %}
            {% for category, message in messages %}
                <!-- 에러일 경우 -->
                {% if category == "error" %}
                    <div class="alert alert-danger alert-dismissable fade show" role="alert" style="text-align: center">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                <!-- 성공일 경우 -->
                {% else %}
                    <div class="alert alert-success alert-dismissable fade show" role="alert" style="text-align: center">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                {% endif %}
            {% endfor %}
        {% endif %}
    {% endwith %}

     


    회원가입, 로그인 페이지와 관련 기능 구현이 끝났습니다.

     

    이제 관련 기능이 제대로 동작하는지, 페이지가 정상적으로 보이는지 확인하는 테스트 코드 작성을 해보겠습니다.

     

    1. UnitTest란?

    먼저 Unit test의 개념에 대해서 설명하겠습니다. Unit Test는 작성한 코드를 가장 작은 단위인 메소드로 쪼개서, 각 메소드가 잘 동작하는지 확인하는 작업입니다. 그래서 개발자들은 작성한 로직을 테스트하는 유닛테스트 코드를 작성하여 로직 변경 시 전체적으로 문제가 없는지 테스트하게 됩니다. 파이썬에서는 unittest 모듈과 pytest 모듈이 존재하는데 둘 모두 사용하기 편리하기 때문에 어떤 모듈을 사용해도 상관없습니다. 다만 unittest 모듈은 표준 라이브러리에 포함되어있어 추가 설치가 필요하지 않고 객체 지향적으로 테스트 코드를 작성할 수 있다는 장점이 있습니다. pytest 모듈은 추가 설치가 필요하지만 플러그인과 확장 기능으로 유연하고 확장성이 뛰어나다고 합니다. 

     

    저는 추가적인 설치 없이, 객체 지향적 코드를 유지할 수 있는 unittest 모듈을 사용해 테스트 코드를 작성해보겠습니다.

    2. unittest Base Class 생성

    unittest 모듈에서의 핵심 개념을 잠깐 설명하고 시작하겠습니다.

     

    - TestCase: unittest 테스트 기본 단위, 일반적으로 하나의 테스트 케이스는 하나의 기능 또는 테스트 대상을 대표합니다.

    • unittest.TestCase 클래스를 상속받아 테스트 케이스를 작성합니다. 내부에 test_로 시작하는 메소드를 자동으로 감지하여 테스트를 진행합니다.
    • 테스트 메소드는 일반적으로 테스트할 기능에 대한 특정 조건을 설정하고, 실행하고, 그 결과를 검사하는 역할을 수행합니다.

    - Fixture: 테스트를 진행할 때 공통적으로 진행하는 작업, 테스트 환경을 설정하거나 정리하는 코드 조각입니다.

    • setUpClass() 메소드는 각 테스트 클래스 레벨에서 공유되는 리소스를 설정하거나 데이터베이스 연결을 초기화하는 데 사용합니다. 테스트 클래스 실행 전 딱 한번만 실행됩니다.
    • tearDownClass() 메소드는 각 테스트 클래스의 모든 테스트 메소드들이 실행된 후에 한 번 호출됩니다. 주로 setUpClass에서 설정한 리소스나 상태를 정리하는 데 사용됩니다.
    • setUp() 메소드는 각 테스트 메소드가 실행되기 전에 실행되며, 테스트 환경을 설정하기 위해 사용됩니다.
    • tearDown() 메소드는 각 테스트 메소드가 실행된 후에 실행되며, 테스트 환경을 정리하기 위해 사용됩니다.

    - assertion: 테스트 결과를 검사하고 예상 결과와 실제 결과를 비교하여 테스트를 평가하는 데 사용됩니다.

    • 예를 들어, 값이 같은지 여부를 확인하는 assertEqual(), 주어진 값이 컬렉션 안에 있는지를 확인하는 assertIn(), 조건이 거짓인지 여부를 확인하는 assertFalse() 등의 Assertion 메소드를 사용할 수 있습니다.
    • Assertion은 테스트가 예상대로 동작하는지 확인하기 위해 사용되며, 테스트 결과에 따라 테스트를 성공 또는 실패로 판단합니다.

    모든 테스트 클래스에서 공통적으로 사용할 setUpClass, tearDownClass, setUp, tearDown 메소드를 정의하기 위해 TestBase Class를 정의합니다. 또한 테스트 환경 설정을 위해 TestConfig 파일도 생성해줍니다.

    # tests/test.py
    import os
    import sys
    # 현재 스크립트의 부모 디렉터리를 상위로 추가
    sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    
    import unittest
    from blog import create_app
    from blog.models import db
    from tests.test_config import TestConfig
    
    class TestBase(unittest.TestCase):
        name = 'BASE'
    
        # 해당 테스트 클래스 실행 시 최초 1번 실행
        @classmethod
        def setUpClass(cls) -> None:
            cls.app = create_app(config=TestConfig) # Flask app 생성
            cls.app_context = cls.app.app_context() # app context 
            cls.app_test_request_context = cls.app.test_request_context() # test request context
            cls.test_client = cls.app.test_client() # test client 생성
            return print(f'{cls.name} Test Start')
    
        @classmethod
        def tearDownClass(cls) -> None:
            return print(f'{cls.name} Test End')
        
        def setUp(self):
            # 테스트 실행 전 Set Up 코드
            # app context 
            self.app_context.push()
            self.app_test_request_context.push()
            with self.app_context:
                db.create_all()
    
        def tearDown(self):
            # 테스트 실행 후 Set Down 코드
            # 세션 삭제 및 데이터베이스 초기화
            db.session.remove()
            db.drop_all()
            self.app_test_request_context.pop()
            self.app_context.pop()
    # tests/test_config.py
    import os
    
    class TestConfig():
        BASE_DIR = os.path.dirname(__file__)
        BASE_DB_NAME = '원하는 db 이름'
    
        SQLALCHEMY_DATABASE_URI = 'sqlite:///{}'.format(os.path.join(BASE_DIR, BASE_DB_NAME))
        SQLALCHEMY_TRACK_MODIFICATIONS = False
        WTF_CSRF_ENABLED = False
    
        SECRET_KEY = '암호 키 설정'

    3. AuthTest Class 생성

    각 테스트에 대한 설명은 주석을 확인해주세요.

    여기서 작성한건 엄밀하게 따지면 unittest보단 integration test에 가깝지만, 크게 구분하지 않고 구현해보았습니다.

    # tests/test_auth.py
    from flask_login import login_user
    from blog.models import db, get_model
    from tests.test import TestBase
    from bs4 import BeautifulSoup
    
    class AuthTest(TestBase):
        name = 'AUTH'
    
        def signup(self):
            return self.test_client.post('/auth/sign-up', data=dict(
                username='XXXXX',
                email='test@example.com',
                password='XXXXXX',
                password_check='XXXXXX',
            ))
        
        '''
        1. db 정상 구동 확인
            db에 직접 회원 넣어보고 확인
        '''
        def test_1_signup_db(self):
            user1 = get_model('user')(
                username='XXXXX', 
                email='test1@example.com', 
                password='123456'
            )
            user2 = get_model('user')(
                username='XXXXO',
                email = 'test2@example.com',
                password='123456'
            )
            db.session.add(user1)
            db.session.commit()
            db.session.add(user2)
            db.session.commit()
    
            self.assertEqual(get_model('user').query.count(), 2) # 유저 수 확인
    
        '''
        2. 회원 가입 기능 확인 
            web 통해서 회원 가입
            redirect = 302 code & db 유저 수 확인
        '''
        def test_2_signup_web(self):
            response = self.signup()
            self.assertEqual(response.status_code, 302) # 성공 시 redirect = code 302
            self.assertEqual(get_model('user').query.count(), 1)     # 유저 수 확인
    
        '''
        3. 로그인 기능 확인
            회원가입 하고 로그인 진행
            회원가입 로그인 모두 redirect = 302 code 받으면 성공
            nav 메뉴 확인(로그인/로그아웃 상태에 따라 nav 메뉴 다름)
        '''
        def test_3_login(self):
            # 1. 로그인 전에 Nav 에서 Login, Sign Up 버튼 있어야하고, New Post, Logout 없어야 함
            response = self.test_client.get('/home')
            nav_before_login = BeautifulSoup(response.data, 'html.parser').nav
    
            self.assertIn('Login', nav_before_login.text)
            self.assertIn('Sign Up', nav_before_login.text)
            self.assertNotIn('New Post', nav_before_login.text)
            self.assertNotIn('Logout', nav_before_login.text)
    
            # 2. 회원 가입 진행
            self.signup()
            self.assertEqual(get_model('user').query.count(), 1)     # 유저 수 확인
    
            # 3. 로그인 진행
            response = self.test_client.post('/auth/login', data=dict(
                email='test@example.com',
                password='XXXXXX' 
            ), follow_redirects=True)
    
            # 4. Nav 에서 Login, Sign Up 버튼 없어야 하고, New Post, Logout 있어야 함
            nav_after_login = BeautifulSoup(response.data, 'html.parser').nav
            self.assertNotIn('Login', nav_after_login.text)
            self.assertNotIn('Sign Up', nav_after_login.text)
            self.assertIn('New Post', nav_after_login.text)
            self.assertIn('Logout', nav_after_login.text)
        
        '''
        4. 로그아웃 기능 확인
            회원가입 하고 로그인 -> 로그아웃 진행
            회원가입 로그인 모두 redirect = 302 code 받으면 성공
            nav 메뉴 확인(로그인/로그아웃 상태에 따라 nav 메뉴 다름)
        '''
        def test_4_logout(self):
    
            # 1. 회원가입
            self.signup()
            self.assertEqual(get_model('user').query.count(), 1)     # 유저 수 확인
    
            # 2. 로그인
            user = get_model('user').query.filter_by(email='test@example.com').first()
            login_user(user, remember=True)
    
            # 3. 로그아웃
            response = self.test_client.get('/auth/logout', follow_redirects=True)
    
            # 4. Nav 에서 Login, Sign Up 버튼 있어야하고, New Post, Logout 없어야 함
            nav_after_logout = BeautifulSoup(response.data, 'html.parser').nav
            self.assertIn('Login', nav_after_logout.text)
            self.assertIn('Sign Up', nav_after_logout.text)
            self.assertNotIn('New Post', nav_after_logout.text)
            self.assertNotIn('Logout', nav_after_logout.text)

    4. 테스트 실행 후 확인

    # tests/test.py
    '''
    이전 코드 생략
    '''
    
    if __name__ == '__main__':
        
        # 회원가입, 로그인, 로그아웃 기능 확인
        from test_auth import AuthTest
        auth_test = unittest.TestLoader().loadTestsFromTestCase(AuthTest)
        unittest.TextTestRunner(verbosity=2).run(auth_test)

     

    테스트 실행 결과


    다음으로는 views 엔드포인트 관리 + Post 생성, 수정, 삭제 관련 기능 구현과 테스트 코드 구현을 해보겠습니다.

    반응형
Designed by Tistory.