diff --git a/Flask_WTF.egg-info/PKG-INFO b/Flask_WTF.egg-info/PKG-INFO
index 7608b400d4b98babfd38b5ae775c76e29283f193..68513104bbfb984c0743cd2af5996b4d54e4710e 100644
--- a/Flask_WTF.egg-info/PKG-INFO
+++ b/Flask_WTF.egg-info/PKG-INFO
@@ -1,23 +1,24 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: Flask-WTF
-Version: 0.14.2
+Version: 0.14.3
 Summary: Simple integration of Flask and WTForms.
 Home-page: https://github.com/lepture/flask-wtf
-Author: Hsiaoming Yang
-Author-email: me@lepture.com
+Author: Dan Jacob
+Author-email: danjac354@gmail.com
+Maintainer: Hsiaoming Yang
+Maintainer-email: me@lepture.com
 License: BSD
 Description: Flask-WTF
         =========
         
         .. image:: https://travis-ci.org/lepture/flask-wtf.svg?branch=master
-           :target: https://travis-ci.org/lepture/flask-wtf
-           :alt: Travis CI Status
-        .. image:: https://coveralls.io/repos/lepture/flask-wtf/badge.svg?branch=master
-           :target: https://coveralls.io/r/lepture/flask-wtf
-           :alt: Coverage Status
+            :target: https://travis-ci.org/lepture/flask-wtf
+            :alt: Test Status
+        .. image:: https://codecov.io/gh/lepture/flask-wtf/branch/master/graph/badge.svg
+            :target: https://codecov.io/gh/lepture/flask-wtf
+            :alt: Coverage Status
         
-        Simple integration of Flask and WTForms, including CSRF, file upload,
-        and reCAPTCHA.
+        Simple integration of Flask and WTForms, including CSRF, file upload, and reCAPTCHA.
         
         Links
         -----
@@ -35,14 +36,15 @@ Classifier: License :: OSI Approved :: BSD License
 Classifier: Operating System :: OS Independent
 Classifier: Programming Language :: Python
 Classifier: Programming Language :: Python :: 2
-Classifier: Programming Language :: Python :: 2.6
 Classifier: Programming Language :: Python :: 2.7
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.3
 Classifier: Programming Language :: Python :: 3.4
 Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: Implementation :: CPython
 Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
 Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Description-Content-Type: text/x-rst
diff --git a/Flask_WTF.egg-info/SOURCES.txt b/Flask_WTF.egg-info/SOURCES.txt
index 8b9d8f6203f228fbcaa3b8f8f0f820a0d1fe54b6..4a77b7c7e74e5b186abbc793f3ef09177190fa06 100644
--- a/Flask_WTF.egg-info/SOURCES.txt
+++ b/Flask_WTF.egg-info/SOURCES.txt
@@ -4,7 +4,6 @@ MANIFEST.in
 README.rst
 setup.cfg
 setup.py
-test-requirements.txt
 tox.ini
 Flask_WTF.egg-info/PKG-INFO
 Flask_WTF.egg-info/SOURCES.txt
@@ -29,16 +28,6 @@ docs/upgrade.rst
 docs/_static/flask-wtf.png
 docs/_templates/brand.html
 docs/_templates/useful-links.html
-docs/_themes/LICENSE
-docs/_themes/README.rst
-docs/_themes/flask_theme_support.py
-docs/_themes/flask/layout.html
-docs/_themes/flask/relations.html
-docs/_themes/flask/theme.conf
-docs/_themes/flask/static/flasky.css_t
-docs/_themes/flask_small/layout.html
-docs/_themes/flask_small/theme.conf
-docs/_themes/flask_small/static/flasky.css_t
 flask_wtf/__init__.py
 flask_wtf/_compat.py
 flask_wtf/csrf.py
@@ -50,20 +39,11 @@ flask_wtf/recaptcha/__init__.py
 flask_wtf/recaptcha/fields.py
 flask_wtf/recaptcha/validators.py
 flask_wtf/recaptcha/widgets.py
-tests/__init__.py
-tests/base.py
-tests/flask.png
-tests/flask.txt
-tests/test_csrf.py
-tests/test_deprecated.py
+tests/conftest.py
+tests/test_csrf_extension.py
+tests/test_csrf_form.py
+tests/test_file.py
+tests/test_form.py
+tests/test_html5.py
 tests/test_i18n.py
-tests/test_recaptcha.py
-tests/test_uploads.py
-tests/test_validation.py
-tests/templates/csrf.html
-tests/templates/csrf_macro.html
-tests/templates/hidden.html
-tests/templates/import_csrf.html
-tests/templates/index.html
-tests/templates/recaptcha.html
-tests/templates/upload.html
\ No newline at end of file
+tests/test_recaptcha.py
\ No newline at end of file
diff --git a/Flask_WTF.egg-info/requires.txt b/Flask_WTF.egg-info/requires.txt
index 658cc2d9d659fbed9334e3ebb47ca9ab10d9f957..0d7ceaaaf380f47538787cf52f69a4e4c4d6276b 100644
--- a/Flask_WTF.egg-info/requires.txt
+++ b/Flask_WTF.egg-info/requires.txt
@@ -1,2 +1,3 @@
 Flask
 WTForms
+itsdangerous
diff --git a/PKG-INFO b/PKG-INFO
index 7608b400d4b98babfd38b5ae775c76e29283f193..68513104bbfb984c0743cd2af5996b4d54e4710e 100644
--- a/PKG-INFO
+++ b/PKG-INFO
@@ -1,23 +1,24 @@
-Metadata-Version: 1.1
+Metadata-Version: 2.1
 Name: Flask-WTF
-Version: 0.14.2
+Version: 0.14.3
 Summary: Simple integration of Flask and WTForms.
 Home-page: https://github.com/lepture/flask-wtf
-Author: Hsiaoming Yang
-Author-email: me@lepture.com
+Author: Dan Jacob
+Author-email: danjac354@gmail.com
+Maintainer: Hsiaoming Yang
+Maintainer-email: me@lepture.com
 License: BSD
 Description: Flask-WTF
         =========
         
         .. image:: https://travis-ci.org/lepture/flask-wtf.svg?branch=master
-           :target: https://travis-ci.org/lepture/flask-wtf
-           :alt: Travis CI Status
-        .. image:: https://coveralls.io/repos/lepture/flask-wtf/badge.svg?branch=master
-           :target: https://coveralls.io/r/lepture/flask-wtf
-           :alt: Coverage Status
+            :target: https://travis-ci.org/lepture/flask-wtf
+            :alt: Test Status
+        .. image:: https://codecov.io/gh/lepture/flask-wtf/branch/master/graph/badge.svg
+            :target: https://codecov.io/gh/lepture/flask-wtf
+            :alt: Coverage Status
         
-        Simple integration of Flask and WTForms, including CSRF, file upload,
-        and reCAPTCHA.
+        Simple integration of Flask and WTForms, including CSRF, file upload, and reCAPTCHA.
         
         Links
         -----
@@ -35,14 +36,15 @@ Classifier: License :: OSI Approved :: BSD License
 Classifier: Operating System :: OS Independent
 Classifier: Programming Language :: Python
 Classifier: Programming Language :: Python :: 2
-Classifier: Programming Language :: Python :: 2.6
 Classifier: Programming Language :: Python :: 2.7
 Classifier: Programming Language :: Python :: 3
-Classifier: Programming Language :: Python :: 3.3
 Classifier: Programming Language :: Python :: 3.4
 Classifier: Programming Language :: Python :: 3.5
 Classifier: Programming Language :: Python :: 3.6
+Classifier: Programming Language :: Python :: 3.7
+Classifier: Programming Language :: Python :: 3.8
 Classifier: Programming Language :: Python :: Implementation :: CPython
 Classifier: Programming Language :: Python :: Implementation :: PyPy
 Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
 Classifier: Topic :: Software Development :: Libraries :: Python Modules
+Description-Content-Type: text/x-rst
diff --git a/README.rst b/README.rst
index dec4232842d027b84c0c49a9adf74386057ad83c..50a680b5124b9d451d3dd5b68ca253961a614e91 100644
--- a/README.rst
+++ b/README.rst
@@ -2,14 +2,13 @@ Flask-WTF
 =========
 
 .. image:: https://travis-ci.org/lepture/flask-wtf.svg?branch=master
-   :target: https://travis-ci.org/lepture/flask-wtf
-   :alt: Travis CI Status
-.. image:: https://coveralls.io/repos/lepture/flask-wtf/badge.svg?branch=master
-   :target: https://coveralls.io/r/lepture/flask-wtf
-   :alt: Coverage Status
+    :target: https://travis-ci.org/lepture/flask-wtf
+    :alt: Test Status
+.. image:: https://codecov.io/gh/lepture/flask-wtf/branch/master/graph/badge.svg
+    :target: https://codecov.io/gh/lepture/flask-wtf
+    :alt: Coverage Status
 
-Simple integration of Flask and WTForms, including CSRF, file upload,
-and reCAPTCHA.
+Simple integration of Flask and WTForms, including CSRF, file upload, and reCAPTCHA.
 
 Links
 -----
diff --git a/docs/_themes/LICENSE b/docs/_themes/LICENSE
deleted file mode 100644
index 8daab7ee6efe3b6f3c2a2594b711e7a9026eb5e2..0000000000000000000000000000000000000000
--- a/docs/_themes/LICENSE
+++ /dev/null
@@ -1,37 +0,0 @@
-Copyright (c) 2010 by Armin Ronacher.
-
-Some rights reserved.
-
-Redistribution and use in source and binary forms of the theme, with or
-without modification, are permitted provided that the following conditions
-are met:
-
-* Redistributions of source code must retain the above copyright
-  notice, this list of conditions and the following disclaimer.
-
-* Redistributions in binary form must reproduce the above
-  copyright notice, this list of conditions and the following
-  disclaimer in the documentation and/or other materials provided
-  with the distribution.
-
-* The names of the contributors may not be used to endorse or
-  promote products derived from this software without specific
-  prior written permission.
-
-We kindly ask you to only use these themes in an unmodified manner just
-for Flask and Flask-related products, not for unrelated projects.  If you
-like the visual style and want to use it for your own projects, please
-consider making some larger changes to the themes (such as changing
-font faces, sizes, colors or margins).
-
-THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
-LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE
-POSSIBILITY OF SUCH DAMAGE.
diff --git a/docs/_themes/README.rst b/docs/_themes/README.rst
deleted file mode 100644
index 427d62c82af812e340ddd17fb647909697e62135..0000000000000000000000000000000000000000
--- a/docs/_themes/README.rst
+++ /dev/null
@@ -1,71 +0,0 @@
-Flask Sphinx Themes
-===================
-
-This repository contains Sphinx themes for Flask and Flask related
-projects.  To use this theme in your Sphinx documentation:
-
-1. Put this folder as ``_themes`` in the docs folder.  Alternatively
-   you can use git submodules to check out the contents there.
-
-2. Add this to ``conf.py``:
-
-   .. code-block:: python
-
-       sys.path.append(os.path.join(os.path.dirname(__file__), '_themes'))
-       html_theme_path = ['_themes']
-       html_theme = 'flask'
-
-Themes
-------
-
-The following themes exist for ``html_theme``.
-
-======================= ===============================================
-flask                   The standard Flask documentation theme for
-                        large projects
-
-flask_small             Small single page theme.  Intended to be used
-                        by very small addon libraries for Flask.
-======================= ===============================================
-
-Options
--------
-
-The following options can be set with ``html_theme_options``.
-
-======================= ===============================================
-index_logo              Filename of a picture in ``_static`` to be used
-                        as replacement for the ``h1`` in the
-                        ``index.rst`` file.
-                        *Default unset.*
-
-index_logo_height       Height of the index logo.
-                        *Default 120px*.
-
-touch_icon              Filename of a picture in ``_static`` to be use
-                        as the app icon on Apple devices.
-                        *Default unset.*
-
-github_fork             Repository name on GitHub for the "Fork Me"
-                        badge.
-                        *Default unset.*
-
-github_ribbon_color     Color for the "Fork Me" badge.
-                        *Default darkblue_121621.*
-======================= ===============================================
-
-Sidebar Templates
------------------
-
-The following sidebar templates can be included in ``html_sidebars``.
-
-======================= ===============================================
-relations.html          Show parent, previous, and next links.
-======================= ===============================================
-
-Pygments Style
---------------
-
-The theme automatically sets ``pygments_style`` to the provided style.
-Make sure you remove any override from ``conf.py`` or set it to
-``flask_theme_support.FlaskyStyle``.
diff --git a/docs/_themes/flask/layout.html b/docs/_themes/flask/layout.html
deleted file mode 100644
index 8398eba660e260a9bc614730230ced88a79d5f1c..0000000000000000000000000000000000000000
--- a/docs/_themes/flask/layout.html
+++ /dev/null
@@ -1,29 +0,0 @@
-{% extends 'basic/layout.html' %}
-
-{% block extrahead %}
-    {{ super() }}
-    {% if theme_touch_icon %}
-        <link rel="apple-touch-icon" href="{{ pathto('_static/' ~ theme_touch_icon, 1) }}">
-    {% endif %}
-    <meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9">
-{% endblock %}
-
-{% block relbar2 %}
-    {% if theme_github_fork %}
-        <a href="http://github.com/{{ theme_github_fork }}">
-            <img style="position: fixed; top: 0; right: 0; border: 0;"
-                 src="http://s3.amazonaws.com/github/ribbons/forkme_right_{{ theme_github_ribbon_color }}.png"
-                 alt="Fork me on GitHub">
-        </a>
-    {% endif %}
-{% endblock %}
-
-{% block header %}
-    {{ super() }}
-    {% if pagename == 'index' %}<div class=indexwrapper>{% endif %}
-{% endblock %}
-
-{% block footer %}
-    {{ super() }}
-    {% if pagename == 'index' %}</div>{% endif %}
-{% endblock %}
diff --git a/docs/_themes/flask/relations.html b/docs/_themes/flask/relations.html
deleted file mode 100644
index 3bbcde85bb48d3f8bdfd066104d8f2eae584e72d..0000000000000000000000000000000000000000
--- a/docs/_themes/flask/relations.html
+++ /dev/null
@@ -1,19 +0,0 @@
-<h3>Related Topics</h3>
-<ul>
-  <li><a href="{{ pathto(master_doc) }}">Documentation overview</a><ul>
-  {%- for parent in parents %}
-  <li><a href="{{ parent.link|e }}">{{ parent.title }}</a><ul>
-  {%- endfor %}
-    {%- if prev %}
-      <li>Previous: <a href="{{ prev.link|e }}" title="{{ _('previous chapter')
-        }}">{{ prev.title }}</a></li>
-    {%- endif %}
-    {%- if next %}
-      <li>Next: <a href="{{ next.link|e }}" title="{{ _('next chapter')
-        }}">{{ next.title }}</a></li>
-    {%- endif %}
-  {%- for parent in parents %}
-  </ul></li>
-  {%- endfor %}
-  </ul></li>
-</ul>
diff --git a/docs/_themes/flask/static/flasky.css_t b/docs/_themes/flask/static/flasky.css_t
deleted file mode 100644
index 5906e751b7a9c21a5c295e3779b0fbe7541c02cb..0000000000000000000000000000000000000000
--- a/docs/_themes/flask/static/flasky.css_t
+++ /dev/null
@@ -1,577 +0,0 @@
-/*
- * flasky.css_t
- * ~~~~~~~~~~~~
- *
- * :copyright: Copyright 2010 by Armin Ronacher.
- * :license: Flask Design License, see LICENSE for details.
- */
-
-{% set page_width = '940px' %}
-{% set sidebar_width = '220px' %}
- 
-@import url("basic.css");
- 
-/* -- page layout ----------------------------------------------------------- */
- 
-body {
-    font-family: 'Georgia', serif;
-    font-size: 17px;
-    background-color: white;
-    color: #000;
-    margin: 0;
-    padding: 0;
-}
-
-div.document {
-    width: {{ page_width }};
-    margin: 30px auto 0 auto;
-}
-
-div.documentwrapper {
-    float: left;
-    width: 100%;
-}
-
-div.bodywrapper {
-    margin: 0 0 0 {{ sidebar_width }};
-}
-
-div.sphinxsidebar {
-    width: {{ sidebar_width }};
-}
-
-hr {
-    border: 1px solid #B1B4B6;
-}
- 
-div.body {
-    background-color: #ffffff;
-    color: #3E4349;
-    padding: 0 30px 0 30px;
-}
-
-img.floatingflask {
-    padding: 0 0 10px 10px;
-    float: right;
-}
- 
-div.footer {
-    width: {{ page_width }};
-    margin: 20px auto 30px auto;
-    font-size: 14px;
-    color: #888;
-    text-align: right;
-}
-
-div.footer a {
-    color: #888;
-}
-
-div.related {
-    display: none;
-}
- 
-div.sphinxsidebar a {
-    color: #444;
-    text-decoration: none;
-    border-bottom: 1px dotted #999;
-}
-
-div.sphinxsidebar a:hover {
-    border-bottom: 1px solid #999;
-}
- 
-div.sphinxsidebar {
-    font-size: 14px;
-    line-height: 1.5;
-}
-
-div.sphinxsidebarwrapper {
-    padding: 18px 10px;
-}
-
-div.sphinxsidebarwrapper p.logo {
-    padding: 0 0 20px 0;
-    margin: 0;
-    text-align: center;
-}
- 
-div.sphinxsidebar h3,
-div.sphinxsidebar h4 {
-    font-family: 'Garamond', 'Georgia', serif;
-    color: #444;
-    font-size: 24px;
-    font-weight: normal;
-    margin: 0 0 5px 0;
-    padding: 0;
-}
-
-div.sphinxsidebar h4 {
-    font-size: 20px;
-}
- 
-div.sphinxsidebar h3 a {
-    color: #444;
-}
-
-div.sphinxsidebar p.logo a,
-div.sphinxsidebar h3 a,
-div.sphinxsidebar p.logo a:hover,
-div.sphinxsidebar h3 a:hover {
-    border: none;
-}
- 
-div.sphinxsidebar p {
-    color: #555;
-    margin: 10px 0;
-}
-
-div.sphinxsidebar ul {
-    margin: 10px 0;
-    padding: 0;
-    color: #000;
-}
- 
-div.sphinxsidebar input {
-    border: 1px solid #ccc;
-    font-family: 'Georgia', serif;
-    font-size: 1em;
-}
- 
-/* -- body styles ----------------------------------------------------------- */
- 
-a {
-    color: #004B6B;
-    text-decoration: underline;
-}
- 
-a:hover {
-    color: #6D4100;
-    text-decoration: underline;
-}
- 
-div.body h1,
-div.body h2,
-div.body h3,
-div.body h4,
-div.body h5,
-div.body h6 {
-    font-family: 'Garamond', 'Georgia', serif;
-    font-weight: normal;
-    margin: 30px 0px 10px 0px;
-    padding: 0;
-}
-
-{% if theme_index_logo %}
-div.indexwrapper h1 {
-    text-indent: -999999px;
-    background: url({{ theme_index_logo }}) no-repeat center center;
-    height: {{ theme_index_logo_height }};
-}
-{% endif %}
-div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; }
-div.body h2 { font-size: 180%; }
-div.body h3 { font-size: 150%; }
-div.body h4 { font-size: 130%; }
-div.body h5 { font-size: 100%; }
-div.body h6 { font-size: 100%; }
- 
-a.headerlink {
-    color: #ddd;
-    padding: 0 4px;
-    text-decoration: none;
-}
- 
-a.headerlink:hover {
-    color: #444;
-    background: #eaeaea;
-}
- 
-div.body p, div.body dd, div.body li {
-    line-height: 1.4em;
-}
-
-div.admonition {
-    background: #fafafa;
-    margin: 20px -30px;
-    padding: 10px 30px;
-    border-top: 1px solid #ccc;
-    border-bottom: 1px solid #ccc;
-}
-
-div.admonition tt.xref, div.admonition a tt {
-    border-bottom: 1px solid #fafafa;
-}
-
-dd div.admonition {
-    margin-left: -60px;
-    padding-left: 60px;
-}
-
-div.admonition p.admonition-title {
-    font-family: 'Garamond', 'Georgia', serif;
-    font-weight: normal;
-    font-size: 24px;
-    margin: 0 0 10px 0;
-    padding: 0;
-    line-height: 1;
-}
-
-div.admonition p.last {
-    margin-bottom: 0;
-}
-
-div.highlight {
-    background-color: white;
-}
-
-dt:target, .highlight {
-    background: #FAF3E8;
-}
-
-div.note {
-    background-color: #eee;
-    border: 1px solid #ccc;
-}
- 
-div.seealso {
-    background-color: #ffc;
-    border: 1px solid #ff6;
-}
- 
-div.topic {
-    background-color: #eee;
-}
- 
-p.admonition-title {
-    display: inline;
-}
- 
-p.admonition-title:after {
-    content: ":";
-}
-
-pre, tt {
-    font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
-    font-size: 0.9em;
-}
-
-img.screenshot {
-}
-
-tt.descname, tt.descclassname {
-    font-size: 0.95em;
-}
-
-tt.descname {
-    padding-right: 0.08em;
-}
-
-img.screenshot {
-    -moz-box-shadow: 2px 2px 4px #eee;
-    -webkit-box-shadow: 2px 2px 4px #eee;
-    box-shadow: 2px 2px 4px #eee;
-}
-
-table.docutils {
-    border: 1px solid #888;
-    -moz-box-shadow: 2px 2px 4px #eee;
-    -webkit-box-shadow: 2px 2px 4px #eee;
-    box-shadow: 2px 2px 4px #eee;
-}
-
-table.docutils td, table.docutils th {
-    border: 1px solid #888;
-    padding: 0.25em 0.7em;
-}
-
-table.field-list, table.footnote {
-    border: none;
-    -moz-box-shadow: none;
-    -webkit-box-shadow: none;
-    box-shadow: none;
-}
-
-table.footnote {
-    margin: 15px 0;
-    width: 100%;
-    border: 1px solid #eee;
-    background: #fdfdfd;
-    font-size: 0.9em;
-}
-
-table.footnote + table.footnote {
-    margin-top: -15px;
-    border-top: none;
-}
-
-table.field-list th {
-    padding: 0 0.8em 0 0;
-}
-
-table.field-list td {
-    padding: 0;
-}
-
-table.footnote td.label {
-    width: 0px;
-    padding: 0.3em 0 0.3em 0.5em;
-}
-
-table.footnote td {
-    padding: 0.3em 0.5em;
-}
-
-dl {
-    margin: 0;
-    padding: 0;
-}
-
-dl dd {
-    margin-left: 30px;
-}
-
-blockquote {
-    margin: 0 0 0 30px;
-    padding: 0;
-}
-
-ul, ol {
-    margin: 10px 0 10px 30px;
-    padding: 0;
-}
- 
-pre {
-    background: #eee;
-    padding: 7px 30px;
-    margin: 15px -30px;
-    line-height: 1.3em;
-}
-
-dl pre, blockquote pre, li pre {
-    margin-left: -60px;
-    padding-left: 60px;
-}
-
-dl dl pre {
-    margin-left: -90px;
-    padding-left: 90px;
-}
- 
-tt {
-    background-color: #ecf0f3;
-    color: #222;
-    /* padding: 1px 2px; */
-}
-
-tt.xref, a tt {
-    background-color: #FBFBFB;
-    border-bottom: 1px solid white;
-}
-
-a.reference {
-    text-decoration: none;
-    border-bottom: 1px dotted #004B6B;
-}
-
-a.reference:hover {
-    border-bottom: 1px solid #6D4100;
-}
-
-a.footnote-reference {
-    text-decoration: none;
-    font-size: 0.7em;
-    vertical-align: top;
-    border-bottom: 1px dotted #004B6B;
-}
-
-a.footnote-reference:hover {
-    border-bottom: 1px solid #6D4100;
-}
-
-a:hover tt {
-    background: #EEE;
-}
-
-
-@media screen and (max-width: 870px) {
-
-    div.sphinxsidebar {
-        display: none;
-    }
-
-    div.document {
-       width: 100%;
-
-    }
-
-    div.documentwrapper {
-        margin-left: 0;
-        margin-top: 0;
-        margin-right: 0;
-        margin-bottom: 0;
-    }
-
-    div.bodywrapper {
-        margin-top: 0;
-        margin-right: 0;
-        margin-bottom: 0;
-        margin-left: 0;
-    }
-
-    ul {
-        margin-left: 0;
-    }
-
-    .document {
-        width: auto;
-    }
-
-    .footer {
-        width: auto;
-    }
-
-    .bodywrapper {
-        margin: 0;
-    }
-
-    .footer {
-        width: auto;
-    }
-
-    .github {
-        display: none;
-    }
-
-
-
-}
-
-
-
-@media screen and (max-width: 875px) {
-
-    body {
-        margin: 0;
-        padding: 20px 30px;
-    }
-
-    div.documentwrapper {
-        float: none;
-        background: white;
-    }
-
-    div.sphinxsidebar {
-        display: block;
-        float: none;
-        width: 102.5%;
-        margin: 50px -30px -20px -30px;
-        padding: 10px 20px;
-        background: #333;
-        color: white;
-    }
-
-    div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p,
-    div.sphinxsidebar h3 a {
-        color: white;
-    }
-
-    div.sphinxsidebar a {
-        color: #aaa;
-    }
-
-    div.sphinxsidebar p.logo {
-        display: none;
-    }
-
-    div.document {
-        width: 100%;
-        margin: 0;
-    }
-
-    div.related {
-        display: block;
-        margin: 0;
-        padding: 10px 0 20px 0;
-    }
-
-    div.related ul,
-    div.related ul li {
-        margin: 0;
-        padding: 0;
-    }
-
-    div.footer {
-        display: none;
-    }
-
-    div.bodywrapper {
-        margin: 0;
-    }
-
-    div.body {
-        min-height: 0;
-        padding: 0;
-    }
-
-    .rtd_doc_footer {
-        display: none;
-    }
-
-    .document {
-        width: auto;
-    }
-
-    .footer {
-        width: auto;
-    }
-
-    .footer {
-        width: auto;
-    }
-
-    .github {
-        display: none;
-    }
-}
-
-
-/* scrollbars */
-
-::-webkit-scrollbar {
-    width: 6px;
-    height: 6px;
-}
-
-::-webkit-scrollbar-button:start:decrement,
-::-webkit-scrollbar-button:end:increment {
-    display: block;
-    height: 10px;
-}
-
-::-webkit-scrollbar-button:vertical:increment {
-    background-color: #fff;
-}
-
-::-webkit-scrollbar-track-piece {
-    background-color: #eee;
-    -webkit-border-radius: 3px;
-}
-
-::-webkit-scrollbar-thumb:vertical {
-    height: 50px;
-    background-color: #ccc;
-    -webkit-border-radius: 3px;
-}
-
-::-webkit-scrollbar-thumb:horizontal {
-    width: 50px;
-    background-color: #ccc;
-    -webkit-border-radius: 3px;
-}
-
-/* misc. */
-
-.revsys-inline {
-    display: none!important;
-}
\ No newline at end of file
diff --git a/docs/_themes/flask/theme.conf b/docs/_themes/flask/theme.conf
deleted file mode 100644
index ba22bcc7b19e6ec40b3edad02595ce0ba6240b9b..0000000000000000000000000000000000000000
--- a/docs/_themes/flask/theme.conf
+++ /dev/null
@@ -1,11 +0,0 @@
-[theme]
-inherit = basic
-stylesheet = flasky.css
-pygments_style = flask_theme_support.FlaskyStyle
-
-[options]
-index_logo =
-index_logo_height = 120px
-touch_icon =
-github_fork =
-github_ribbon_color = darkblue_121621
diff --git a/docs/_themes/flask_small/layout.html b/docs/_themes/flask_small/layout.html
deleted file mode 100644
index 67478c3bf1f51b4d85abda7073abf3246e2d145b..0000000000000000000000000000000000000000
--- a/docs/_themes/flask_small/layout.html
+++ /dev/null
@@ -1,38 +0,0 @@
-{% extends 'basic/layout.html' %}
-
-{% block extrahead %}
-    {{ super() }}
-    {% if theme_touch_icon %}
-        <link rel="apple-touch-icon" href="{{ pathto('_static/' ~ theme_touch_icon, 1) }}">
-    {% endif %}
-    <meta name="viewport" content="width=device-width, initial-scale=0.9, maximum-scale=0.9">
-{% endblock %}
-
-{% block header %}
-    {{ super() }}
-    {% if pagename == 'index' %}
-    <div class=indexwrapper>
-    {% endif %}
-{% endblock %}
-
-{% block footer %}
-    {% if pagename == 'index' %}</div>{% endif %}
-{% endblock %}
-
-{# do not display relbars or sidebars #}
-
-{% block relbar1 %}{% endblock %}
-
-{% block relbar2 %}
-    {% if theme_github_fork %}
-        <a href="http://github.com/{{ theme_github_fork }}">
-            <img style="position: fixed; top: 0; right: 0; border: 0;"
-                 src="http://s3.amazonaws.com/github/ribbons/forkme_right_darkblue_121621.png"
-                 alt="Fork me on GitHub">
-        </a>
-    {% endif %}
-{% endblock %}
-
-{% block sidebar1 %}{% endblock %}
-
-{% block sidebar2 %}{% endblock %}
diff --git a/docs/_themes/flask_small/static/flasky.css_t b/docs/_themes/flask_small/static/flasky.css_t
deleted file mode 100644
index fe2141c565e6976b6a2a5b73f318ad7c6dc3d58b..0000000000000000000000000000000000000000
--- a/docs/_themes/flask_small/static/flasky.css_t
+++ /dev/null
@@ -1,287 +0,0 @@
-/*
- * flasky.css_t
- * ~~~~~~~~~~~~
- *
- * Sphinx stylesheet -- flasky theme based on nature theme.
- *
- * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS.
- * :license: BSD, see LICENSE for details.
- *
- */
- 
-@import url("basic.css");
- 
-/* -- page layout ----------------------------------------------------------- */
- 
-body {
-    font-family: 'Georgia', serif;
-    font-size: 17px;
-    color: #000;
-    background: white;
-    margin: 0;
-    padding: 0;
-}
-
-div.documentwrapper {
-    float: left;
-    width: 100%;
-}
-
-div.bodywrapper {
-    margin: 40px auto 0 auto;
-    width: 700px;
-}
-
-hr {
-    border: 1px solid #B1B4B6;
-}
- 
-div.body {
-    background-color: #ffffff;
-    color: #3E4349;
-    padding: 0 30px 30px 30px;
-}
-
-img.floatingflask {
-    padding: 0 0 10px 10px;
-    float: right;
-}
- 
-div.footer {
-    text-align: right;
-    color: #888;
-    padding: 10px;
-    font-size: 14px;
-    width: 650px;
-    margin: 0 auto 40px auto;
-}
- 
-div.footer a {
-    color: #888;
-    text-decoration: underline;
-}
- 
-div.related {
-    line-height: 32px;
-    color: #888;
-}
-
-div.related ul {
-    padding: 0 0 0 10px;
-}
- 
-div.related a {
-    color: #444;
-}
- 
-/* -- body styles ----------------------------------------------------------- */
- 
-a {
-    color: #004B6B;
-    text-decoration: underline;
-}
- 
-a:hover {
-    color: #6D4100;
-    text-decoration: underline;
-}
-
-div.body {
-    padding-bottom: 40px; /* saved for footer */
-}
- 
-div.body h1,
-div.body h2,
-div.body h3,
-div.body h4,
-div.body h5,
-div.body h6 {
-    font-family: 'Garamond', 'Georgia', serif;
-    font-weight: normal;
-    margin: 30px 0px 10px 0px;
-    padding: 0;
-}
-
-{% if theme_index_logo %}
-div.indexwrapper h1 {
-    text-indent: -999999px;
-    background: url({{ theme_index_logo }}) no-repeat center center;
-    height: {{ theme_index_logo_height }};
-}
-{% endif %}
- 
-div.body h2 { font-size: 180%; }
-div.body h3 { font-size: 150%; }
-div.body h4 { font-size: 130%; }
-div.body h5 { font-size: 100%; }
-div.body h6 { font-size: 100%; }
- 
-a.headerlink {
-    color: white;
-    padding: 0 4px;
-    text-decoration: none;
-}
- 
-a.headerlink:hover {
-    color: #444;
-    background: #eaeaea;
-}
- 
-div.body p, div.body dd, div.body li {
-    line-height: 1.4em;
-}
-
-div.admonition {
-    background: #fafafa;
-    margin: 20px -30px;
-    padding: 10px 30px;
-    border-top: 1px solid #ccc;
-    border-bottom: 1px solid #ccc;
-}
-
-div.admonition p.admonition-title {
-    font-family: 'Garamond', 'Georgia', serif;
-    font-weight: normal;
-    font-size: 24px;
-    margin: 0 0 10px 0;
-    padding: 0;
-    line-height: 1;
-}
-
-div.admonition p.last {
-    margin-bottom: 0;
-}
-
-div.highlight{
-    background-color: white;
-}
-
-dt:target, .highlight {
-    background: #FAF3E8;
-}
-
-div.note {
-    background-color: #eee;
-    border: 1px solid #ccc;
-}
- 
-div.seealso {
-    background-color: #ffc;
-    border: 1px solid #ff6;
-}
- 
-div.topic {
-    background-color: #eee;
-}
- 
-div.warning {
-    background-color: #ffe4e4;
-    border: 1px solid #f66;
-}
- 
-p.admonition-title {
-    display: inline;
-}
- 
-p.admonition-title:after {
-    content: ":";
-}
-
-pre, tt {
-    font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace;
-    font-size: 0.85em;
-}
-
-img.screenshot {
-}
-
-tt.descname, tt.descclassname {
-    font-size: 0.95em;
-}
-
-tt.descname {
-    padding-right: 0.08em;
-}
-
-img.screenshot {
-    -moz-box-shadow: 2px 2px 4px #eee;
-    -webkit-box-shadow: 2px 2px 4px #eee;
-    box-shadow: 2px 2px 4px #eee;
-}
-
-table.docutils {
-    border: 1px solid #888;
-    -moz-box-shadow: 2px 2px 4px #eee;
-    -webkit-box-shadow: 2px 2px 4px #eee;
-    box-shadow: 2px 2px 4px #eee;
-}
-
-table.docutils td, table.docutils th {
-    border: 1px solid #888;
-    padding: 0.25em 0.7em;
-}
-
-table.field-list, table.footnote {
-    border: none;
-    -moz-box-shadow: none;
-    -webkit-box-shadow: none;
-    box-shadow: none;
-}
-
-table.footnote {
-    margin: 15px 0;
-    width: 100%;
-    border: 1px solid #eee;
-}
-
-table.field-list th {
-    padding: 0 0.8em 0 0;
-}
-
-table.field-list td {
-    padding: 0;
-}
-
-table.footnote td {
-    padding: 0.5em;
-}
-
-dl {
-    margin: 0;
-    padding: 0;
-}
-
-dl dd {
-    margin-left: 30px;
-}
- 
-pre {
-    padding: 0;
-    margin: 15px -30px;
-    padding: 8px;
-    line-height: 1.3em;
-    padding: 7px 30px;
-    background: #eee;
-    border-radius: 2px;
-    -moz-border-radius: 2px;
-    -webkit-border-radius: 2px;
-}
-
-dl pre {
-    margin-left: -60px;
-    padding-left: 60px;
-}
-
-tt {
-    background-color: #ecf0f3;
-    color: #222;
-    /* padding: 1px 2px; */
-}
-
-tt.xref, a tt {
-    background-color: #FBFBFB;
-}
-
-a:hover tt {
-    background: #EEE;
-}
diff --git a/docs/_themes/flask_small/theme.conf b/docs/_themes/flask_small/theme.conf
deleted file mode 100644
index 128c969649dfc3b66f28ce637a9fb1e2f75d067f..0000000000000000000000000000000000000000
--- a/docs/_themes/flask_small/theme.conf
+++ /dev/null
@@ -1,12 +0,0 @@
-[theme]
-inherit = basic
-stylesheet = flasky.css
-nosidebar = true
-pygments_style = flask_theme_support.FlaskyStyle
-
-[options]
-index_logo =
-index_logo_height = 120px
-touch_icon =
-github_fork =
-github_ribbon_color = darkblue_121621
diff --git a/docs/_themes/flask_theme_support.py b/docs/_themes/flask_theme_support.py
deleted file mode 100644
index 33f47449c11d241e36c83d7f95d819bbe56e2c8f..0000000000000000000000000000000000000000
--- a/docs/_themes/flask_theme_support.py
+++ /dev/null
@@ -1,86 +0,0 @@
-# flasky extensions.  flasky pygments style based on tango style
-from pygments.style import Style
-from pygments.token import Keyword, Name, Comment, String, Error, \
-     Number, Operator, Generic, Whitespace, Punctuation, Other, Literal
-
-
-class FlaskyStyle(Style):
-    background_color = "#f8f8f8"
-    default_style = ""
-
-    styles = {
-        # No corresponding class for the following:
-        #Text:                     "", # class:  ''
-        Whitespace:                "underline #f8f8f8",      # class: 'w'
-        Error:                     "#a40000 border:#ef2929", # class: 'err'
-        Other:                     "#000000",                # class 'x'
-
-        Comment:                   "italic #8f5902", # class: 'c'
-        Comment.Preproc:           "noitalic",       # class: 'cp'
-
-        Keyword:                   "bold #004461",   # class: 'k'
-        Keyword.Constant:          "bold #004461",   # class: 'kc'
-        Keyword.Declaration:       "bold #004461",   # class: 'kd'
-        Keyword.Namespace:         "bold #004461",   # class: 'kn'
-        Keyword.Pseudo:            "bold #004461",   # class: 'kp'
-        Keyword.Reserved:          "bold #004461",   # class: 'kr'
-        Keyword.Type:              "bold #004461",   # class: 'kt'
-
-        Operator:                  "#582800",   # class: 'o'
-        Operator.Word:             "bold #004461",   # class: 'ow' - like keywords
-
-        Punctuation:               "bold #000000",   # class: 'p'
-
-        # because special names such as Name.Class, Name.Function, etc.
-        # are not recognized as such later in the parsing, we choose them
-        # to look the same as ordinary variables.
-        Name:                      "#000000",        # class: 'n'
-        Name.Attribute:            "#c4a000",        # class: 'na' - to be revised
-        Name.Builtin:              "#004461",        # class: 'nb'
-        Name.Builtin.Pseudo:       "#3465a4",        # class: 'bp'
-        Name.Class:                "#000000",        # class: 'nc' - to be revised
-        Name.Constant:             "#000000",        # class: 'no' - to be revised
-        Name.Decorator:            "#888",           # class: 'nd' - to be revised
-        Name.Entity:               "#ce5c00",        # class: 'ni'
-        Name.Exception:            "bold #cc0000",   # class: 'ne'
-        Name.Function:             "#000000",        # class: 'nf'
-        Name.Property:             "#000000",        # class: 'py'
-        Name.Label:                "#f57900",        # class: 'nl'
-        Name.Namespace:            "#000000",        # class: 'nn' - to be revised
-        Name.Other:                "#000000",        # class: 'nx'
-        Name.Tag:                  "bold #004461",   # class: 'nt' - like a keyword
-        Name.Variable:             "#000000",        # class: 'nv' - to be revised
-        Name.Variable.Class:       "#000000",        # class: 'vc' - to be revised
-        Name.Variable.Global:      "#000000",        # class: 'vg' - to be revised
-        Name.Variable.Instance:    "#000000",        # class: 'vi' - to be revised
-
-        Number:                    "#990000",        # class: 'm'
-
-        Literal:                   "#000000",        # class: 'l'
-        Literal.Date:              "#000000",        # class: 'ld'
-
-        String:                    "#4e9a06",        # class: 's'
-        String.Backtick:           "#4e9a06",        # class: 'sb'
-        String.Char:               "#4e9a06",        # class: 'sc'
-        String.Doc:                "italic #8f5902", # class: 'sd' - like a comment
-        String.Double:             "#4e9a06",        # class: 's2'
-        String.Escape:             "#4e9a06",        # class: 'se'
-        String.Heredoc:            "#4e9a06",        # class: 'sh'
-        String.Interpol:           "#4e9a06",        # class: 'si'
-        String.Other:              "#4e9a06",        # class: 'sx'
-        String.Regex:              "#4e9a06",        # class: 'sr'
-        String.Single:             "#4e9a06",        # class: 's1'
-        String.Symbol:             "#4e9a06",        # class: 'ss'
-
-        Generic:                   "#000000",        # class: 'g'
-        Generic.Deleted:           "#a40000",        # class: 'gd'
-        Generic.Emph:              "italic #000000", # class: 'ge'
-        Generic.Error:             "#ef2929",        # class: 'gr'
-        Generic.Heading:           "bold #000080",   # class: 'gh'
-        Generic.Inserted:          "#00A000",        # class: 'gi'
-        Generic.Output:            "#888",           # class: 'go'
-        Generic.Prompt:            "#745334",        # class: 'gp'
-        Generic.Strong:            "bold #000000",   # class: 'gs'
-        Generic.Subheading:        "bold #800080",   # class: 'gu'
-        Generic.Traceback:         "bold #a40000",   # class: 'gt'
-    }
diff --git a/docs/authors.rst b/docs/authors.rst
index 7166c483002cdc25cd0fd9ea36cda13e67700693..08e64d3e9938577725bed0bc53a0237ab29a5ecb 100644
--- a/docs/authors.rst
+++ b/docs/authors.rst
@@ -12,4 +12,4 @@ People who send patches and suggestions:
 
 Find more contributors on GitHub_.
 
-.. _GitHub: http://github.com/lepture/flask-wtf/contributors
+.. _GitHub: https://github.com/lepture/flask-wtf/graphs/contributors
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 63c505786874dc6056dee5b33f7f89716fcccd8c..df64d5cbcdf597fc23795a77cada6cf6f8550483 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,6 +1,14 @@
 Flask-WTF Changelog
 ===================
 
+Version 0.14.3
+--------------
+
+Released 2020-02-06
+
+-   Fix deprecated imports from ``werkzeug`` and ``collections``.
+
+
 Version 0.14.2
 --------------
 
@@ -46,7 +54,7 @@ Released 2017-01-06
   (`#264`_)
 - ``CsrfProtect`` protects the ``DELETE`` method by default. (`#264`_)
 - The same CSRF token is generated for the lifetime of a request. It is exposed
-  as ``request.csrf_token`` for use during testing. (`#227`_, `#264`_)
+  as ``g.csrf_token`` for use during testing. (`#227`_, `#264`_)
 - ``CsrfProtect.error_handler`` is deprecated. (`#264`_)
 
     - Handlers that return a response work in addition to those that raise an
@@ -191,8 +199,8 @@ Released 2014/03/21
 - ``csrf_token`` for all template types `#112`_.
 - Make FileRequired a subclass of InputRequired `#108`_.
 
-.. _`#108`: https://github.com/lepture/flask-wtf/issues/108
-.. _`#112`: https://github.com/lepture/flask-wtf/issues/112
+.. _`#108`: https://github.com/lepture/flask-wtf/pull/108
+.. _`#112`: https://github.com/lepture/flask-wtf/pull/112
 
 Version 0.9.4
 -------------
@@ -212,8 +220,8 @@ Released 2013/10/02
 - Fix validation of recaptcha when app in testing mode `#89`_.
 - Bugfix for csrf module `#91`_
 
-.. _`#89`: https://github.com/lepture/flask-wtf/issues/89
-.. _`#91`: https://github.com/lepture/flask-wtf/issues/91
+.. _`#89`: https://github.com/lepture/flask-wtf/pull/89
+.. _`#91`: https://github.com/lepture/flask-wtf/pull/91
 
 
 Version 0.9.2
diff --git a/docs/conf.py b/docs/conf.py
index 0352c653acec9e4440aac2d44bae8c120ceb8b9e..4940ed0945f82ccbc640ec4e9582e874ada1f02d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -114,7 +114,7 @@ todo_include_todos = False
 # a list of builtin themes.
 
 try:
-    __import__('flask_theme_support')
+    __import__('flask_sphinx_themes')
     html_theme = 'flask'
     html_theme_options = {
         'index_logo': 'flask-wtf.png',
@@ -122,12 +122,12 @@ try:
     }
 except ImportError:
     print('-' * 72)
-    print('Flask theme not found.  Run "git submodule update --init" to get it.')
+    print('Flask theme not found. Run "pip install flask-sphinx-themes" to get it.')
     print('-' * 72)
     html_theme = 'default'
 
 # Add any paths that contain custom themes here, relative to this directory.
-html_theme_path = ['_themes']
+# html_theme_path = ['_themes']
 
 # The name for this set of Sphinx documents.
 # "<project> v<release> documentation" by default.
@@ -166,10 +166,10 @@ html_static_path = ['_static']
 #
 # html_last_updated_fmt = None
 
-# If true, SmartyPants will be used to convert quotes and dashes to
+# If true, Smart Quotes will be used to convert quotes and dashes to
 # typographically correct entities.
 #
-html_use_smartypants = False
+smartquotes = False
 
 # Custom sidebar templates, maps document names to template names.
 #
diff --git a/docs/config.rst b/docs/config.rst
index 683e3b228535d38956a02c83f7ab6c2026f46777..c3e09299298eb77f32ca58a9e9001a305a76e256 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -28,12 +28,14 @@ Recaptcha
 ---------
 
 ========================= ==============================================
-``RECAPTCHA_USE_SSL``     Enable/disable recaptcha through SSL. Default is
-                          ``False``.
 ``RECAPTCHA_PUBLIC_KEY``  **required** A public key.
 ``RECAPTCHA_PRIVATE_KEY`` **required** A private key.
-                          https://www.google.com/recaptcha/admin/create
-``RECAPTCHA_OPTIONS``     **optional** A dict of configuration options.
+                          https://www.google.com/recaptcha/admin
+``RECAPTCHA_PARAMETERS``  **optional** A dict of configuration options.
+``RECAPTCHA_HTML``        **optional** Override default HTML template
+                          for Recaptcha.
+``RECAPTCHA_DATA_ATTRS``  **optional** A dict of ``data-`` attrs to use
+                          for Recaptcha div
 ========================= ==============================================
 
 Logging
diff --git a/docs/csrf.rst b/docs/csrf.rst
index a29eae5a74d1b2c307ae05a239faa554d770ddb4..b2236b0d666c0147da9e2d530201d3d1a5cdc0dc 100644
--- a/docs/csrf.rst
+++ b/docs/csrf.rst
@@ -1,4 +1,4 @@
-.. module:: flask_wtf.csrf
+.. currentmodule:: flask_wtf.csrf
 
 .. _csrf:
 
diff --git a/docs/form.rst b/docs/form.rst
index 527954bdc930ca92514b340ca490c0f4d76a7a06..2c02e04deb4aa63ee089cb1be2e21e55b262e69e 100644
--- a/docs/form.rst
+++ b/docs/form.rst
@@ -4,14 +4,14 @@ Creating Forms
 Secure Form
 -----------
 
-.. module:: flask_wtf
+.. currentmodule:: flask_wtf
 
 Without any configuration, the :class:`FlaskForm` will be a session secure
 form with csrf protection. We encourage you do nothing.
 
 But if you want to disable the csrf protection, you can pass::
 
-    form = FlaskForm(csrf_enabled=False)
+    form = FlaskForm(meta={'csrf': False})
 
 You can disable it globally—though you really shouldn't—with the
 configuration::
@@ -28,7 +28,7 @@ another secret key, config it::
 File Uploads
 ------------
 
-.. module:: flask_wtf.file
+.. currentmodule:: flask_wtf.file
 
 The :class:`FileField` provided by Flask-WTF differs from the WTForms-provided
 field. It will check that the file is a non-empty instance of
@@ -44,6 +44,8 @@ field. It will check that the file is a non-empty instance of
 
     @app.route('/upload', methods=['GET', 'POST'])
     def upload():
+        form = PhotoForm()
+
         if form.validate_on_submit():
             f = form.photo.data
             filename = secure_filename(f.filename)
@@ -110,7 +112,7 @@ It can be used without Flask-Uploads by passing the extensions directly. ::
 Recaptcha
 ---------
 
-.. module:: flask_wtf.recaptcha
+.. currentmodule:: flask_wtf.recaptcha
 
 Flask-WTF also provides Recaptcha support through a :class:`RecaptchaField`::
 
diff --git a/docs/index.rst b/docs/index.rst
index 1a2fb1645dde7b5e57f3ee5f37a9463248e51bfc..861a92697d1ba4c723eb14339b02c7d8bc6b6e39 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -4,9 +4,8 @@ Flask-WTF
 Simple integration of `Flask`_ and `WTForms`_, including CSRF, file upload,
 and reCAPTCHA.
 
-.. _Flask: https://palletsprojects.com/p/flask
-.. _WTForms: https://wtforms.readthedocs.io/
-
+.. _Flask: https://www.palletsprojects.com/p/flask
+.. _WTForms: https://wtforms.readthedocs.io/en/latest/
 
 Features
 --------
diff --git a/docs/install.rst b/docs/install.rst
index de08bc2fc2500d8bee5a9ce533512e2668b52678..6cfcb16e9c1ca6e1847dedf6a2673c07bf0a1df0 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -1,44 +1,30 @@
 Installation
 ============
 
-This part of the documentation covers the installation of Flask-WTF.
-The first step to using any software package is getting it properly installed.
+The `Python Packaging Guide`_ contains general information about how to manage
+your project and dependencies.
 
+.. _Python Packaging Guide: https://packaging.python.org/current/
 
-Distribute & Pip
+Released version
 ----------------
 
-Installing Flask-WTF is simple with `pip <http://www.pip-installer.org/>`_::
+Install or upgrade using pip. ::
 
-    $ pip install Flask-WTF
+    pip install -U Flask-WTF
 
-or, with `easy_install <http://pypi.python.org/pypi/setuptools>`_::
+Development
+-----------
 
-    $ easy_install Flask-WTF
+The latest code is available from `GitHub`_. Clone the repository then install
+using pip. ::
 
-But, you really `shouldn't do that <https://python-packaging-user-guide.readthedocs.io/en/latest/pip_easy_install/>`_.
+    git clone https://github.com/lepture/flask-wtf
+    pip install -e ./flask-wtf
 
+Or install the latest build from an `archive`_. ::
 
-Get the Code
-------------
+    pip install -U https://github.com/lepture/flask-wtf/tarball/master
 
-Flask-WTF is actively developed on GitHub, where the code is
-`always available <https://github.com/lepture/flask-wtf>`_.
-
-You can either clone the public repository::
-
-    git clone git://github.com/lepture/flask-wtf.git
-
-Download the `tarball <https://github.com/lepture/flask-wtf/tarball/master>`_::
-
-    $ curl -OL https://github.com/lepture/flask-wtf/tarball/master
-
-Or, download the `zipball <https://github.com/lepture/flask-wtf/zipball/master>`_::
-
-    $ curl -OL https://github.com/lepture/flask-wtf/zipball/master
-
-
-Once you have a copy of the source, you can embed it in your Python package,
-or install it into your site-packages easily::
-
-    $ python setup.py install
+.. _GitHub: https://github.com/lepture/flask-wtf
+.. _archive: https://github.com/lepture/flask-wtf/archive/master.tar.gz
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
index fa34ca7847b939937635cc3df086722dbd79762f..881402fb16ea975102f389acb7ee01457b020a27 100644
--- a/docs/quickstart.rst
+++ b/docs/quickstart.rst
@@ -52,7 +52,7 @@ Validating Forms
 
 Validating the request in your view handlers::
 
-    @app.route('/submit', methods=('GET', 'POST'))
+    @app.route('/submit', methods=['GET', 'POST'])
     def submit():
         form = MyForm()
         if form.validate_on_submit():
diff --git a/flask_wtf/__init__.py b/flask_wtf/__init__.py
index 30ec506d0d65349bfb04b40f6bdf56a0ef5bfda2..d4e6f7db6d768d1d2507b55895e4d3c3a26cf85c 100644
--- a/flask_wtf/__init__.py
+++ b/flask_wtf/__init__.py
@@ -1,19 +1,7 @@
-# -*- coding: utf-8 -*-
-"""
-    flask_wtf
-    ~~~~~~~~~
-
-    Flask-WTF extension
-
-    :copyright: (c) 2010 by Dan Jacob.
-    :copyright: (c) 2013 - 2015 by Hsiaoming Yang.
-    :license: BSD, see LICENSE for more details.
-"""
-# flake8: noqa
 from __future__ import absolute_import
 
 from .csrf import CSRFProtect, CsrfProtect
 from .form import FlaskForm, Form
 from .recaptcha import *
 
-__version__ = '0.14.2'
+__version__ = '0.14.3'
diff --git a/flask_wtf/_compat.py b/flask_wtf/_compat.py
index e5956e00a6214068e03199808db41d6248158b78..c5a99234984ac8a2489eea717b8417044e4befc0 100644
--- a/flask_wtf/_compat.py
+++ b/flask_wtf/_compat.py
@@ -6,10 +6,12 @@ PY2 = sys.version_info[0] == 2
 if not PY2:
     text_type = str
     string_types = (str,)
+    from collections import abc
     from urllib.parse import urlparse
 else:
     text_type = unicode
     string_types = (str, unicode)
+    import collections as abc
     from urlparse import urlparse
 
 
@@ -32,4 +34,6 @@ class FlaskWTFDeprecationWarning(DeprecationWarning):
 
 
 warnings.simplefilter('always', FlaskWTFDeprecationWarning)
-warnings.filterwarnings('ignore', category=FlaskWTFDeprecationWarning, module='wtforms|flask_wtf')
+warnings.filterwarnings(
+    'ignore', category=FlaskWTFDeprecationWarning, module='wtforms|flask_wtf'
+)
diff --git a/flask_wtf/csrf.py b/flask_wtf/csrf.py
index 7ca569bb4ef038d463a3fab0bc930160830d0535..1d70ed28557d351952a456baf0db9234a9db03a3 100644
--- a/flask_wtf/csrf.py
+++ b/flask_wtf/csrf.py
@@ -40,11 +40,18 @@ def generate_csrf(secret_key=None, token_key=None):
     )
 
     if field_name not in g:
+        s = URLSafeTimedSerializer(secret_key, salt='wtf-csrf-token')
+
         if field_name not in session:
             session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest()
 
-        s = URLSafeTimedSerializer(secret_key, salt='wtf-csrf-token')
-        setattr(g, field_name, s.dumps(session[field_name]))
+        try:
+            token = s.dumps(session[field_name])
+        except TypeError:
+            session[field_name] = hashlib.sha1(os.urandom(64)).hexdigest()
+            token = s.dumps(session[field_name])
+
+        setattr(g, field_name, token)
 
     return g.get(field_name)
 
@@ -118,7 +125,7 @@ def _get_config(
         value = current_app.config.get(config_name, default)
 
     if required and value is None:
-        raise KeyError(message)
+        raise RuntimeError(message)
 
     return value
 
@@ -157,7 +164,7 @@ class CSRFProtect(object):
     ::
 
         app = Flask(__name__)
-        csrf = CsrfProtect(app)
+        csrf = CSRFProtect(app)
 
     Checks the ``csrf_token`` field sent with forms, or the ``X-CSRFToken``
     header sent with JavaScript requests. Render the token in templates using
@@ -205,15 +212,11 @@ class CSRFProtect(object):
             if not request.endpoint:
                 return
 
-            view = app.view_functions.get(request.endpoint)
-
-            if not view:
-                return
-
             if request.blueprint in self._exempt_blueprints:
                 return
 
-            dest = '%s.%s' % (view.__module__, view.__name__)
+            view = app.view_functions.get(request.endpoint)
+            dest = '{0}.{1}'.format(view.__module__, view.__name__)
 
             if dest in self._exempt_views:
                 return
@@ -221,11 +224,14 @@ class CSRFProtect(object):
             self.protect()
 
     def _get_csrf_token(self):
-        # find the ``csrf_token`` field in the subitted form
-        # if the form had a prefix, the name will be
-        # ``{prefix}-csrf_token``
+        # find the token in the form data
         field_name = current_app.config['WTF_CSRF_FIELD_NAME']
+        base_token = request.form.get(field_name)
+
+        if base_token:
+            return base_token
 
+        # if the form has a prefix, the name will be {prefix}-csrf_token
         for key in request.form:
             if key.endswith(field_name):
                 csrf_token = request.form[key]
@@ -233,6 +239,7 @@ class CSRFProtect(object):
                 if csrf_token:
                     return csrf_token
 
+        # find the token in the headers
         for header_name in current_app.config['WTF_CSRF_HEADERS']:
             csrf_token = request.headers.get(header_name)
 
@@ -286,7 +293,7 @@ class CSRFProtect(object):
         if isinstance(view, string_types):
             view_location = view
         else:
-            view_location = '%s.%s' % (view.__module__, view.__name__)
+            view_location = '.'.join((view.__module__, view.__name__))
 
         self._exempt_views.add(view_location)
         return view
@@ -302,8 +309,8 @@ class CSRFProtect(object):
             ``@app.errorhandler(CSRFError)`` instead. This will be removed in
             version 1.0.
 
-        The function will be passed one argument, ``reason``. By default it will
-        raise a :class:`~flask_wtf.csrf.CSRFError`. ::
+        The function will be passed one argument, ``reason``. By default it
+        will raise a :class:`~flask_wtf.csrf.CSRFError`. ::
 
             @csrf.error_handler
             def csrf_error(reason):
@@ -314,15 +321,15 @@ class CSRFProtect(object):
         """
 
         warnings.warn(FlaskWTFDeprecationWarning(
-            '"@csrf.error_handler" is deprecated. Use the standard Flask error '
-            'system with "@app.errorhandler(CSRFError)" instead. This will be'
-            'removed in 1.0.'
+            '"@csrf.error_handler" is deprecated. Use the standard Flask '
+            'error system with "@app.errorhandler(CSRFError)" instead. This '
+            'will be removed in 1.0.'
         ), stacklevel=2)
 
         @wraps(view)
         def handler(reason):
             response = current_app.make_response(view(reason))
-            raise CSRFError(response.get_data(as_text=True), response=response)
+            raise CSRFError(response=response)
 
         self._error_response = handler
         return view
diff --git a/flask_wtf/file.py b/flask_wtf/file.py
index e5851f52c225eeb29e5ce87774baf33b6e386a66..267268b534a860195fc5045bb00ba2ceb579cf29 100644
--- a/flask_wtf/file.py
+++ b/flask_wtf/file.py
@@ -1,10 +1,10 @@
 import warnings
-from collections import Iterable
 
 from werkzeug.datastructures import FileStorage
 from wtforms import FileField as _FileField
 from wtforms.validators import DataRequired, StopValidation
 
+from ._compat import abc
 from ._compat import FlaskWTFDeprecationWarning
 
 
@@ -47,12 +47,10 @@ class FileRequired(DataRequired):
 
     def __call__(self, form, field):
         if not (isinstance(field.data, FileStorage) and field.data):
-            if self.message is None:
-                message = field.gettext('This field is required.')
-            else:
-                message = self.message
+            raise StopValidation(self.message or field.gettext(
+                'This field is required.'
+            ))
 
-            raise StopValidation(message)
 
 file_required = FileRequired
 
@@ -78,7 +76,7 @@ class FileAllowed(object):
 
         filename = field.data.filename.lower()
 
-        if isinstance(self.upload_set, Iterable):
+        if isinstance(self.upload_set, abc.Iterable):
             if any(filename.endswith('.' + x) for x in self.upload_set):
                 return
 
@@ -91,4 +89,5 @@ class FileAllowed(object):
                 'File does not have an approved extension.'
             ))
 
+
 file_allowed = FileAllowed
diff --git a/flask_wtf/form.py b/flask_wtf/form.py
index b8a3d96edd139838748bfd1f46671f531e0555eb..085d51ce7e874347dceef732a1c1377793f1df2b 100644
--- a/flask_wtf/form.py
+++ b/flask_wtf/form.py
@@ -1,7 +1,6 @@
 import warnings
 
-from flask import current_app, request
-from flask import session
+from flask import current_app, request, session
 from jinja2 import Markup
 from werkzeug.datastructures import CombinedMultiDict, ImmutableMultiDict
 from werkzeug.utils import cached_property
@@ -70,7 +69,7 @@ class FlaskForm(Form):
 
         def get_translations(self, form):
             if not current_app.config.get('WTF_I18N_ENABLED', True):
-                return None
+                return super(FlaskForm.Meta, self).get_translations(form)
 
             return translations
 
@@ -80,7 +79,7 @@ class FlaskForm(Form):
         if csrf_enabled is not None:
             warnings.warn(FlaskWTFDeprecationWarning(
                 '"csrf_enabled" is deprecated and will be removed in 1.0. '
-                'Set "meta.csrf" instead.'
+                "Pass meta={'csrf': False} instead."
             ), stacklevel=3)
             kwargs['meta'] = kwargs.get('meta') or {}
             kwargs['meta'].setdefault('csrf', csrf_enabled)
diff --git a/flask_wtf/html5.py b/flask_wtf/html5.py
index 23e7927832fef1d815dbcac1e3c33857946592ec..a80cf67897f0af8369d106b6a84b185660114a01 100644
--- a/flask_wtf/html5.py
+++ b/flask_wtf/html5.py
@@ -1,7 +1,6 @@
-# coding: utf-8
-# flake8: noqa
 import warnings
-from flask_wtf._compat import FlaskWTFDeprecationWarning
+
+from ._compat import FlaskWTFDeprecationWarning
 
 warnings.warn(FlaskWTFDeprecationWarning(
     '"flask_wtf.html5" will be removed in 1.0.  '
diff --git a/flask_wtf/i18n.py b/flask_wtf/i18n.py
index 4a6dda1edcbf3fe6860dd0999fd1913e92f804d3..78cfcb29ff2082f516747c96d8129bed8574eb03 100644
--- a/flask_wtf/i18n.py
+++ b/flask_wtf/i18n.py
@@ -1,24 +1,11 @@
-# coding: utf-8
-"""
-    flask_wtf.i18n
-    ~~~~~~~~~~~~~~
-
-    Internationalization support for Flask WTF.
-
-    :copyright: (c) 2013 by Hsiaoming Yang.
-"""
-
-from flask import _request_ctx_stack
 from babel import support
+from flask import current_app, request
+from wtforms.i18n import messages_path
+
 try:
     from flask_babel import get_locale
 except ImportError:
     from flask_babelex import get_locale
-try:
-    from wtforms.i18n import messages_path
-except ImportError:
-    from wtforms.ext.i18n.utils import messages_path
-
 
 __all__ = ('Translations', 'translations')
 
@@ -27,43 +14,37 @@ def _get_translations():
     """Returns the correct gettext translations.
     Copy from flask-babel with some modifications.
     """
-    ctx = _request_ctx_stack.top
-    if ctx is None:
+
+    if not request:
         return None
+
     # babel should be in extensions for get_locale
-    if 'babel' not in ctx.app.extensions:
+    if 'babel' not in current_app.extensions:
         return None
-    translations = getattr(ctx, 'wtforms_translations', None)
+
+    translations = getattr(request, 'wtforms_translations', None)
+
     if translations is None:
-        dirname = messages_path()
         translations = support.Translations.load(
-            dirname, [get_locale()], domain='wtforms'
+            messages_path(), [get_locale()], domain='wtforms'
         )
-        ctx.wtforms_translations = translations
+        request.wtforms_translations = translations
+
     return translations
 
 
 class Translations(object):
     def gettext(self, string):
         t = _get_translations()
-        if t is None:
-            return string
-        if hasattr(t, 'ugettext'):
-            return t.ugettext(string)
-        # Python 3 has no ugettext
-        return t.gettext(string)
+        return string if t is None else t.ugettext(string)
 
     def ngettext(self, singular, plural, n):
         t = _get_translations()
+
         if t is None:
-            if n == 1:
-                return singular
-            return plural
-
-        if hasattr(t, 'ungettext'):
-            return t.ungettext(singular, plural, n)
-        # Python 3 has no ungettext
-        return t.ngettext(singular, plural, n)
+            return singular if n == 1 else plural
+
+        return t.ungettext(singular, plural, n)
 
 
 translations = Translations()
diff --git a/flask_wtf/recaptcha/validators.py b/flask_wtf/recaptcha/validators.py
index bcff23d34cca52fab79be7edb62a977d5a10556d..86bb5d9674b941ab16d076a0e37d6dfcf79f67ad 100644
--- a/flask_wtf/recaptcha/validators.py
+++ b/flask_wtf/recaptcha/validators.py
@@ -4,11 +4,13 @@ except ImportError:
     # Python 3
     from urllib import request as http
 
-from flask import request, current_app
+import json
+
+from flask import current_app, request
+from werkzeug.urls import url_encode
 from wtforms import ValidationError
-from werkzeug import url_encode
+
 from .._compat import to_bytes, to_unicode
-import json
 
 RECAPTCHA_VERIFY_SERVER = 'https://www.google.com/recaptcha/api/siteverify'
 RECAPTCHA_ERROR_CODES = {
diff --git a/flask_wtf/recaptcha/widgets.py b/flask_wtf/recaptcha/widgets.py
index 71a100650ab86891ef4770abdb0d4681e217a540..9cdc8b18bdf92fd6d6c349ef9d33e21c245565a7 100644
--- a/flask_wtf/recaptcha/widgets.py
+++ b/flask_wtf/recaptcha/widgets.py
@@ -1,18 +1,17 @@
 # -*- coding: utf-8 -*-
 
-from flask import current_app, Markup
-from flask import json
-from werkzeug import url_encode
+from flask import Markup, current_app, json
+from werkzeug.urls import url_encode
+
 JSONEncoder = json.JSONEncoder
 
 RECAPTCHA_SCRIPT = u'https://www.google.com/recaptcha/api.js'
-
 RECAPTCHA_TEMPLATE = u'''
 <script src='%s' async defer></script>
 <div class="g-recaptcha" %s></div>
 '''
 
-__all__ = ["RecaptchaWidget"]
+__all__ = ['RecaptchaWidget']
 
 
 class RecaptchaWidget(object):
@@ -37,6 +36,6 @@ class RecaptchaWidget(object):
         try:
             public_key = current_app.config['RECAPTCHA_PUBLIC_KEY']
         except KeyError:
-            raise RuntimeError("RECAPTCHA_PUBLIC_KEY config not set")
+            raise RuntimeError('RECAPTCHA_PUBLIC_KEY config not set')
 
         return self.recaptcha_html(public_key)
diff --git a/setup.cfg b/setup.cfg
index 6d926a06d1f02a0333e59af3365f52068a5fd203..10f47629adca9ac51237265ffde7a25b8321a98d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,14 +1,15 @@
-[egg_info]
-tag_build = 
-tag_date = 0
-tag_svn_revision = 0
-
-[aliases]
-release = egg_info -RDb ''
-
 [bdist_wheel]
 universal = 1
 
 [metadata]
 license_file = LICENSE
+long_description_content_type = text/x-rst
+
+[tool:pytest]
+minversion = 3.0
+testpaths = tests
+
+[egg_info]
+tag_build = 
+tag_date = 0
 
diff --git a/setup.py b/setup.py
index 365d5b904d717ca022eeac5ff9aed50a5a6dcba0..a67877eccd3d74b07d0a2dd677b58eb5e3ce7572 100755
--- a/setup.py
+++ b/setup.py
@@ -1,12 +1,12 @@
 #!/usr/bin/env python
-from setuptools import setup, find_packages
+from setuptools import find_packages, setup
 
 with open('README.rst') as f:
     readme = f.read()
 
 setup(
     name='Flask-WTF',
-    version='0.14.2',
+    version='0.14.3',
     url='https://github.com/lepture/flask-wtf',
     license='BSD',
     author='Dan Jacob',
@@ -21,6 +21,7 @@ setup(
     install_requires=[
         'Flask',
         'WTForms',
+        'itsdangerous',
     ],
     classifiers=[
         'Development Status :: 5 - Production/Stable',
@@ -31,13 +32,13 @@ setup(
         'Operating System :: OS Independent',
         'Programming Language :: Python',
         'Programming Language :: Python :: 2',
-        'Programming Language :: Python :: 2.6',
         'Programming Language :: Python :: 2.7',
         'Programming Language :: Python :: 3',
-        'Programming Language :: Python :: 3.3',
         'Programming Language :: Python :: 3.4',
         'Programming Language :: Python :: 3.5',
         'Programming Language :: Python :: 3.6',
+        'Programming Language :: Python :: 3.7',
+        'Programming Language :: Python :: 3.8',
         'Programming Language :: Python :: Implementation :: CPython',
         'Programming Language :: Python :: Implementation :: PyPy',
         'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
diff --git a/test-requirements.txt b/test-requirements.txt
deleted file mode 100644
index 4d4397a5ff73722d5ddbae67f67ab8c0ae87a8be..0000000000000000000000000000000000000000
--- a/test-requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
--e .
-flask-babel
-flask-uploads
-nose
-coverage
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000
diff --git a/tests/base.py b/tests/base.py
deleted file mode 100644
index 70684f0b1f5de90220016bb922fc9046776cddff..0000000000000000000000000000000000000000
--- a/tests/base.py
+++ /dev/null
@@ -1,133 +0,0 @@
-from __future__ import with_statement
-
-from contextlib import contextmanager
-from unittest import TestCase as _TestCase
-
-import logging
-from flask import Flask, jsonify, render_template
-from wtforms import HiddenField, StringField, SubmitField
-from wtforms.validators import DataRequired
-
-from flask_wtf import FlaskForm
-from flask_wtf._compat import text_type
-
-
-def to_unicode(text):
-    if not isinstance(text, text_type):
-        return text.decode('utf-8')
-    return text
-
-
-class MyForm(FlaskForm):
-    SECRET_KEY = "a poorly kept secret."
-    name = StringField("Name", validators=[DataRequired()])
-    submit = SubmitField("Submit")
-
-
-class HiddenFieldsForm(FlaskForm):
-    SECRET_KEY = "a poorly kept secret."
-    name = HiddenField()
-    url = HiddenField()
-    method = HiddenField()
-    secret = HiddenField()
-    submit = SubmitField("Submit")
-
-    def __init__(self, *args, **kwargs):
-        super(HiddenFieldsForm, self).__init__(*args, **kwargs)
-        self.method.name = '_method'
-
-
-class SimpleForm(FlaskForm):
-    SECRET_KEY = "a poorly kept secret."
-    pass
-
-
-class CaptureHandler(logging.Handler):
-    def __init__(self):
-        self.records = []
-        logging.Handler.__init__(self, logging.DEBUG)
-
-    def emit(self, record):
-        self.records.append(record)
-
-    def __iter__(self):
-        return iter(self.records)
-
-    def __len__(self):
-        return len(self.records)
-
-    def __getitem__(self, item):
-        return self.records[item]
-
-
-@contextmanager
-def capture_logging(logger):
-    handler = CaptureHandler()
-
-    try:
-        logger.addHandler(handler)
-        yield handler
-    finally:
-        logger.removeHandler(handler)
-
-
-class TestCase(_TestCase):
-    def setUp(self):
-        self.app = self.create_app()
-        self.client = self.app.test_client()
-
-    def create_app(self):
-        app = Flask(__name__)
-        app.secret_key = "secret"
-
-        @app.route("/", methods=("GET", "POST"))
-        def index():
-
-            form = MyForm()
-            if form.validate_on_submit():
-                name = form.name.data.upper()
-            else:
-                name = ''
-
-            return render_template("index.html",
-                                   form=form,
-                                   name=name)
-
-        @app.route("/simple/", methods=("POST",))
-        def simple():
-            form = SimpleForm()
-            form.validate()
-            assert form.meta.csrf
-            assert not form.validate()
-            return "OK"
-
-        @app.route("/two_forms/", methods=("POST",))
-        def two_forms():
-            form = SimpleForm()
-            assert form.meta.csrf
-            assert form.validate()
-            assert form.validate()
-            form2 = SimpleForm()
-            assert form2.meta.csrf
-            assert form2.validate()
-            return "OK"
-
-        @app.route("/hidden/")
-        def hidden():
-
-            form = HiddenFieldsForm()
-            return render_template("hidden.html", form=form)
-
-        @app.route("/ajax/", methods=("POST",))
-        def ajax_submit():
-            form = MyForm()
-            if form.validate_on_submit():
-                return jsonify(name=form.name.data,
-                               success=True,
-                               errors=None)
-
-            return jsonify(name=None,
-                           #errors=form.errors,
-                           success=False)
-
-        return app
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000000000000000000000000000000000000..61582a5866f26faed2f410f5a559ef7679604957
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,36 @@
+import pytest
+from flask import Flask as _Flask
+
+
+class Flask(_Flask):
+    testing = True
+    secret_key = __name__
+
+    def make_response(self, rv):
+        if rv is None:
+            rv = ''
+
+        return super(Flask, self).make_response(rv)
+
+
+@pytest.fixture
+def app():
+    app = Flask(__name__)
+    return app
+
+
+@pytest.yield_fixture
+def app_ctx(app):
+    with app.app_context() as ctx:
+        yield ctx
+
+
+@pytest.yield_fixture
+def req_ctx(app):
+    with app.test_request_context() as ctx:
+        yield ctx
+
+
+@pytest.fixture
+def client(app):
+    return app.test_client()
diff --git a/tests/flask.png b/tests/flask.png
deleted file mode 100644
index f98ce466e407b10035f04aff906a34a5ffe8ef96..0000000000000000000000000000000000000000
Binary files a/tests/flask.png and /dev/null differ
diff --git a/tests/flask.txt b/tests/flask.txt
deleted file mode 100644
index b5c4d04e99e907e3b05b056b18473b314d16fc02..0000000000000000000000000000000000000000
--- a/tests/flask.txt
+++ /dev/null
@@ -1 +0,0 @@
-Hello Flask.
diff --git a/tests/templates/csrf.html b/tests/templates/csrf.html
deleted file mode 100644
index 42257486b95f21e9cee4aea7a8ec927821ec667f..0000000000000000000000000000000000000000
--- a/tests/templates/csrf.html
+++ /dev/null
@@ -1,7 +0,0 @@
-<!DOCTYPE html>
-
-<html>
-    <body>
-        token: {{ csrf_token() }}
-    </body>
-</html>
diff --git a/tests/templates/csrf_macro.html b/tests/templates/csrf_macro.html
deleted file mode 100644
index 3e68e62d7f9ff6dca93b150b188267468996d7a3..0000000000000000000000000000000000000000
--- a/tests/templates/csrf_macro.html
+++ /dev/null
@@ -1,3 +0,0 @@
-{% macro render_csrf_token() %}
-  <input name="csrf_token" type="hidden" value="{{ csrf_token() }}">
-{% endmacro %}
diff --git a/tests/templates/hidden.html b/tests/templates/hidden.html
deleted file mode 100644
index 3cd7f9b7dd6b3b68c21779e2d123cb9aef0d5214..0000000000000000000000000000000000000000
--- a/tests/templates/hidden.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-<!DOCTYPE html>
-
-<html>
-    <body>
-        {% if name %}
-        <p>{{ name }}</p>
-        {% endif %}
-
-        {{ form.errors }}
-        <form method="POST" enctype="multipart/form-data">
-            {{ form.hidden_tag() }}
-            <p>
-                {{ form.submit }}
-            </p>
-        </form>
-    </body>
-</html>
diff --git a/tests/templates/import_csrf.html b/tests/templates/import_csrf.html
deleted file mode 100644
index bec50fa74f5c4a59d524f2bb755250e210305291..0000000000000000000000000000000000000000
--- a/tests/templates/import_csrf.html
+++ /dev/null
@@ -1,2 +0,0 @@
-{% import "csrf_macro.html" as h %}
-{{ h.render_csrf_token() }}
diff --git a/tests/templates/index.html b/tests/templates/index.html
deleted file mode 100644
index 229f09d701fa2d98dff0bd1156529c4d5f6b16bf..0000000000000000000000000000000000000000
--- a/tests/templates/index.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-<!DOCTYPE html>
-
-<html>
-    <body>
-        {% if name %}
-        <p>{{ name }}</p>
-        {% endif %}
-
-        {{ form.errors }}
-        <form method="POST" enctype="multipart/form-data">
-            {{ form.hidden_tag() }}
-            <p>
-                {{ form.name.label }}
-                {{ form.name }}
-            </p>
-            <p>
-                {{ form.submit }}
-            </p>
-        </form>
-    </body>
-</html>
diff --git a/tests/templates/recaptcha.html b/tests/templates/recaptcha.html
deleted file mode 100644
index d138f16e4ea03f323448118b04c6b7d82cd53999..0000000000000000000000000000000000000000
--- a/tests/templates/recaptcha.html
+++ /dev/null
@@ -1,17 +0,0 @@
-<!DOCTYPE html>
-
-<html>
-    <body>
-        {{ form.errors }}
-        <form method="POST">
-            {{ form.hidden_tag() }}
-            <p>
-                {{ form.recaptcha }}
-            </p>
-            <p>
-                {{ form.submit }}
-            </p>
-        </form>
-    </body>
-</html>
-
diff --git a/tests/templates/upload.html b/tests/templates/upload.html
deleted file mode 100644
index ab24a180600cd0252558eec3b16653dff37ef0e3..0000000000000000000000000000000000000000
--- a/tests/templates/upload.html
+++ /dev/null
@@ -1,20 +0,0 @@
-
-<!DOCTYPE html>
-
-<html>
-    <body>
-        {% if filedata %}
-        <h3>{{ filedata.filename }}</h3>
-        {% endif %}
-        <form method="POST" enctype="multipart/form-data">
-            {{ form.hidden_tag() }}
-            <p>
-                {{ form.upload.label }}
-                {{ form.upload }}
-            </p>
-            <p>
-                <input type="submit" value="Submit">
-            </p>
-        </form>
-    </body>
-</html>
diff --git a/tests/test_csrf.py b/tests/test_csrf.py
deleted file mode 100644
index 942e68f5a4ec794a56513ff3bacc4da02505ff53..0000000000000000000000000000000000000000
--- a/tests/test_csrf.py
+++ /dev/null
@@ -1,321 +0,0 @@
-from __future__ import with_statement
-
-import re
-import warnings
-
-from flask import Blueprint, abort, render_template, request
-from flask import g
-from wtforms import ValidationError
-
-from flask_wtf._compat import FlaskWTFDeprecationWarning
-from flask_wtf.csrf import CSRFError, CSRFProtect, generate_csrf, validate_csrf
-from .base import MyForm, TestCase
-
-
-class TestCSRF(TestCase):
-    def setUp(self):
-        app = self.create_app()
-        app.config['WTF_CSRF_SECRET_KEY'] = "a poorly kept secret."
-        csrf = CSRFProtect(app)
-        self.csrf = csrf
-
-        @csrf.exempt
-        @app.route('/csrf-exempt', methods=['GET', 'POST'])
-        def csrf_exempt():
-            form = MyForm()
-            if form.validate_on_submit():
-                name = form.name.data.upper()
-            else:
-                name = ''
-
-            return render_template(
-                "index.html", form=form, name=name
-            )
-
-        @csrf.exempt
-        @app.route('/csrf-protect-method', methods=['GET', 'POST'])
-        def csrf_protect_method():
-            csrf.protect()
-            return 'protected'
-
-        bp = Blueprint('csrf', __name__)
-
-        @bp.route('/foo', methods=['GET', 'POST'])
-        def foo():
-            return 'foo'
-
-        app.register_blueprint(bp, url_prefix='/bar')
-        self.bp = bp
-        self.app = app
-        self.client = self.app.test_client()
-
-    def test_invalid_csrf(self):
-        response = self.client.post("/", data={"name": "danny"})
-        assert response.status_code == 400
-
-        @self.app.errorhandler(CSRFError)
-        def handle_csrf_error(e):
-            return e, 200
-
-        response = self.client.post("/", data={"name": "danny"})
-        assert response.status_code == 200
-        assert b'The CSRF token is missing.' in response.data
-
-    def test_invalid_csrf2(self):
-        # tests with bad token
-        response = self.client.post("/", data={
-            "name": "danny",
-            "csrf_token": "9999999999999##test"
-            # will work only if greater than time.time()
-        })
-        assert response.status_code == 400
-
-    def test_invalid_secure_csrf3(self):
-        # test with multiple separators
-        response = self.client.post("/", data={
-            "name": "danny",
-            "csrf_token": "1378915137.722##foo##bar##and"
-            # will work only if greater than time.time()
-        })
-        assert response.status_code == 400
-
-    def test_valid_csrf(self):
-        with self.client:
-            self.client.get('/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post("/", data={
-            "name": "danny",
-            "csrf_token": csrf_token
-        })
-        assert b'DANNY' in response.data
-
-    def test_prefixed_csrf(self):
-        with self.client:
-            self.client.get('/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post('/', data={
-            'prefix-name': 'David',
-            'prefix-csrf_token': csrf_token,
-        })
-        assert response.status_code == 200
-
-    def test_invalid_secure_csrf(self):
-        with self.client:
-            self.client.get('/', base_url='https://localhost/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={'X-CSRFToken': csrf_token},
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 400
-        assert b'The referrer header is missing.' in response.data
-
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={
-                'X-CSRFToken': csrf_token,
-            },
-            environ_base={
-                'HTTP_REFERER': 'https://example.com/',
-            },
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 400
-        assert b'The referrer does not match the host.' in response.data
-
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={
-                'X-CSRFToken': csrf_token,
-            },
-            environ_base={
-                'HTTP_REFERER': 'http://localhost/',
-            },
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 400
-        assert b'The referrer does not match the host.' in response.data
-
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={
-                'X-CSRFToken': csrf_token,
-            },
-            environ_base={
-                'HTTP_REFERER': 'https://localhost:3000/',
-            },
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 400
-        assert b'The referrer does not match the host.' in response.data
-
-    def test_valid_secure_csrf(self):
-        with self.client:
-            self.client.get('/', base_url='https://localhost/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={
-                'X-CSRFToken': csrf_token,
-            },
-            environ_base={
-                'HTTP_REFERER': 'https://localhost/',
-            },
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 200
-
-    def test_valid_csrf_method(self):
-        with self.client:
-            self.client.get('/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post("/csrf-protect-method", data={
-            "csrf_token": csrf_token
-        })
-        assert response.status_code == 200
-
-    def test_empty_csrf_headers(self):
-        with self.client:
-            self.client.get('/', base_url='https://localhost/')
-            csrf_token = g.csrf_token
-
-        self.app.config['WTF_CSRF_HEADERS'] = list()
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={
-                'X-CSRFToken': csrf_token,
-            },
-            environ_base={
-                'HTTP_REFERER': 'https://localhost/',
-            },
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 400
-
-    def test_custom_csrf_headers(self):
-        with self.client:
-            self.client.get('/', base_url='https://localhost/')
-            csrf_token = g.csrf_token
-
-        self.app.config['WTF_CSRF_HEADERS'] = ['X-XSRF-TOKEN']
-        response = self.client.post(
-            "/",
-            data={"name": "danny"},
-            headers={
-                'X-XSRF-TOKEN': csrf_token,
-            },
-            environ_base={
-                'HTTP_REFERER': 'https://localhost/',
-            },
-            base_url='https://localhost/',
-        )
-        assert response.status_code == 200
-
-    def test_not_endpoint(self):
-        response = self.client.post('/not-endpoint')
-        assert response.status_code == 404
-
-    def test_testing(self):
-        self.app.testing = True
-        self.client.post("/", data={"name": "danny"})
-
-    def test_csrf_exempt_view_with_form(self):
-        with self.client:
-            self.client.get('/', base_url='https://localhost/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post("/csrf-exempt", data={
-            "name": "danny",
-            "csrf_token": csrf_token
-        })
-        assert b'DANNY' in response.data
-
-    def test_validate_csrf(self):
-        with self.app.test_request_context():
-            self.assertRaises(ValidationError, validate_csrf, None)
-            self.assertRaises(ValidationError, validate_csrf, 'invalid')
-            validate_csrf(generate_csrf())
-
-    def test_validate_not_expiring_csrf(self):
-        with self.app.test_request_context():
-            csrf_token = generate_csrf()
-            validate_csrf(csrf_token, time_limit=False)
-
-    def test_csrf_token_helper(self):
-        @self.app.route("/token")
-        def withtoken():
-            return render_template("csrf.html")
-
-        with self.client:
-            response = self.client.get('/token')
-            assert re.search(br'token: ([0-9a-zA-Z\-._]+)', response.data)
-
-    def test_csrf_blueprint(self):
-        response = self.client.post('/bar/foo')
-        assert response.status_code == 400
-
-        self.csrf.exempt(self.bp)
-        response = self.client.post('/bar/foo')
-        assert response.status_code == 200
-
-    def test_csrf_token_macro(self):
-        @self.app.route("/token")
-        def withtoken():
-            return render_template("import_csrf.html")
-
-        with self.client:
-            response = self.client.get('/token')
-            assert g.csrf_token in response.data.decode('utf8')
-
-    def test_csrf_custom_token_key(self):
-        with self.app.test_request_context():
-            # Generate a normal and a custom CSRF token
-            default_csrf_token = generate_csrf()
-            custom_csrf_token = generate_csrf(token_key='oauth_state')
-
-            # Verify they are different due to using different session keys
-            self.assertNotEqual(default_csrf_token, custom_csrf_token)
-
-            # However, the custom key can validate as well
-            validate_csrf(custom_csrf_token, token_key='oauth_state')
-
-    def test_old_error_handler(self):
-        with warnings.catch_warnings(record=True) as w:
-            warnings.simplefilter('always', FlaskWTFDeprecationWarning)
-
-            @self.csrf.error_handler
-            def handle_csrf_error(reason):
-                return 'caught csrf return'
-
-            self.assertEqual(len(w), 1)
-            assert issubclass(w[0].category, FlaskWTFDeprecationWarning)
-            assert 'app.errorhandler(CSRFError)' in str(w[0].message)
-
-            rv = self.client.post('/', data={'name': 'david'})
-            assert b'caught csrf return' in rv.data
-
-        with warnings.catch_warnings(record=True) as w:
-            warnings.simplefilter('always', FlaskWTFDeprecationWarning)
-
-            @self.csrf.error_handler
-            def handle_csrf_error(reason):
-                abort(401, 'caught csrf abort')
-
-            self.assertEqual(len(w), 1)
-            assert issubclass(w[0].category, FlaskWTFDeprecationWarning)
-            assert 'app.errorhandler(CSRFError)' in str(w[0].message)
-
-            rv = self.client.post('/', data={'name': 'david'})
-            assert b'caught csrf abort' in rv.data
diff --git a/tests/test_csrf_extension.py b/tests/test_csrf_extension.py
new file mode 100644
index 0000000000000000000000000000000000000000..456405d4b5effc1eaa4a2da967984c6f33e74b01
--- /dev/null
+++ b/tests/test_csrf_extension.py
@@ -0,0 +1,194 @@
+import pytest
+from flask import Blueprint, abort, g, render_template_string, request
+
+from flask_wtf import FlaskForm
+from flask_wtf._compat import FlaskWTFDeprecationWarning
+from flask_wtf.csrf import CSRFError, CSRFProtect, CsrfProtect, generate_csrf
+
+
+@pytest.fixture
+def app(app):
+    CSRFProtect(app)
+
+    @app.route('/', methods=['GET', 'POST'])
+    def index():
+        pass
+
+    @app.after_request
+    def add_csrf_header(response):
+        response.headers.set('X-CSRF-Token', generate_csrf())
+        return response
+
+    return app
+
+
+@pytest.fixture
+def csrf(app):
+    return app.extensions['csrf']
+
+
+def test_render_token(req_ctx):
+    token = generate_csrf()
+    assert render_template_string('{{ csrf_token() }}') == token
+
+
+def test_protect(app, client, app_ctx):
+    response = client.post('/')
+    assert response.status_code == 400
+    assert 'The CSRF token is missing.' in response.get_data(as_text=True)
+
+    app.config['WTF_CSRF_ENABLED'] = False
+    assert client.post('/').get_data() == b''
+    app.config['WTF_CSRF_ENABLED'] = True
+
+    app.config['WTF_CSRF_CHECK_DEFAULT'] = False
+    assert client.post('/').get_data() == b''
+    app.config['WTF_CSRF_CHECK_DEFAULT'] = True
+
+    assert client.options('/').status_code == 200
+    assert client.post('/not-found').status_code == 404
+
+    response = client.get('/')
+    assert response.status_code == 200
+    token = response.headers['X-CSRF-Token']
+    assert client.post('/', data={'csrf_token': token}).status_code == 200
+    assert client.post(
+        '/', data={'prefix-csrf_token': token}
+    ).status_code == 200
+    assert client.post('/', data={'prefix-csrf_token': ''}).status_code == 400
+    assert client.post('/', headers={'X-CSRF-Token': token}).status_code == 200
+
+
+def test_same_origin(client):
+    token = client.get('/').headers['X-CSRF-Token']
+    response = client.post('/', base_url='https://localhost', headers={
+        'X-CSRF-Token': token
+    })
+    data = response.get_data(as_text=True)
+    assert 'The referrer header is missing.' in data
+
+    response = client.post('/', base_url='https://localhost', headers={
+        'X-CSRF-Token': token, 'Referer': 'http://localhost/'
+    })
+    data = response.get_data(as_text=True)
+    assert 'The referrer does not match the host.' in data
+
+    response = client.post('/', base_url='https://localhost', headers={
+        'X-CSRF-Token': token, 'Referer': 'https://other/'
+    })
+    data = response.get_data(as_text=True)
+    assert 'The referrer does not match the host.' in data
+
+    response = client.post('/', base_url='https://localhost', headers={
+        'X-CSRF-Token': token, 'Referer': 'https://localhost:8080/'
+    })
+    data = response.get_data(as_text=True)
+    assert 'The referrer does not match the host.' in data
+
+    response = client.post('/', base_url='https://localhost', headers={
+        'X-CSRF-Token': token, 'Referer': 'https://localhost/'
+    })
+    assert response.status_code == 200
+
+
+def test_form_csrf_short_circuit(app, client):
+    @app.route('/skip', methods=['POST'])
+    def skip():
+        assert g.get('csrf_valid')
+        # don't pass the token, then validate the form
+        # this would fail if CSRFProtect didn't run
+        form = FlaskForm(None)
+        assert form.validate()
+
+    token = client.get('/').headers['X-CSRF-Token']
+    response = client.post('/skip', headers={'X-CSRF-Token': token})
+    assert response.status_code == 200
+
+
+def test_exempt_view(app, csrf, client):
+    @app.route('/exempt', methods=['POST'])
+    @csrf.exempt
+    def exempt():
+        pass
+
+    response = client.post('/exempt')
+    assert response.status_code == 200
+
+    csrf.exempt('test_csrf_extension.index')
+    response = client.post('/')
+    assert response.status_code == 200
+
+
+def test_manual_protect(app, csrf, client):
+    @app.route('/manual', methods=['GET', 'POST'])
+    @csrf.exempt
+    def manual():
+        csrf.protect()
+
+    response = client.get('/manual')
+    assert response.status_code == 200
+
+    response = client.post('/manual')
+    assert response.status_code == 400
+
+
+def test_exempt_blueprint(app, csrf, client):
+    bp = Blueprint('exempt', __name__, url_prefix='/exempt')
+    csrf.exempt(bp)
+
+    @bp.route('/', methods=['POST'])
+    def index():
+        pass
+
+    app.register_blueprint(bp)
+    response = client.post('/exempt/')
+    assert response.status_code == 200
+
+
+def test_error_handler(app, client):
+    @app.errorhandler(CSRFError)
+    def handle_csrf_error(e):
+        return e.description.lower()
+
+    response = client.post('/')
+    assert response.get_data(as_text=True) == 'the csrf token is missing.'
+
+
+def test_validate_error_logged(client, monkeypatch):
+    from flask_wtf.csrf import logger
+
+    messages = []
+
+    def assert_info(message):
+        messages.append(message)
+
+    monkeypatch.setattr(logger, 'info', assert_info)
+
+    client.post('/')
+    assert len(messages) == 1
+    assert messages[0] == 'The CSRF token is missing.'
+
+
+def test_deprecated_csrfprotect(recwarn):
+    CsrfProtect()
+    w = recwarn.pop(FlaskWTFDeprecationWarning)
+    assert 'CSRFProtect' in str(w.message)
+
+
+def test_deprecated_error_handler(csrf, client, recwarn):
+    @csrf.error_handler
+    def handle_csrf_error(reason):
+        if 'abort' in request.form:
+            abort(418)
+
+        return 'return'
+
+    w = recwarn.pop(FlaskWTFDeprecationWarning)
+    assert '@app.errorhandler' in str(w.message)
+
+    response = client.post('/', data={'abort': '1'})
+    assert response.status_code == 418
+
+    response = client.post('/')
+    assert response.status_code == 200
+    assert 'return' in response.get_data(as_text=True)
diff --git a/tests/test_csrf_form.py b/tests/test_csrf_form.py
new file mode 100644
index 0000000000000000000000000000000000000000..011edaced584a26fee8c032145db2d81dcedb58f
--- /dev/null
+++ b/tests/test_csrf_form.py
@@ -0,0 +1,96 @@
+import pytest
+from flask import g, session
+from wtforms import ValidationError
+
+from flask_wtf import FlaskForm
+from flask_wtf.csrf import generate_csrf, validate_csrf
+
+
+def test_csrf_requires_secret_key(app, req_ctx):
+    # use secret key set by test setup
+    generate_csrf()
+    # fail with no key
+    app.secret_key = None
+    pytest.raises(RuntimeError, generate_csrf)
+    # use WTF_CSRF config
+    app.config['WTF_CSRF_SECRET_KEY'] = 'wtf_secret'
+    generate_csrf()
+    del app.config['WTF_CSRF_SECRET_KEY']
+    # use direct argument
+    generate_csrf(secret_key='direct')
+
+
+def test_token_stored_by_generate(req_ctx):
+    generate_csrf()
+    assert 'csrf_token' in session
+    assert 'csrf_token' in g
+
+
+def test_custom_token_key(req_ctx):
+    generate_csrf(token_key='oauth_token')
+    assert 'oauth_token' in session
+    assert 'oauth_token' in g
+
+
+def test_token_cached(req_ctx):
+    assert generate_csrf() == generate_csrf()
+
+
+def test_validate(req_ctx):
+    validate_csrf(generate_csrf())
+
+
+def test_validation_errors(req_ctx):
+    e = pytest.raises(ValidationError, validate_csrf, None)
+    assert str(e.value) == 'The CSRF token is missing.'
+
+    e = pytest.raises(ValidationError, validate_csrf, 'no session')
+    assert str(e.value) == 'The CSRF session token is missing.'
+
+    token = generate_csrf()
+    e = pytest.raises(ValidationError, validate_csrf, token, time_limit=-1)
+    assert str(e.value) == 'The CSRF token has expired.'
+
+    e = pytest.raises(ValidationError, validate_csrf, 'invalid')
+    assert str(e.value) == 'The CSRF token is invalid.'
+
+    other_token = generate_csrf(token_key='other_csrf')
+    e = pytest.raises(ValidationError, validate_csrf, other_token)
+    assert str(e.value) == 'The CSRF tokens do not match.'
+
+
+def test_form_csrf(app, client, app_ctx):
+    @app.route('/', methods=['GET', 'POST'])
+    def index():
+        f = FlaskForm()
+
+        if f.validate_on_submit():
+            return 'good'
+
+        if f.errors:
+            return f.csrf_token.errors[0]
+
+        return f.csrf_token.current_token
+
+    response = client.get('/')
+    assert response.get_data(as_text=True) == g.csrf_token
+
+    response = client.post('/')
+    assert response.get_data(as_text=True) == 'The CSRF token is missing.'
+
+    response = client.post('/', data={'csrf_token': g.csrf_token})
+    assert response.get_data(as_text=True) == 'good'
+
+
+def test_validate_error_logged(req_ctx, monkeypatch):
+    from flask_wtf.csrf import logger
+
+    messages = []
+
+    def assert_info(message):
+        messages.append(message)
+
+    monkeypatch.setattr(logger, 'info', assert_info)
+    FlaskForm().validate()
+    assert len(messages) == 1
+    assert messages[0] == 'The CSRF token is missing.'
diff --git a/tests/test_deprecated.py b/tests/test_deprecated.py
deleted file mode 100644
index 7c72c5686d7f90f94b26a6f1f24601df438fe22d..0000000000000000000000000000000000000000
--- a/tests/test_deprecated.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import warnings
-from unittest import TestCase
-
-from wtforms.compat import with_metaclass
-from wtforms.form import FormMeta
-
-from flask_wtf import CsrfProtect, FlaskForm, Form
-from flask_wtf._compat import FlaskWTFDeprecationWarning
-
-
-class TestDeprecated(TestCase):
-    def test_deprecated_form(self):
-        with warnings.catch_warnings():
-            warnings.simplefilter('error', FlaskWTFDeprecationWarning)
-
-            class F1(Form):
-                pass
-
-            self.assertRaises(FlaskWTFDeprecationWarning, F1)
-
-            class FMeta(FormMeta):
-                pass
-
-            class F2(with_metaclass(FMeta, Form)):
-                pass
-
-            self.assertRaises(FlaskWTFDeprecationWarning, F2)
-
-    def test_deprecated_html5(self):
-        with warnings.catch_warnings():
-            warnings.simplefilter('error', FlaskWTFDeprecationWarning)
-            self.assertRaises(FlaskWTFDeprecationWarning, __import__, 'flask_wtf.html5')
-
-    def test_deprecated_csrf_enabled(self):
-        class F(FlaskForm):
-            pass
-
-        with warnings.catch_warnings():
-            warnings.simplefilter('error', FlaskWTFDeprecationWarning)
-            self.assertRaises(FlaskWTFDeprecationWarning, F, csrf_enabled=False)
-
-    def test_deprecated_csrfprotect(self):
-        with warnings.catch_warnings():
-            warnings.simplefilter('error', FlaskWTFDeprecationWarning)
-            self.assertRaises(FlaskWTFDeprecationWarning, CsrfProtect)
diff --git a/tests/test_file.py b/tests/test_file.py
new file mode 100644
index 0000000000000000000000000000000000000000..4843101b52510c2959668bde6d6019928afe436d
--- /dev/null
+++ b/tests/test_file.py
@@ -0,0 +1,95 @@
+import pytest
+from werkzeug.datastructures import FileStorage, MultiDict
+from wtforms import FileField as BaseFileField
+
+from flask_wtf import FlaskForm
+from flask_wtf._compat import FlaskWTFDeprecationWarning
+from flask_wtf.file import FileAllowed, FileField, FileRequired
+
+
+@pytest.fixture
+def form(req_ctx):
+    class UploadForm(FlaskForm):
+        class Meta:
+            csrf = False
+
+        file = FileField()
+
+    return UploadForm
+
+
+def test_process_formdata(form):
+    assert form(MultiDict((('file', FileStorage()),))).file.data is None
+    assert form(
+        MultiDict((('file', FileStorage(filename='real')),))
+    ).file.data is not None
+
+
+def test_file_required(form):
+    form.file.kwargs['validators'] = [FileRequired()]
+
+    f = form()
+    assert not f.validate()
+    assert f.file.errors[0] == 'This field is required.'
+
+    f = form(file='not a file')
+    assert not f.validate()
+    assert f.file.errors[0] == 'This field is required.'
+
+    f = form(file=FileStorage())
+    assert not f.validate()
+
+    f = form(file=FileStorage(filename='real'))
+    assert f.validate()
+
+
+def test_file_allowed(form):
+    form.file.kwargs['validators'] = [FileAllowed(('txt',))]
+
+    f = form()
+    assert f.validate()
+
+    f = form(file=FileStorage(filename='test.txt'))
+    assert f.validate()
+
+    f = form(file=FileStorage(filename='test.png'))
+    assert not f.validate()
+    assert f.file.errors[0] == 'File does not have an approved extension: txt'
+
+
+def test_file_allowed_uploadset(app, form):
+    pytest.importorskip('flask_uploads')
+    from flask_uploads import UploadSet, configure_uploads
+
+    app.config['UPLOADS_DEFAULT_DEST'] = 'uploads'
+    txt = UploadSet('txt', extensions=('txt',))
+    configure_uploads(app, (txt,))
+    form.file.kwargs['validators'] = [FileAllowed(txt)]
+
+    f = form()
+    assert f.validate()
+
+    f = form(file=FileStorage(filename='test.txt'))
+    assert f.validate()
+
+    f = form(file=FileStorage(filename='test.png'))
+    assert not f.validate()
+    assert f.file.errors[0] == 'File does not have an approved extension.'
+
+
+def test_validate_base_field(req_ctx):
+    class F(FlaskForm):
+        class Meta:
+            csrf = False
+
+        f = BaseFileField(validators=[FileRequired()])
+
+    assert not F().validate()
+    assert not F(f=FileStorage()).validate()
+    assert F(f=FileStorage(filename='real')).validate()
+
+
+def test_deprecated_filefield(recwarn, form):
+    assert not form().file.has_file()
+    w = recwarn.pop(FlaskWTFDeprecationWarning)
+    assert 'has_file' in str(w.message)
diff --git a/tests/test_form.py b/tests/test_form.py
new file mode 100644
index 0000000000000000000000000000000000000000..12d439a45680a1815d5ea6758613302c5ed52253
--- /dev/null
+++ b/tests/test_form.py
@@ -0,0 +1,165 @@
+from io import BytesIO
+
+from flask import json, request
+from wtforms import FileField, HiddenField, IntegerField, StringField
+from wtforms.compat import with_metaclass
+from wtforms.form import FormMeta
+from wtforms.validators import DataRequired
+from wtforms.widgets import HiddenInput
+
+from flask_wtf import FlaskForm, Form
+from flask_wtf._compat import FlaskWTFDeprecationWarning
+
+
+class BasicForm(FlaskForm):
+    class Meta:
+        csrf = False
+
+    name = StringField(validators=[DataRequired()])
+    avatar = FileField()
+
+
+def test_populate_from_form(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = BasicForm()
+        assert form.name.data == 'form'
+
+    client.post('/', data={'name': 'form'})
+
+
+def test_populate_from_files(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = BasicForm()
+        assert form.avatar.data is not None
+        assert form.avatar.data.filename == 'flask.png'
+
+    client.post('/', data={
+        'name': 'files', 'avatar': (BytesIO(), 'flask.png')
+    })
+
+
+def test_populate_from_json(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = BasicForm()
+        assert form.name.data == 'json'
+
+    client.post(
+        '/', data=json.dumps({'name': 'json'}),
+        content_type='application/json'
+    )
+
+
+def test_populate_manually(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = BasicForm(request.args)
+        assert form.name.data == 'args'
+
+    client.post('/', query_string={'name': 'args'})
+
+
+def test_populate_none(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = BasicForm(None)
+        assert form.name.data is None
+
+    client.post('/', data={'name': 'ignore'})
+
+
+def test_validate_on_submit(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = BasicForm()
+        assert form.is_submitted()
+        assert not form.validate_on_submit()
+        assert 'name' in form.errors
+
+    client.post('/')
+
+
+def test_no_validate_on_get(app, client):
+    @app.route('/', methods=['GET', 'POST'])
+    def index():
+        form = BasicForm()
+        assert not form.validate_on_submit()
+        assert 'name' not in form.errors
+
+    client.get('/')
+
+
+def test_hidden_tag(req_ctx):
+    class F(BasicForm):
+        class Meta:
+            csrf = True
+
+        key = HiddenField()
+        count = IntegerField(widget=HiddenInput())
+
+    f = F()
+    out = f.hidden_tag()
+    assert all(x in out for x in ('csrf_token', 'count', 'key'))
+    assert 'avatar' not in out
+    assert 'csrf_token' not in f.hidden_tag('count', 'key')
+
+
+def test_deprecated_form(req_ctx, recwarn):
+    class F(Form):
+        pass
+
+    F()
+    w = recwarn.pop(FlaskWTFDeprecationWarning)
+    assert 'FlaskForm' in str(w.message)
+
+
+def test_custom_meta_with_deprecated_form(req_ctx, recwarn):
+    class FMeta(FormMeta):
+        pass
+
+    class F(with_metaclass(FMeta, Form)):
+        pass
+
+    F()
+    recwarn.pop(FlaskWTFDeprecationWarning)
+
+
+def test_deprecated_csrf_enabled(req_ctx, recwarn):
+    class F(FlaskForm):
+        pass
+
+    F(csrf_enabled=False)
+    w = recwarn.pop(FlaskWTFDeprecationWarning)
+    assert "meta={'csrf': False}" in str(w.message)
+
+
+def test_set_default_message_language(app, client):
+
+    @app.route('/default', methods=['POST'])
+    def default():
+        form = BasicForm()
+        assert not form.validate_on_submit()
+        assert 'This field is required.' in form.name.errors
+
+    client.post('/default', data={'name': '  '})
+
+    @app.route('/es', methods=['POST'])
+    def es():
+        app.config['WTF_I18N_ENABLED'] = False
+
+        class MyBaseForm(FlaskForm):
+            class Meta:
+                csrf = False
+                locales = ['es']
+
+        class NameForm(MyBaseForm):
+            name = StringField(validators=[DataRequired()])
+
+        form = NameForm()
+        assert form.meta.locales == ['es']
+        assert not form.validate_on_submit()
+        assert 'Este campo es obligatorio.' in form.name.errors
+
+    client.post('/es', data={'name': '  '})
diff --git a/tests/test_html5.py b/tests/test_html5.py
new file mode 100644
index 0000000000000000000000000000000000000000..b4ced05d91b12435037bffcddb42d72cd2c033f5
--- /dev/null
+++ b/tests/test_html5.py
@@ -0,0 +1,7 @@
+from flask_wtf._compat import FlaskWTFDeprecationWarning
+
+
+def test_deprecated_html5(recwarn):
+    __import__('flask_wtf.html5')
+    w = recwarn.pop(FlaskWTFDeprecationWarning)
+    assert 'wtforms.fields.html5' in str(w.message)
diff --git a/tests/test_i18n.py b/tests/test_i18n.py
index 310047f41f06cfac8c754754df25bfecedccd7c3..a2d5eae132c4eb6ee3591e4d9550f10047c49e09 100644
--- a/tests/test_i18n.py
+++ b/tests/test_i18n.py
@@ -1,35 +1,74 @@
-from __future__ import with_statement
+# coding=utf8
+import pytest
+from flask import request
+from wtforms import StringField
+from wtforms.validators import DataRequired, Length
 
-from .base import TestCase, to_unicode
+from flask_wtf import FlaskForm
 
 
-class TestI18NCase(TestCase):
-    def test_i18n_disabled(self):
-        self.app.config['WTF_CSRF_ENABLED'] = False
-        response = self.client.post(
-            "/",
-            headers={'Accept-Language': 'zh-CN,zh;q=0.8'},
-            data={}
-        )
-        assert b'This field is required.' in response.data
+class NameForm(FlaskForm):
+    class Meta:
+        csrf = False
 
-    def test_i18n_enabled(self):
-        from flask import request
+    name = StringField(validators=[DataRequired(), Length(min=8)])
+
+
+def test_no_extension(app, client):
+    @app.route('/', methods=['POST'])
+    def index():
+        form = NameForm()
+        form.validate()
+        assert form.name.errors[0] == 'This field is required.'
+
+    client.post(
+        '/', headers={'Accept-Language': 'zh-CN,zh;q=0.8'}
+    )
+
+
+def test_i18n(app, client):
+    try:
         from flask_babel import Babel
-        babel = Babel(self.app)
+    except ImportError:
+        try:
+            from flask_babelex import Babel
+        except ImportError:
+            pytest.skip('Flask-Babel or Flask-BabelEx must be installed.')
+
+    babel = Babel(app)
+
+    @babel.localeselector
+    def get_locale():
+        return request.accept_languages.best_match(['en', 'zh'], 'en')
+
+    @app.route('/', methods=['POST'])
+    def index():
+        form = NameForm()
+        form.validate()
+
+        if not app.config.get('WTF_I18N_ENABLED', True):
+            assert form.name.errors[0] == 'This field is required.'
+        elif not form.name.data:
+            assert form.name.errors[0] == u'该字段是必填字段。'
+        else:
+            assert form.name.errors[0] == u'字段长度必须至少 8 个字符。'
+
+    client.post('/', headers={'Accept-Language': 'zh-CN,zh;q=0.8'})
+    client.post(
+        '/', headers={'Accept-Language': 'zh'}, data={'name': 'short'}
+    )
+    app.config['WTF_I18N_ENABLED'] = False
+    client.post('/', headers={'Accept-Language': 'zh'})
 
-        @babel.localeselector
-        def get_locale():
-            return request.accept_languages.best_match(['en', 'zh'], 'en')
 
-        self.app.config['WTF_CSRF_ENABLED'] = False
+def test_outside_request():
+    pytest.importorskip('babel')
+    from flask_wtf.i18n import translations
 
-        response = self.client.post(
-            "/",
-            headers={'Accept-Language': 'zh-CN,zh;q=0.8'},
-            data={}
-        )
-        assert '\u8be5\u5b57\u6bb5\u662f' in to_unicode(response.data)
+    s = 'This field is required.'
+    assert translations.gettext(s) == s
 
-        response = self.client.post("/", data={})
-        assert b'This field is required.' in response.data
+    ss = 'Field must be at least %(min)d character long.'
+    sp = 'Field must be at least %(min)d character long.'
+    assert translations.ngettext(ss, sp, 1) == ss
+    assert translations.ngettext(ss, sp, 2) == sp
diff --git a/tests/test_recaptcha.py b/tests/test_recaptcha.py
index bd8c93afdafd678b8ee90c741e09661d84ff28ae..457f119fa22605cd06eec9e7ba5a72a6be12d2d9 100644
--- a/tests/test_recaptcha.py
+++ b/tests/test_recaptcha.py
@@ -1,71 +1,151 @@
-from __future__ import with_statement
-
-from flask import Flask, json, render_template
+import pytest
+from flask import json
+from markupsafe import Markup
 
 from flask_wtf import FlaskForm
+from flask_wtf._compat import to_bytes
 from flask_wtf.recaptcha import RecaptchaField
-from .base import TestCase
-
+from flask_wtf.recaptcha.validators import Recaptcha, http
 
-RECAPTCHA_PUBLIC_KEY = '6LeYIbsSAAAAACRPIllxA7wvXjIE411PfdB2gt2J'
-RECAPTCHA_PRIVATE_KEY = '6LeYIbsSAAAAAJezaIq3Ft_hSTo0YtyeFG-JgRtu'
 
+class RecaptchaForm(FlaskForm):
+    class Meta:
+        csrf = False
 
-class RecaptchaFrom(FlaskForm):
-    SECRET_KEY = "a poorly kept secret."
     recaptcha = RecaptchaField()
 
 
-class TestRecaptcha(TestCase):
-    def create_app(self):
-        app = Flask(__name__)
-        app.secret_key = "secret"
-        app.config['RECAPTCHA_PUBLIC_KEY'] = RECAPTCHA_PUBLIC_KEY
-        app.config['RECAPTCHA_PRIVATE_KEY'] = RECAPTCHA_PRIVATE_KEY
-
-        @app.route("/", methods=("GET", "POST"))
-        def index():
-            form = RecaptchaFrom(meta={'csrf': False})
-            if form.validate_on_submit():
-                return 'OK'
-            return render_template("recaptcha.html", form=form)
-        return app
-
-    def test_recaptcha(self):
-        response = self.client.get('/')
-        assert b'//www.google.com/recaptcha/api.js' in response.data
-
-    def test_invalid_recaptcha(self):
-        response = self.client.post('/', data={})
-        assert b'missing' in response.data
-
-    def test_send_recaptcha_request(self):
-        response = self.client.post('/', data={
-            'g-recaptcha-response': 'test'
-        })
-        assert b'invalid' in response.data
+@pytest.fixture
+def app(app):
+    app.testing = False
+    app.config['PROPAGATE_EXCEPTIONS'] = True
+    app.config['RECAPTCHA_PUBLIC_KEY'] = 'public'
+    app.config['RECAPTCHA_PRIVATE_KEY'] = 'private'
+    return app
 
-        response = self.client.post('/', data=json.dumps({
-            'g-recaptcha-response': 'test'
-        }), content_type='application/json')
-        assert b'invalid' in response.data
 
-    def test_testing(self):
-        self.app.testing = True
-        response = self.client.post('/', data={
-            'g-recaptcha-response': 'test'
-        })
-        assert b'invalid' not in response.data
+@pytest.yield_fixture(autouse=True)
+def req_ctx(app):
+    with app.test_request_context(
+        data={'g-recaptcha-response': 'pass'}
+    ) as ctx:
+        yield ctx
+
+
+def test_config(app, monkeypatch):
+    f = RecaptchaForm()
+    monkeypatch.setattr(app, 'testing', True)
+    f.validate()
+    assert not f.recaptcha.errors
+    monkeypatch.undo()
+
+    monkeypatch.delitem(app.config, 'RECAPTCHA_PUBLIC_KEY')
+    pytest.raises(RuntimeError, f.recaptcha)
+    monkeypatch.undo()
+
+    monkeypatch.delitem(app.config, 'RECAPTCHA_PRIVATE_KEY')
+    pytest.raises(RuntimeError, f.validate)
+
+
+def test_render_has_js():
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert 'https://www.google.com/recaptcha/api.js' in render
+
+
+def test_render_custom_html(app):
+    app.config['RECAPTCHA_HTML'] = 'custom'
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert render == 'custom'
+    assert isinstance(render, Markup)
+
+
+def test_render_custom_args(app):
+    app.config['RECAPTCHA_PARAMETERS'] = {'key': '(value)'}
+    app.config['RECAPTCHA_DATA_ATTRS'] = {'red': 'blue'}
+    f = RecaptchaForm()
+    render = f.recaptcha()
+    assert '?key=%28value%29' in render
+    assert 'data-red="blue"' in render
+
+
+def test_missing_response(app):
+    with app.test_request_context():
+        f = RecaptchaForm()
+        f.validate()
+        assert f.recaptcha.errors[0] == 'The response parameter is missing.'
+
 
-    def test_no_private_key(self):
-        self.app.testing = False
-        self.app.config.pop('RECAPTCHA_PRIVATE_KEY', None)
-        response = self.client.post('/', data={
-            'g-recaptcha-response': 'test'
+class MockResponse(object):
+    def __init__(self, code, error='invalid-input-response', read_bytes=False):
+        self.code = code
+        self.data = json.dumps({
+            'success': not error,
+            'error-codes': [error] if error else []
         })
-        assert response.status_code == 500
+        self.read_bytes = read_bytes
+
+    def read(self):
+        if self.read_bytes:
+            return to_bytes(self.data)
+
+        return self.data
+
+
+def test_send_invalid_request(monkeypatch):
+    def mock_urlopen(url, data):
+        return MockResponse(200)
+
+    monkeypatch.setattr(http, 'urlopen', mock_urlopen)
+    f = RecaptchaForm()
+    f.validate()
+    assert f.recaptcha.errors[0] == (
+        'The response parameter is invalid or malformed.'
+    )
+
+
+def test_response_from_json(app, monkeypatch):
+    def mock_urlopen(url, data):
+        return MockResponse(200)
+
+    monkeypatch.setattr(http, 'urlopen', mock_urlopen)
+
+    with app.test_request_context(
+        data=json.dumps({'g-recaptcha-response': 'pass'}),
+        content_type='application/json'
+    ):
+        f = RecaptchaForm()
+        f.validate()
+        assert f.recaptcha.errors[0] != 'The response parameter is missing.'
+
+
+def test_request_fail(monkeypatch):
+    def mock_urlopen(url, data):
+        return MockResponse(400)
+
+    monkeypatch.setattr(http, 'urlopen', mock_urlopen)
+    f = RecaptchaForm()
+    f.validate()
+    assert f.recaptcha.errors
+
+
+def test_request_success(monkeypatch):
+    def mock_urlopen(url, data):
+        return MockResponse(200, '')
+
+    monkeypatch.setattr(http, 'urlopen', mock_urlopen)
+    f = RecaptchaForm()
+    f.validate()
+    assert not f.recaptcha.errors
+
+
+def test_request_unmatched_error(monkeypatch):
+    def mock_urlopen(url, data):
+        return MockResponse(200, 'not-an-error', True)
 
-    def test_no_public_key(self):
-        self.app.config.pop('RECAPTCHA_PUBLIC_KEY', None)
-        response = self.client.get('/')
-        assert response.status_code == 500
+    monkeypatch.setattr(http, 'urlopen', mock_urlopen)
+    f = RecaptchaForm()
+    f.recaptcha.validators = [Recaptcha('custom')]
+    f.validate()
+    assert f.recaptcha.errors[0] == 'custom'
diff --git a/tests/test_uploads.py b/tests/test_uploads.py
deleted file mode 100644
index 385db3dfb7d27d08d527b35e2636cbe8c5289880..0000000000000000000000000000000000000000
--- a/tests/test_uploads.py
+++ /dev/null
@@ -1,177 +0,0 @@
-from __future__ import with_statement
-
-try:
-    from io import BytesIO
-except ImportError:
-    from StringIO import StringIO as BytesIO
-
-from flask import render_template, request
-
-from wtforms import StringField, FieldList
-from flask_wtf import FlaskForm
-from flask_wtf.file import FileField
-from flask_wtf.file import file_required, file_allowed
-
-from .base import TestCase
-
-
-class UploadSet(object):
-    def __init__(self, name='files', extensions=None):
-        self.name = name
-        self.extensions = extensions
-
-    def file_allowed(self, storage, basename):
-        if not self.extensions:
-            return True
-
-        ext = basename.rsplit('.', 1)[-1]
-        return ext in self.extensions
-
-images = UploadSet('images', ['jpg', 'png'])
-
-
-class FileUploadForm(FlaskForm):
-    upload = FileField("Upload file")
-
-
-class MultipleFileUploadForm(FlaskForm):
-    uploads = FieldList(FileField("upload"), min_entries=3)
-
-
-class ImageUploadForm(FlaskForm):
-    upload = FileField("Upload file",
-                       validators=[file_required(),
-                                   file_allowed(images)])
-
-
-class TextUploadForm(FlaskForm):
-    upload = FileField("Upload file",
-                       validators=[file_required(),
-                                   file_allowed(['txt'])])
-
-
-class TestFileUpload(TestCase):
-    def create_app(self):
-        app = super(TestFileUpload, self).create_app()
-        app.config['WTF_CSRF_ENABLED'] = False
-
-        @app.route("/upload-image/", methods=("POST",))
-        def upload_image():
-            form = ImageUploadForm()
-            if form.validate_on_submit():
-                return "OK"
-            return "invalid"
-
-        @app.route("/upload-text/", methods=("POST",))
-        def upload_text():
-            form = TextUploadForm()
-            if form.validate_on_submit():
-                return "OK"
-            return "invalid"
-
-        @app.route("/upload-multiple/", methods=("POST",))
-        def upload_multiple():
-            form = MultipleFileUploadForm()
-            if form.validate_on_submit():
-                assert len(form.uploads.entries) == 3
-                for upload in form.uploads.entries:
-                    assert upload.has_file()
-
-            return "OK"
-
-        @app.route("/upload/", methods=("POST",))
-        def upload():
-            form = FileUploadForm()
-            if form.validate_on_submit():
-                filedata = form.upload.data
-            else:
-                filedata = None
-
-            return render_template("upload.html",
-                                   filedata=filedata,
-                                   form=form)
-
-        return app
-
-    def test_multiple_files(self):
-        fps = [self.app.open_resource("flask.png") for i in range(3)]
-        data = [("uploads-%d" % i, fp) for i, fp in enumerate(fps)]
-        response = self.client.post("/upload-multiple/", data=dict(data))
-        assert response.status_code == 200
-
-    def test_valid_file(self):
-        with self.app.open_resource("flask.png") as fp:
-            response = self.client.post(
-                "/upload-image/",
-                data={'upload': fp}
-            )
-
-        assert b'OK' in response.data
-
-    def test_missing_file(self):
-        response = self.client.post(
-            "/upload-image/",
-            data={'upload': "test"}
-        )
-
-        assert b'invalid' in response.data
-
-    def test_invalid_file(self):
-        with self.app.open_resource("flask.png") as fp:
-            response = self.client.post(
-                "/upload-text/",
-                data={'upload': fp}
-            )
-
-        assert b'invalid' in response.data
-
-    def test_invalid_file_2(self):
-        response = self.client.post(
-            "/upload/",
-            data={'upload': 'flask.png'}
-        )
-
-        assert b'flask.png</h3>' not in response.data
-
-    def test_valid_txt_file(self):
-        with self.app.open_resource("flask.txt") as fp:
-            response = self.client.post(
-                "/upload-text/",
-                data={'upload': fp}
-            )
-
-        assert b'OK' in response.data
-
-    def test_invalid_image_file(self):
-        with self.app.open_resource("flask.txt") as fp:
-            response = self.client.post(
-                "/upload-image/",
-                data={'upload': fp}
-            )
-
-        assert b'invalid' in response.data
-
-
-class BrokenForm(FlaskForm):
-    text_fields = FieldList(StringField())
-    file_fields = FieldList(FileField())
-
-text_data = [('text_fields-0', 'First input'),
-             ('text_fields-1', 'Second input')]
-
-file_data = [('file_fields-0', (BytesIO(b'contents 0'), 'file0.txt')),
-             ('file_fields-1', (BytesIO(b'contents 1'), 'file1.txt'))]
-
-
-class TestFileList(TestCase):
-    def test_multiple_upload(self):
-        data = dict(text_data + file_data)
-        with self.app.test_request_context(method='POST', data=data):
-            assert len(request.files)  # the files have been added to the
-                                       # request
-
-            f = BrokenForm(meta={'csrf': False})
-
-            assert f.validate_on_submit()
-            assert len(text_data) == len(f.text_fields)
-            assert len(file_data) == len(f.file_fields)
diff --git a/tests/test_validation.py b/tests/test_validation.py
deleted file mode 100644
index a5560b8c70b3059d01aed8d25a9b70d89fbaf85f..0000000000000000000000000000000000000000
--- a/tests/test_validation.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from __future__ import with_statement
-
-from flask import g, json
-
-from flask_wtf.csrf import generate_csrf
-from .base import MyForm, TestCase, capture_logging, to_unicode
-
-
-class TestValidateOnSubmit(TestCase):
-
-    def test_not_submitted(self):
-        response = self.client.get("/")
-        assert b'DANNY' not in response.data
-
-    def test_submitted_not_valid(self):
-        self.app.config['WTF_CSRF_ENABLED'] = False
-        response = self.client.post("/", data={})
-        assert b'DANNY' not in response.data
-
-    def test_submitted_and_valid(self):
-        self.app.config['WTF_CSRF_ENABLED'] = False
-        response = self.client.post("/", data={"name": "danny"})
-        assert b'DANNY' in response.data
-
-    def test_json_data(self):
-        self.app.config['WTF_CSRF_ENABLED'] = False
-        response = self.client.post(
-            '/', content_type='application/json',
-            data=json.dumps({'name': 'Flask-WTF'})
-        )
-        assert b'FLASK-WTF' in response.data
-
-
-class TestValidateWithoutSubmit(TestCase):
-    def test_unsubmitted_valid(self):
-        class obj:
-            name = 'foo'
-
-        with self.app.test_request_context():
-            assert MyForm(obj=obj, meta={'csrf': False}).validate()
-            t = generate_csrf()
-            assert MyForm(obj=obj, csrf_token=t).validate()
-
-
-class TestHiddenTag(TestCase):
-
-    def test_hidden_tag(self):
-
-        response = self.client.get("/hidden/")
-        assert to_unicode(response.data).count('type="hidden"') == 5
-        assert b'name="_method"' in response.data
-
-
-class TestCSRF(TestCase):
-
-    def test_csrf_token(self):
-
-        response = self.client.get("/")
-        snippet = '<input id="csrf_token" name="csrf_token" type="hidden" value'
-        assert snippet in to_unicode(response.data)
-
-    def test_invalid_csrf(self):
-        from flask_wtf.csrf import logger
-
-        with capture_logging(logger) as handler:
-            response = self.client.post("/", data={"name": "danny"})
-
-        assert b'DANNY' not in response.data
-        assert b'The CSRF token is missing.' in response.data
-        self.assertEqual(1, len(handler))
-        self.assertEqual('The CSRF token is missing.', handler[0].message)
-
-    def test_csrf_disabled(self):
-
-        self.app.config['WTF_CSRF_ENABLED'] = False
-
-        response = self.client.post("/", data={"name": "danny"})
-        assert b'DANNY' in response.data
-
-    def test_validate_twice(self):
-
-        response = self.client.post("/simple/", data={})
-        assert response.status_code == 200
-
-    def test_ajax(self):
-
-        response = self.client.post(
-            "/ajax/", data={"name": "danny"},
-            headers={'X-Requested-With': 'XMLHttpRequest'}
-        )
-        assert response.status_code == 200
-
-    def test_valid_csrf(self):
-        with self.client:
-            self.client.get('/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post('/', data={
-            'name': 'danny',
-            'csrf_token': csrf_token
-        })
-        assert b'DANNY' in response.data
-
-    def test_double_csrf(self):
-        with self.client:
-            self.client.get('/')
-            csrf_token = g.csrf_token
-
-        response = self.client.post("/two_forms/", data={
-            "name": "danny",
-            "csrf_token": csrf_token
-        })
-        assert response.data == b'OK'
diff --git a/tox.ini b/tox.ini
index 58a3b14a7935942bf7e74f3bde1f07c3eb69c2d1..2e7cda47f543dc71c47b1dc98970ca7aa85cdeda 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,6 +1,58 @@
 [tox]
-envlist = py{26,27,33,34,35,36},pypy
+envlist =
+    py{27,34,35,36,37,38}
+    pypy
+    py-babelex
+    py-no-babel
+    docs-html
+    coverage-report
 
 [testenv]
-deps = -rtest-requirements.txt
-commands = nosetests tests
+deps =
+    coverage
+    pytest>=3
+    flask-babel
+    flask-uploads
+commands = coverage run -p -m pytest
+
+[testenv:py-babelex]
+deps =
+    coverage
+    pytest>=3
+    flask-babelex
+commands = coverage run -p -m pytest
+
+[testenv:py-no-babel]
+deps =
+    coverage
+    pytest>=3
+commands = coverage run -p -m pytest
+
+[testenv:docs-html]
+deps =
+    sphinx
+    flask-sphinx-themes
+commands =
+    sphinx-build -W -b html -d docs/_build/doctrees docs docs/_build/html
+
+[testenv:docs-linkcheck]
+deps = sphinx
+commands =
+    sphinx-build -W -b linkcheck -d docs/_build/doctrees docs docs/_build/linkcheck
+
+[testenv:coverage-report]
+deps = coverage
+skip_install = true
+commands =
+    coverage combine
+    coverage report
+    coverage html
+
+[testenv:codecov]
+passenv = CI TRAVIS TRAVIS_*
+deps = codecov
+skip_install = true
+commands =
+    coverage combine
+    coverage report
+    codecov