20 |
21 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) [2017] [Jinsoo Park]
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/blog/base/views.py:
--------------------------------------------------------------------------------
1 | from flask import render_template, request, redirect, url_for
2 | from blog.base import base
3 | from blog.base.models import Item, List
4 | from blog.database import db_session
5 |
6 |
7 | @base.route('/', methods=['GET', 'POST'])
8 | def index():
9 | return render_template('home.html')
10 |
11 |
12 | @base.route('/lists/', methods=['GET', 'POST'])
13 | def view_list(list_id):
14 | items = Item.query.filter(Item.list == list_id).all()
15 | return render_template('list.html', items=items, list_id=list_id)
16 |
17 |
18 | @base.route('/lists/new', methods=['GET', 'POST'])
19 | def new_list():
20 | list_ = List()
21 | db_session.add(list_)
22 | db_session.commit()
23 | item = Item(text=request.form['item_text'], list=list_.id)
24 | db_session.add(item)
25 | db_session.commit()
26 | return redirect(url_for('base.view_list', list_id=list_.id))
27 |
28 |
29 | @base.route('/lists//add_item', methods=['POST'])
30 | def add_item(list_id):
31 | list_ = List.query.filter(List.id == list_id).first()
32 | item = Item(text=request.form['item_text'], list=list_.id)
33 | db_session.add(item)
34 | db_session.commit()
35 | return redirect(url_for('base.view_list', list_id=list_.id))
--------------------------------------------------------------------------------
/blog/templates/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | To-Do lists
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
{% block header_text %}{% endblock %}
15 |
18 |
19 |
20 |
21 |
22 | {% block table %}
23 | {% endblock %}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FLASK TDD with TESTING GOAT
2 |
3 | 개발을 시작한지 2년 남짓 되었지만, 제대로 된 테스트 코드 하나 갖추지 못한채 개발을 하는 모습을 바꿔보고자 시작하게되었습니다.
4 |
5 | [파이썬을 이용한 클린 코드를 위한 테스트 주도 개발](http://www.yes24.com/24/goods/16886031)의 Django 코드를 Flask로 구현했습니다. 그리고 테스팅에 사용된 unittest대신 pytest를 사용해보았습니다.
6 |
7 | 기존 코드를 그대로 입력해볼 수도 있었지만, 기계적인 입력이 되는 것을 방지하고자 새롭게 코드를 짜며 책 내용을 반복하고 있습니다.
8 |
9 | ### 시작에 앞서
10 |
11 | 다음 링크에서 무료로 영문판 책 내용을 보실 수 있습니다.
12 |
13 | [Test-Driven Development with Python](http://www.obeythetestinggoat.com/pages/book.html)
14 |
15 | 본 예제는 파이썬 3.6.0 버전으로 실행됩니다.(3이상이면 큰 차이는 없을 것으로 예상됩니다.)
16 |
17 | 본 예제가 Flask와 Testing을 함께 공부하고자 하시는 분께 도움이 되었으면 합니다.
18 |
19 | ### 책과 달라진 점
20 |
21 | - Django -> Flask
22 | - Django Template - > Jinja2
23 | - Django ORM -> SQLAlchemy
24 | - unnittest -> pytest
25 |
26 | 추가적으로 설치된 라이브러리 목록은 requirement.txt에 입력돼 있습니다.
27 |
28 |
29 | ### 설치
30 |
31 | 실행에 앞서 파이썬3 가상환경에서 requirement.txt의 리스트 대로 설치해줍니다.
32 |
33 | ```
34 | $ pip install -r requirement.text
35 | ```
36 |
37 | 관련 라이브러리 설치 후 예제에 사용된 DB 모델 테이블을 생성해줍니다.
38 |
39 | ```
40 | $ export FLASK_APP=blog/__init__.py
41 | $ flask shell
42 | >>> from blog.database import init_db
43 | >>> init_db()
44 | ```
45 |
46 | def init_db():
47 | import blog.base.models
48 | Base.metadata.create_all(bind=engine)
49 |
50 | init_db()를 통해 models.py에 있는 클래스가 sqlite에 만들어집니다.
51 |
52 | 저는 db 파일을 tmp 경로에 만들도록 해두었습니다.
53 |
54 | 본 예제에서 데이터베이스는 sqlite가 사용되었습니다. [다운로드](http://www.sqlite.org/download.html)
55 |
56 | ### 라이센스
57 |
58 | 본 예제는 MIT License 아래 보호되고 있습니다. 자세한 내용은 다음 파일을 참고해주세요. [LICENSE.md](LICENSE.md)
59 |
60 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.gitignore.io/api/pycharm
2 |
3 | ### PyCharm ###
4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
5 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
6 |
7 | # User-specific stuff:
8 | .idea/**/workspace.xml
9 | .idea/**/tasks.xml
10 | .idea/dictionaries
11 |
12 | # Sensitive or high-churn files:
13 | .idea/**/dataSources/
14 | .idea/**/dataSources.ids
15 | .idea/**/dataSources.xml
16 | .idea/**/dataSources.local.xml
17 | .idea/**/sqlDataSources.xml
18 | .idea/**/dynamic.xml
19 | .idea/**/uiDesigner.xml
20 |
21 | ## File-based project format:
22 | *.iws
23 |
24 | # Crashlytics plugin (for Android Studio and IntelliJ)
25 | com_crashlytics_export_strings.xml
26 | crashlytics.properties
27 | crashlytics-build.properties
28 | fabric.properties
29 |
30 | # End of https://www.gitignore.io/api/pycharm
31 | # Created by https://www.gitignore.io/api/python
32 |
33 | ### Python ###
34 | # Byte-compiled / optimized / DLL files
35 | __pycache__/
36 | *.py[cod]
37 | *$py.class
38 |
39 | # Distribution / packaging
40 | .Python
41 | env/
42 | build/
43 | develop-eggs/
44 | dist/
45 | downloads/
46 | eggs/
47 | .eggs/
48 | lib/
49 | lib64/
50 | parts/
51 | sdist/
52 | var/
53 | wheels/
54 | *.egg-info/
55 | .installed.cfg
56 | *.egg
57 |
58 | # PyInstaller
59 | # Usually these files are written by a python script from a template
60 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
61 | *.manifest
62 | *.spec
63 |
64 | # Installer logs
65 | pip-log.txt
66 | pip-delete-this-directory.txt
67 |
68 | # Unit test / coverage reports
69 | htmlcov/
70 | .tox/
71 | .coverage
72 | .coverage.*
73 | .cache
74 | nosetests.xml
75 | coverage.xml
76 | *,cover
77 | .hypothesis/
78 |
79 | # Translations
80 | *.mo
81 | *.pot
82 |
83 | # Flask stuff:
84 | instance/
85 | .webassets-cache
86 |
87 | # Sphinx documentation
88 | docs/_build/
89 |
90 | # PyBuilder
91 | target/
92 |
93 | # Jupyter Notebook
94 | .ipynb_checkpoints
95 |
96 | # pyenv
97 | .python-version
98 |
99 | # celery beat schedule file
100 | celerybeat-schedule
101 |
102 | # dotenv
103 | .env
104 |
105 | # virtualenv
106 | .venv
107 | venv/
108 | ENV/
109 |
110 | # mkdocs documentation
111 | /site
112 |
113 | # End of https://www.gitignore.io/api/python
--------------------------------------------------------------------------------
/blog/functional_test/tests.py:
--------------------------------------------------------------------------------
1 | from selenium import webdriver
2 | from selenium.webdriver.common.keys import Keys
3 | import pytest
4 |
5 |
6 | # pytest code
7 | class TestNewVisitor(object):
8 | @staticmethod
9 | def url_match(url):
10 | raise ValueError(url)
11 |
12 | # using fixture set_browser in conftest.py
13 | @pytest.mark.skip
14 | def test_layout_and_styling(self, browser):
15 | # 에디스는 메인 페이지를 방문한다.
16 | browser.get('http://localhost:5000')
17 | browser.set_window_size(1024, 768)
18 |
19 | inputbox = browser.find_element_by_id('id_new_item')
20 |
21 | location_value = inputbox.location['x'] + inputbox.size['width'] / 2
22 |
23 | assert 500 < location_value < 530
24 |
25 | # def test_cannot_add_empty_list_items(self):
26 | # # 에디스는 메인 페이지에 접속해서 빈 아이템을 실수로 등록하려고 한다
27 | # # 입력 상자가 비어 있는 상태에서 엔터키를 누른다
28 | #
29 | # # 페이지가 새로고침되고 빈 아이템을 등록할 수 없다는 에러 메시지가 표시된다
30 | # # 다른 아이템을 입력하고 이번에는 정상 처리된다
31 | # # 그녀는 고의적으로 다시 빈 아이템을 등록
32 | # # 리스트 페이지에 다시 에러 메시지 표시
33 | # # 아이템을 입력하면 정상 동작한다
34 | # self.fail('write me!')
35 |
36 | @pytest.mark.skip
37 | def test_show_title(self):
38 | self.browser = webdriver.Chrome()
39 | self.browser.implicitly_wait(3)
40 | self.browser.get('http://localhost:5000')
41 | assert 'To-Do' in self.browser.title
42 |
43 | header_text = self.browser.find_element_by_tag_name('h1').text
44 | assert '작업 목록' in header_text
45 |
46 | inputbox = self.browser.find_element_by_id('id_new_item')
47 | assert inputbox.get_attribute('placeholder') == '작업 아이템 입력'
48 |
49 | inputbox.send_keys('공작깃털 사기')
50 | inputbox.send_keys(Keys.ENTER)
51 | edith_list_url = self.browser.current_url
52 |
53 | # excinfo == Exception Informantion
54 | with pytest.raises(ValueError) as excinfo:
55 | self.url_match(edith_list_url)
56 |
57 | # self.assertRegex(edith_list_url, '/lists/.+')
58 | assert excinfo.match(r'/lists/.+')
59 |
60 | inputbox = self.browser.find_element_by_id('id_new_item')
61 | inputbox.send_keys('공작깃털을 이용해서 그물 만들기')
62 | inputbox.send_keys(Keys.ENTER)
63 |
64 | page_text = self.browser.find_element_by_tag_name('body').text
65 | assert '공작깃털 사기' in page_text
66 | assert '그물 만들기' in page_text
67 |
68 | # 프란시스가 새로운 작업 아이템을 입력하기 시작한다.
69 | self.browser.get('http://localhost:5000')
70 | inputbox = self.browser.find_element_by_id('id_new_item')
71 | inputbox.send_keys('우유 사기')
72 | inputbox.send_keys(Keys.ENTER)
73 |
74 | # 프란시스가 전용 URL을 취득
75 | francis_list_url = self.browser.current_url
76 | with pytest.raises(ValueError) as excinfo:
77 | self.url_match(francis_list_url)
78 | assert excinfo.match(r'/lists/.+')
79 | assert francis_list_url != edith_list_url
80 |
81 | # 에디스가 입력한 흔적이 없다는 것을 다시 확인
82 | page_text = self.browser.find_element_by_tag_name('body').text
83 | assert '공작깃털 사기' not in page_text
84 | assert '우유 사기' in page_text
85 |
--------------------------------------------------------------------------------
/blog/base/test_base.py:
--------------------------------------------------------------------------------
1 | from blog import app
2 | from flask import request, render_template
3 | from blog.base.views import index, view_list
4 | from blog.base.models import Item, List
5 | from blog.database import db_session
6 | from sqlalchemy import or_
7 |
8 |
9 | class TestNewList(object):
10 | def test_saving_a_post_request(self):
11 | with app.test_client() as client:
12 | keyword = '포스트 저장용'
13 | client.post('/lists/new', data=dict(item_text=keyword))
14 | items = Item.query.filter(Item.text == '포스트 저장용').all()
15 |
16 | # 마지막에 추가된 레코드
17 | assert items[0].text == keyword
18 | Item.query.filter(Item.text == '포스트 저장용').delete()
19 | db_session.commit()
20 | assert Item.query.filter(Item.text == '포스트 저장용').count() == 0
21 |
22 | def test_redirects_after_post(self):
23 | with app.test_client() as client:
24 | rv = client.post('/lists/new', data=dict(item_text='신규 작업 아이템'))
25 | # print(rv)
26 | # print(type(rv))
27 | assert rv.status_code == 302
28 | assert '/lists/' in rv.location
29 | assert request.path == '/lists/new'
30 | Item.query.filter(Item.text == '신규 작업 아이템').delete()
31 | db_session.commit()
32 | assert Item.query.filter(Item.text == '신규 작업 아이템').count() == 0
33 |
34 |
35 | class TestLiveView(object):
36 | def test_displays_only_items_for_that_list(self):
37 | correct_list = List()
38 | db_session.add(correct_list)
39 | db_session.commit()
40 | item_01 = Item(text='itemey 1', list=correct_list.id)
41 | item_02 = Item(text='itemey 2', list=correct_list.id)
42 | db_session.add(item_01)
43 | db_session.add(item_02)
44 | db_session.commit()
45 |
46 | other_list = List()
47 | db_session.add(other_list)
48 | db_session.commit()
49 | other_item_01 = Item(text='다른 목록 아이템 1', list=other_list.id)
50 | other_item_02 = Item(text='다른 목록 아이템 2', list=other_list.id)
51 |
52 | with app.test_request_context('/lists/%d' % correct_list.id):
53 | res = view_list(list_id=correct_list.id)
54 | assert item_01.text in res
55 | assert item_02.text in res
56 | assert other_item_01.text not in res
57 | assert other_item_02.text not in res
58 | Item.query.filter(Item.list == correct_list.id).delete()
59 | Item.query.filter(Item.list == other_list.id).delete()
60 | db_session.commit()
61 | List.query.filter(List.id == correct_list.id).delete()
62 | List.query.filter(List.id == other_list.id).delete()
63 | db_session.commit()
64 |
65 | def test_uses_list_template(self):
66 | with app.test_client() as client:
67 | list_ = List()
68 | db_session.add(list_)
69 | db_session.commit()
70 | client.get('/lists/%d/' % list_.id)
71 |
72 |
73 | class TestListAndItemModels(object):
74 |
75 | def test_saving_and_retrieving_items(self):
76 | list_ = List()
77 | db_session.add(list_)
78 | db_session.commit()
79 |
80 | first_item = Item(text='첫 번째 아이템', list=list_.id)
81 | second_item = Item(text='두 번째 아이템', list=list_.id)
82 | db_session.add(first_item)
83 | db_session.add(second_item)
84 | db_session.commit()
85 |
86 | saved_items = Item.query.filter(or_(Item.text == '첫 번째 아이템', Item.text == '두 번째 아이템')).all()
87 |
88 | first_saved_item = saved_items[0]
89 | second_saved_item = saved_items[1]
90 |
91 | assert first_saved_item.text == '첫 번째 아이템'
92 | assert first_saved_item.list == list_.id
93 | assert second_saved_item.text == '두 번째 아이템'
94 | assert second_saved_item.list == list_.id
95 |
96 | Item.query.filter(Item.text == '첫 번째 아이템').delete()
97 | Item.query.filter(Item.text == '두 번째 아이템').delete()
98 | List.query.filter(List.id == list_.id).delete()
99 | db_session.commit()
100 |
101 |
102 | class TestMainPageSetUp(object):
103 |
104 | def test_root_url_resolves_to_welcome_page_view(self):
105 | with app.test_request_context('/', method='GET'):
106 | module = index.__module__.split('.')[1] # index의 모듈 blog.base.views
107 | func = index.__name__ # index
108 | assert request.endpoint in module + '.' + func
109 |
110 | def test_welcome_page_returns_correct_html(self):
111 |
112 | with app.test_request_context('/', method='GET'):
113 | res = index() # type 'str', rendered html
114 | expected_html = render_template('home.html') # type 'str', rendered html
115 |
116 | assert res.startswith('')
117 | assert 'To-Do lists' in res
118 | assert res.endswith('