[18] [烧瓶里的部落格] 08. 撰写测试

写单元测试可以检查程序是否按预期执行,Flask 可以模拟发送请求并回传资料

应当尽可能多进行测试,函数中的程序只有在函数被呼叫的情况下才会运行
程序中的判断条件,例如 if 判断条件下,只有在符合条件的情况下才会运行
测试应该覆盖每个函数和每个判断条件

越接近 100% 的测试覆盖率,越能够保证修改程序後不会出现意外
但是 100% 覆盖率也不能保证程序没有错误,因为单元测试测试不会包含使用者如何在浏览器中操作
尽管如此,在开发过程中测试覆盖率仍然是非常重要的

这边会使用 pytestcoverage 来进行测试和评估
先进行安装:

pip install pytest coverage

设定

测试程序位於tests资料夹中,该资料夹位於flaskr的同层而不是里面
tests/conftest.py 文件包含名为fixtures的设定函数,每个测试都会用到这个函数
测试位於 Python 模组中,以test_开头,并且模组中的每个测试函数也以test_开头

每个测试都会建立一个新的临时资料库档案,并产生一些用於测试的资料
写一个 SQL 档案来新增资料

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');

app fixtures 会呼叫工厂并为测试传递test_config来设定应用和资料库,而不使用本机的开发环境
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() 建立并开启一个暂时档案,回传档案说明和路径
DATABASE路径被重新载入,这样它会指向临时路径,而不是之前存放资料库实体的资料夹
设定好路径之後,测试资料库被建立,然後资料被写入。当测试结束後暂时档案会被关闭并删除

TESTING 告诉 Flask 应用处在测试模式下
这个情况下 Flask 会改变一些内部行为方便测试,其他的扩展也可以使用这个标签让测试更容易进行

clientfixture 呼叫app.test_client()app fixture 建立的应用程序物件
测试会使用用户端来发送请求,而不用预先启动服务

runnerfixture 类似於client
使用 app.test_cli_runner() 建立一个可以呼叫应用中注册功能 Click 指令的 runner

Pytest 通过对应函数名称和测试函数的参数名称来使用 fixture
例如下面要写test_hello函数有一个client参数
Pytest 就会使用名为client的 fixture 函数,呼叫函数并把回传值给测试函数

工厂

工厂本身没有什麽好测试的,因为大部分程序会被每个测试用到
因此如果工厂程序有问题,那麽在进行其他测试时会被发现

唯一可以改变的行为是传递测试 config,如果有传递设定内容要被覆写
否则应该要使用预设值

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!'

还记得在一开始这系列之前我们有在flaskr/__init__.py建立过hello路由
它会回传「Hello, World!」,所以我们就来测试的执行结果是否一致

资料库

在一个应用环境中,每次调用get_db都应该回传相同的连线
而在退出环境後,连线应该被中断

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)

指令init-db应该呼叫init_db函数并输出一个讯息

接着使用 Pytest’s monkeypatch fixture 来取代init_db函数进行测试
使用前面写的runner fixture 透过名称呼叫init-db指令

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

会员验证

在大多数 view 里,使用者需要先登入
在测试中最方便的方法是使用用户端制作一个 POST 请求发送给login view
与其每次都写一遍,不如写一个 class 来做这件事,并使用一个 fixture 把它传递给每个测试的用户端

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)

通过auth fixture,可以在测试中调用auth.login()登入为使用者:test
这个使用者的资料已经在 app fixture 中写入了

注册

register view 应该在GET请求时渲染成功
POST请求中,表单资料通过验证时 view 应该要写入使用者资料到资料库,并重新导向到登入页
资料验证失败时,要显示错误讯息

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 'http://localhost/auth/login' == response.headers['Location']

    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() 制作一个GET请求并由 Flask 回传 Response 物件
POST也是类似的作法 client.post(),把data dict 当作要送出的表单

为了测试页面是否渲染成功,制作一个简单的 request,并检查是否返回一个200 OK status_code
如果渲染失败 Flask 会回传一个500 Internal Server Error 状态码

headers will have a Location header with the login URL when the register view redirects to the login view.

当注册 view 重新导向(3XX)到登入 view 时,headers 会包含登入 URL 的 Location

data 以 bytes 方式回传
如果想要检测渲染页面中的某个值,那就在data中检查
bytes 值只能与 bytes 值作比较。如果想比较字串,要使用get_data(as_text=True)

pytest.mark.parametrize 告诉 Pytest 以不同的参数执行同一个测试
这里用於测试不同的非法输入和错误讯息,避免重复写三次相同的程序

登入

login view 的测试和register非常相似,後者是测试资料库中的资料
前者是测试登录之後session应该要包含user_id

tests/test_auth.py

def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers['Location'] == 'http://localhost/'

    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

通常情况下,若是在请求之外存取session会发生错误
如果在with区块中使用client则允许我们在 response 回传之後操作环境变数,例如 session

登出

logout测试与login相反,登出之後session里面不应该包含user_id

tests/test_auth.py

def test_logout(client, auth):
    auth.login()

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

部落格

所有部落格的 view 使用之前所写的authfixture
呼叫auth.login(),并且用户端的後续请求会作为叫做test的使用者

index view 应该要显示已经加入的测试文章,作为作者登入之後应该要有编辑的连结

当测试indexview时,还可以测试更多验证行为
当没有登录时每个页面显示登入或注册连结,当登入之後则是要有登出连结

画面渲染

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

使用者权限

使用者必须登入後才能造访createupdatedelete 的 view
文章作者才能造访updatedelete,否则回传一个403 Forbidden状态码
如果要访问的文章id不存在,那麽updatedelete要回传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'] == 'http://localhost/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

新增修改

对於GET请求,createupdate view 应该渲染画面和回传一个200 OK状态码

POST请求发送了合法资料後

  • create应该在资料库加入新的文章资料
  • update则应该修改资料库中已经存在的资料
  • 资料验证失败时,两者都要显示一个错误讯息

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

删除

delete view 要重新导向到首页,并且文章要从资料库里被删除

tests/test_blog.py

def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers['Location'] == 'http://localhost/'

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

执行测试

额外的设定可以加入到专案的setup.cfg档案,这些设定不是必需的,但是可以让覆盖率测试不这麽冗长
setup.cfg

[tool:pytest]
testpaths = tests

[coverage:run]
branch = True
source =
    flaskr

使用pytest来执行测试,该指令会找到并且执行所有测试!

如果有测试失败, pytest 会显示引发的错误
例如这边我们就遇到一个错误

这可能是因为你的虚拟环境重开过,重新打包就没事了
打包的指令是

pip install -e .

可以使用pytest -v得到每个测试的列表,而不是一串点

要评估测试的覆盖率的话,可以使用coverage指令来执行 pytest

coverage run -m pytest

接着就可以取得简单的覆盖率报告

coverage report

除此之外还可以生成 HTML 的报告,可以看到每个档案中哪些内容有被测试覆盖

coverage html

这个指令会在htmlcov资料夹中产生测试报告
在浏览器中打开htmlcov/index.html就可以看到结果


<<:  自动化 End-End 测试 Nightwatch.js 之踩雷笔记:关闭多视窗

>>:  【Day 17】Google Apps Script - API 篇 - Spreadsheet Service - 电子试算表服务介绍

[想试试看JavaScript ] 运算子与自动转型

运算子 所谓的运算子就是就是一些符号,运算广义来说就是对资料做的任何动作。 平常生活中的加减乘除是一...

Day 31: 【全系列终】架构考古学

Appendix: 架构考古学 联盟会计系统 简述 1960 年代,很简单的 CRUD 记帐系统,由...

#18-手写字特效炫起来!(SVG dasharray & dashoffset)

第3天写了打字特效炫起来! 今天来个姊妹篇,手写字特效炫起来! 已经默默进入SVG几天了... 老样...

资料库正规化说明(Day11)

目的 1.降低资料重复性(Data Redundancy) 2.避免资料更新异常(Anomalies...

表单处理 Object 里的 Array

今天来看看一个常见问题。 { first_name: 'chris', last_name: 'wa...