Cobertura de la prueba

Escribir pruebas unitarias para tu aplicación te permite comprobar que el código que has escrito funciona como esperas. Flask proporciona un cliente de pruebas que simula peticiones a la aplicación y devuelve los datos de respuesta.

Deberías probar la mayor parte posible de tu código. El código de las funciones sólo se ejecuta cuando se llama a la función, y el código de las ramas, como los bloques if, sólo se ejecuta cuando se cumple la condición. Debes asegurarte de que cada función se pruebe con datos que cubran cada rama.

Cuanto más se acerque al 100% de cobertura, más seguro estará de que un cambio no modificará inesperadamente otros comportamientos. Sin embargo, una cobertura del 100% no garantiza que tu aplicación no tenga errores. En particular, no comprueba cómo interactúa el usuario con la aplicación en el navegador. A pesar de esto, la cobertura de las pruebas es una herramienta importante para utilizar durante el desarrollo.

Nota

Esto se está introduciendo tarde en el tutorial, pero en tus futuros proyectos deberías hacer pruebas mientras desarrollas.

Utilizarás pytest y coverage para probar y medir tu código. Instala ambos:

$ pip install pytest coverage

Configuración y fixtures

El código de prueba se encuentra en el directorio tests. Este directorio está al lado del paquete flaskr, no dentro de él. El archivo tests/conftest.py contiene funciones de configuración llamadas fixtures que cada prueba utilizará. Las pruebas están en módulos de Python que empiezan por test_, y cada función de prueba en esos módulos también empieza por test_.

Cada prueba creará un nuevo archivo de base de datos temporal y rellenará algunos datos que se utilizarán en las pruebas. Escribe un archivo SQL para insertar esos datos.

tests/data.sql
INSERT INTO user (username, password)
VALUES
  ('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
  ('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');

INSERT INTO post (title, body, author_id, created)
VALUES
  ('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');

El fixture app llamará a la fábrica y pasará test_config para configurar la aplicación y la base de datos para las pruebas en lugar de utilizar su configuración local de desarrollo.

tests/conftest.py
import os
import tempfile

import pytest
from flaskr import create_app
from flaskr.db import get_db, init_db

with open(os.path.join(os.path.dirname(__file__), 'data.sql'), 'rb') as f:
    _data_sql = f.read().decode('utf8')


@pytest.fixture
def app():
    db_fd, db_path = tempfile.mkstemp()

    app = create_app({
        'TESTING': True,
        'DATABASE': db_path,
    })

    with app.app_context():
        init_db()
        get_db().executescript(_data_sql)

    yield app

    os.close(db_fd)
    os.unlink(db_path)


@pytest.fixture
def client(app):
    return app.test_client()


@pytest.fixture
def runner(app):
    return app.test_cli_runner()

tempfile.mkstemp() crea y abre un archivo temporal, devolviendo el descriptor del archivo y la ruta al mismo. La ruta de DATABASE se anula para que apunte a esta ruta temporal en lugar de a la carpeta de la instancia. Después de establecer la ruta, se crean las tablas de la base de datos y se insertan los datos de la prueba. Una vez finalizada la prueba, el archivo temporal se cierra y se elimina.

TESTING indica a Flask que la aplicación está en modo de prueba. Flask cambia algunos comportamientos internos para que sea más fácil de probar, y otras extensiones también pueden utilizar la bandera para hacer que las pruebas sean más fáciles.

El fixture client llama a app.test_client() con el objeto de aplicación creado por el fixture app. Las pruebas utilizarán el cliente para hacer peticiones a la aplicación sin ejecutar el servidor.

El fixture runner es similar al client. app.test_cli_runner() crea un runner que puede llamar a los comandos Click registrados con la aplicación.

Pytest utiliza fixtures haciendo coincidir sus nombres de función con los nombres de los argumentos de las funciones de prueba. Por ejemplo, la función test_hello que escribirás a continuación toma un argumento client. Pytest hace coincidir eso con la función fixture client, la llama, y pasa el valor devuelto a la función de prueba.

Fábrica

No hay mucho que probar sobre la fábrica en sí. La mayor parte del código se ejecutará ya para cada prueba, así que si algo falla las otras pruebas lo notarán.

El único comportamiento que puede cambiar es pasar la configuración de la prueba. Si no se pasa config, debe haber alguna configuración por defecto, de lo contrario la configuración debe ser anulada.

tests/test_factory.py
from flaskr import create_app


def test_config():
    assert not create_app().testing
    assert create_app({'TESTING': True}).testing


def test_hello(client):
    response = client.get('/hello')
    assert response.data == b'Hello, World!'

Has añadido la ruta hello como ejemplo al escribir la fábrica al principio del tutorial. Devuelve «¡Hola, mundo!», por lo que la prueba comprueba que los datos de respuesta coinciden.

Base de datos

Dentro de un contexto de aplicación, get_db debe devolver la misma conexión cada vez que se llame. Después del contexto, la conexión debe ser cerrada.

tests/test_db.py
import sqlite3

import pytest
from flaskr.db import get_db


def test_get_close_db(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()

    with pytest.raises(sqlite3.ProgrammingError) as e:
        db.execute('SELECT 1')

    assert 'closed' in str(e.value)

El comando init-db debe llamar a la función init_db y mostrar un mensaje.

tests/test_db.py
def test_init_db_command(runner, monkeypatch):
    class Recorder(object):
        called = False

    def fake_init_db():
        Recorder.called = True

    monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    result = runner.invoke(args=['init-db'])
    assert 'Initialized' in result.output
    assert Recorder.called

Esta prueba utiliza el fixture monkeypatch de Pytest para sustituir la función init_db por otra que registre que ha sido llamada. El fixture runner que escribiste arriba se utiliza para llamar al comando init-db por su nombre.

Autenticación

Para la mayoría de las vistas, un usuario necesita estar conectado. La forma más fácil de hacer esto en las pruebas es hacer una petición POST a la vista login con el cliente. En lugar de escribir eso cada vez, puedes escribir una clase con métodos para hacer eso, y usar un fixture para pasarle el cliente para cada prueba.

tests/conftest.py
class AuthActions(object):
    def __init__(self, client):
        self._client = client

    def login(self, username='test', password='test'):
        return self._client.post(
            '/auth/login',
            data={'username': username, 'password': password}
        )

    def logout(self):
        return self._client.get('/auth/logout')


@pytest.fixture
def auth(client):
    return AuthActions(client)

Con el fixture auth, puedes llamar a auth.login() en una prueba para iniciar sesión como el usuario test, que fue insertado como parte de los datos de la prueba en el fixture app.

La vista register debería ser mostrada con éxito en GET. En POST con datos de formulario válidos, debería redirigir a la URL de acceso y los datos del usuario deberían estar en la base de datos. Los datos no válidos deberían mostrar mensajes de error.

tests/test_auth.py
import pytest
from flask import g, session
from flaskr.db import get_db


def test_register(client, app):
    assert client.get('/auth/register').status_code == 200
    response = client.post(
        '/auth/register', data={'username': 'a', 'password': 'a'}
    )
    assert response.headers["Location"] == "/auth/login"

    with app.app_context():
        assert get_db().execute(
            "SELECT * FROM user WHERE username = 'a'",
        ).fetchone() is not None


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('', '', b'Username is required.'),
    ('a', '', b'Password is required.'),
    ('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
    response = client.post(
        '/auth/register',
        data={'username': username, 'password': password}
    )
    assert message in response.data

client.get() realiza una petición GET y devuelve el objeto Response devuelto por Flask. Del mismo modo, client.post() realiza una petición POST, convirtiendo el dictado data en datos del formulario.

Para comprobar que la página se renderiza correctamente, se hace una petición simple y se comprueba si hay un 200 OK status_code. Si el renderizado falla, Flask devolverá un código 500 Internal Server Error.

headers tendrá una cabecera Location con la URL de acceso cuando la vista de registro redirija a la vista de acceso.

data contiene el cuerpo de la respuesta en forma de bytes. Si esperas que un determinado valor se renderice en la página, comprueba que está en data. Los bytes deben ser comparados con bytes. Si quieres comparar texto, utiliza get_data(as_text=True) en su lugar.

pytest.mark.parametrize le dice a Pytest que ejecute la misma función de prueba con diferentes argumentos. Se utiliza aquí para probar diferentes entradas no válidas y mensajes de error sin tener que escribir el mismo código tres veces.

Las pruebas para la vista login son muy similares a las de register. En lugar de probar los datos en la base de datos, session debe tener user_id establecido después de iniciar la sesión.

tests/test_auth.py
def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers["Location"] == "/"

    with client:
        client.get('/')
        assert session['user_id'] == 1
        assert g.user['username'] == 'test'


@pytest.mark.parametrize(('username', 'password', 'message'), (
    ('a', 'test', b'Incorrect username.'),
    ('test', 'a', b'Incorrect password.'),
))
def test_login_validate_input(auth, username, password, message):
    response = auth.login(username, password)
    assert message in response.data

El uso de client en un bloque with permite acceder a variables de contexto como session después de que se devuelva la respuesta. Normalmente, acceder a session fuera de una petición daría un error.

Probar logout es lo contrario de login. session no debe contener user_id después de cerrar la sesión.

tests/test_auth.py
def test_logout(client, auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

Blog

Todas las vistas del blog utilizan el fixture auth que escribiste antes. Llama a auth.login() y las siguientes peticiones del cliente se registrarán como el usuario test.

La vista index debería mostrar información sobre el post que fue añadido con los datos de prueba. Cuando se inicie la sesión como autor, debería haber un enlace para editar la entrada.

También puedes probar más comportamientos de autenticación mientras pruebas la vista index. Cuando no se ha iniciado la sesión, cada página muestra enlaces para iniciar la sesión o registrarse. Cuando se ha iniciado la sesión, hay un enlace para cerrar la sesión.

tests/test_blog.py
import pytest
from flaskr.db import get_db


def test_index(client, auth):
    response = client.get('/')
    assert b"Log In" in response.data
    assert b"Register" in response.data

    auth.login()
    response = client.get('/')
    assert b'Log Out' in response.data
    assert b'test title' in response.data
    assert b'by test on 2018-01-01' in response.data
    assert b'test\nbody' in response.data
    assert b'href="/1/update"' in response.data

Un usuario debe estar conectado para acceder a las vistas create, update y delete. El usuario conectado debe ser el autor de la entrada para acceder a update y delete, de lo contrario se devuelve el estado 403 Forbidden. Si un post con el id dado no existe, update y delete deben devolver 404 Not Found.

tests/test_blog.py
@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
    '/1/delete',
))
def test_login_required(client, path):
    response = client.post(path)
    assert response.headers["Location"] == "/auth/login"


def test_author_required(app, client, auth):
    # change the post author to another user
    with app.app_context():
        db = get_db()
        db.execute('UPDATE post SET author_id = 2 WHERE id = 1')
        db.commit()

    auth.login()
    # current user can't modify other user's post
    assert client.post('/1/update').status_code == 403
    assert client.post('/1/delete').status_code == 403
    # current user doesn't see edit link
    assert b'href="/1/update"' not in client.get('/').data


@pytest.mark.parametrize('path', (
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

Las vistas create y update deben generar y devolver un estado 200 OK para una petición GET. Cuando se envían datos válidos en una petición POST, create debe insertar los nuevos datos de la entrada en la base de datos, y update debe modificar los datos existentes. Ambas páginas deben mostrar un mensaje de error si los datos no son válidos.

tests/test_blog.py
def test_create(client, auth, app):
    auth.login()
    assert client.get('/create').status_code == 200
    client.post('/create', data={'title': 'created', 'body': ''})

    with app.app_context():
        db = get_db()
        count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
        assert count == 2


def test_update(client, auth, app):
    auth.login()
    assert client.get('/1/update').status_code == 200
    client.post('/1/update', data={'title': 'updated', 'body': ''})

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post['title'] == 'updated'


@pytest.mark.parametrize('path', (
    '/create',
    '/1/update',
))
def test_create_update_validate(client, auth, path):
    auth.login()
    response = client.post(path, data={'title': '', 'body': ''})
    assert b'Title is required.' in response.data

La vista delete debería redirigir a la URL del índice y la entrada ya no debería existir en la base de datos.

tests/test_blog.py
def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers["Location"] == "/"

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

Ejecución de las pruebas

Some extra configuration, which is not required but makes running tests with coverage less verbose, can be added to the project’s pyproject.toml file.

pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.coverage.run]
branch = true
source = ["flaskr"]

Para ejecutar las pruebas, utiliza el comando pytest. Encontrará y ejecutará todas las funciones de prueba que hayas escrito.

$ pytest

========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================

Si alguna prueba falla, pytest mostrará el error que se produjo. Puedes ejecutar pytest -v para obtener una lista de cada función de prueba en lugar de puntos.

Para medir la cobertura de código de tus pruebas, utiliza el comando coverage para ejecutar pytest en lugar de ejecutarlo directamente.

$ coverage run -m pytest

Puede ver un simple informe de cobertura en el terminal:

$ coverage report

Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%

Un informe HTML le permite ver qué líneas se han cubierto en cada archivo:

$ coverage html

Esto genera archivos en el directorio htmlcov. Abra htmlcov/index.html en su navegador para ver el informe.

Continúe con Despliegue en producción.