总结
和我一起唱!
冒烟测试,让你快速失败;
回归测试,不打破过去;
健全性检查,保留所拥有;
集成测试,处理副作用;
端到端,永无尽头!
回测,所有的东西!
property tests have pros and cons, But they’ll guarantee your slot at PyCon. (属性测试有利有弊,但会保证你在PyCon的位置)
你知道的,命名是个麻烦事
我们今天会介绍一些行话(黑话),目的是让你了解测试的类别。
我们不会涵盖的内容
测试是一个广阔的领域。
检查类型提示是一种测试,linting 也是如此。将软件交到用户手中,看看他们如何使用它也是测试。有时你放一个摄像头,称之为人体工程学/可用性测试,有时你把它发布给一组选定的用户,并称之为alpha/beta测试,有时你把它随机施加到你的生产区域的一个子集,让每个人都感到困惑。这就是 A/B 测试。
一些公司有专门的部门来手动验证软件,他们可能称之为质量测试或验收测试。我有一些朋友根本没有自动化测试,但在每次发布之前,他们都会点击他们应用程序上的每个按钮。是的,这也是测试。
然后你有安全测试,以及各种模糊测试、红队和对抗性压力。再次测试。
还没完。将系统置于预期的压力下,这就是负载测试。把它放在极端的压力下,这就是压力测试。从长远来看,检查它在这些压力下的表现,这就是浸泡测试。看看当这个压力突然变化时它的表现,这就是尖峰测试。所有这些都被归入性能测试的保护伞下。
拔掉服务器是一种测试形式,听说过混沌猴子吗?
甚至审计也是一种测试形式。
由于我不是要写 900 页的三部曲,所以我将坚持单元测试、集成测试、端到端测试和属性测试。这已经很多了,我们可能需要为每个人写一篇文章。此外,大多数人永远不会在进行所有这些类型测试的环境中工作。成本是巨大的,只有非常大的公司才能负担得起整个套餐。
单元测试(Unit tests)
当人们说测试时,通常说的就是这个。
如果你去维基百科,会看到:
单元测试,又名组件或模块测试,是一种软件测试形式,通过它测试隔离的源代码以验证预期行为。
单元测试描述了在单元级别运行的测试,以对比集成或系统级别的测试。
so,你隔离了一部分代码,进行测试,这部分代码成为单元(unit)。
什么部分?怎么隔离?怎么测试?维基百科进一步告诉我们:
单元通常意味着相对较少的代码量;可以与代码库的其余部分隔离的代码,这些代码库可能是一个庞大而复杂的系统。在过程编程中,单元通常是一个函数或模块。在面向对象编程中,单元通常是一个方法、对象或类。
所以基本上,你可以测试一个函数、一个方法、一个对象、一个类或一个模块。就像你可以驾驶三轮车、自行车、汽车、卡车或星际飞船一样。与银河系的其他其他驱动器隔离开来。
冒烟测试(Smoke testing)
冒烟测试是非常基本的初步测试,用于检查软件的基本功能是否正常。这基本上是为了节省你的时间:如果失败了,那么研究细节就没有意义了。
这是我经常写的第一个单元测试:
def test_import():from the_module import main_entry_point
为什么?因为 Python 被导入陷阱所困扰: sys.path 、循环依赖关系、阴影,应有尽有。导入可能会在测试之外失败,但这样我就不会有测试报告,我的测试会崩溃。如果我的项目根本没有加载,这个报告干净,没有歧义。这不仅适用于我,也适用于我的后辈搞砸了,不得不在聊天中报告一些事情。我可以告诉他们先运行这个测试。
冒烟测试有多种形式,就像我说的,它是一个去频谱。它不仅适用于单元测试,还可以进行端到端的冒烟测试,例如运行 CLI --version
并查看它是否返回错误代码。
使用冒烟测试有两个原因:
- 作为测试的起点,它容易读写,并让人们参与进来。
- 节省时间,如果冒烟出错了,就不用浪费时间调试更小的东西了。
回归测试
回归测试的主要好处是:保证不会破坏已有的好代码。
例如:
def test_add_strings(setup_and_tear_down):result = add("1", "2")assert result == "12"
这是一个回归测试,如果正确修改了代码,该测试应该同意通过。
健全性测试(Sanity tests)
回归测试的另一面是健全性检查:你确保特定的东西按预期工作。它节省了开发时间,而不是一次又一次地手动运行它,您可以将其委托给测试机器。它让您高枕无忧。它迫使你使用代码的 API,从而了解你的设计带来的权衡。当然,它会对规范的合规性进行编码,或者表明错误修复确实可以修复错误。
通常,回归测试只是旧的健全性检查。
我交替使用它们,对我来说这是一回事,这更像是上下文和词汇的问题,而不是实际的划分。但是你知道极客,我们喜欢分类法。
啊,我在开玩笑,我两者都不用。当我谈论它们时,我只是说“单元测试”,或者只是“测试”。团队知道。
单元测试的范围
我将在另一篇文章中专门讨论单元测试的良好实践。现在,假设单元测试是“不太大”的测试。在这一点上,我站在维基百科的一边。此外,大多数人倾向于同意单元测试是那些几乎没有副作用的单元测试,尤其是 I/O,例如网络调用、文件系统访问等。
如果你将一些不可变的参数传递给单个函数并检查结果,你将很难找到有人会认为这不是一个单元测试。
集成测试(Integration tests)
集成测试是检查“比单元测试更多,但比端到端少,并可以接受副作用”的测试。这是确切的科学定义。不要检查。
他们的目标是查看几个组件是否协同工作。比如,模型是否从缓存中加载?API 是否检查权限?
所以这是一个集成测试:
def test_user_authentication():user = auth_service.authenticate("username", "password")assert user is not None
因为尽管只有几行,但它对系统进行了大量操作,并且实际上它调用了另一个系统:数据库。
您希望集成测试易于单独运行,因为:
- 它们比单元测试慢
- 可能有副作用
- 可能有肮脏的mock
- 更脆弱
然而,在现场,它们只是混在一大堆测试中,与单元测试混合在一起是很常见的。毕竟,它们还可以检查回归或健全性,而且它们看起来很像单元测试。如果可以,请将它们放在单独的目录中,使用装饰器标记它们,或使用命名约定,以便可以筛选它们。
不幸的是,这并不总是可能的。坦率地说,并不总是可取的。请记住,这完全与目标和约束有关。如果运行整个测试套件需要 4 分钟以上,但将整个测试套件分开会花费很多,您可能不在乎。
分离的主要好处是迫使开发人员考虑其组件的纯度。缺点是开发人员可能过于关注组件的纯度。
许多项目都会有一个巨大的测试目录,其中大部分是集成测试,很少有单元测试,将整个 blob 称为“测试”,而且它们做得很好。不要对此过于强调。它可能是一个非常耦合的设计的标志,同样,这可能是一件好事,也可能是一件坏事,这取决于你的环境。不过,这值得研究,因为它可能会破坏一个项目。
现在,在美妙的 IT 世界中,总有一个问题。集成也用于“持续集成(continuous integration, CI)”的上下文中,即每次推送新代码时,在所有支持的平台上打包、安装和运行软件以及所有测试的做法。想想 GitHub Actions、Gitlab CI、Azure Pipelines、Travis、Jenkins…我们不想让彼此之间的沟通变得太容易,不是吗?
对于一个小团队和项目来说,持续集成是矫枉过正的。在发布之前进行手动检查阶段就足够了。使用 nox + doit 等工具可以轻松完成,以后您可以随时从该工具迁移到 CI。事实上,我的大多数 CI 只是在幕后打电话,因为我讨厌充满激情的模板化 YAML,并且每周都有撒旦仪式专门诅咒想出它们的人。
当您成长时,CI 在避免人为错误、执行策略、管理复杂性等方面变得方便。一旦您进行了大量的兼容性测试以检查不同的 Python 版本、浏览器、设备和操作系统,这绝对是无价的。但是,您公司的 Web API 精确地运行在 CentOS 6 + Python 3.5.1 上,您与一个 3 人团队一起开发,都在推动主 Git 分支,绝对可以推迟采用。
端到端测试(End-to-end tests)
简称为e2e,它是一种测试形式,试图以用户的方式执行系统的大部分内容。
让我们以联系表单为例,以及如何对其进行端到端测试:
import pytest
from playwright.sync_api import sync_playwright
from contact.models import ContactMessage@pytest.mark.django_db
def test_contact_form_submission(playwright_context):# playwright is a lib to manipulate a web browser from pythonwith sync_playwright() as playwright:# Start a real web browser with JS support and actually# navigate to the sitebrowser = playwright.chromium.launch()context = browser.new_context()page = context.new_page()# Assume the server has been started somewhere elsepage.goto("http://localhost:8000/contact")# Fill the contact formpage.fill('#name', 'John Doe')page.fill('#email', 'johndoe@example.com')page.fill('#message', 'Hello, this is a test message.')page.click('button[type="submit"]')# Wait for the form to be submitted and confirmation message to appearpage.wait_for_selector('.success-message')browser.close()# Check if the contact message exists in the databaseassert ContactMessage.objects.filter(name='John Doe',email='johndoe@example.com',message='Hello, this is a test message.').exists()
您会注意到:
- 它使用真正的浏览器、HTML、CSS 和 JS 测试前端。
- 它执行 DOM 并形成交互。
- 它检查 HTTP 堆栈,因为它发出真正的 POST 请求。
- 它运行您的后端代码、验证、身份验证等。
- 它确保数据库确实是最新的。
- 它甚至可以确保响应按预期返回。
它们是一个很棒的 canari,可以快速告诉您是否会影响大量用户的东西正在疯狂运行。他们会告诉你,如果你破坏了UI,让人们感到困惑。他们会告诉你,如果你的集成测试错过了房间里的大象。他们会告诉你,如果你一直有错误的期望,并让你立足于现实。
有一些严肃的批评者对端到端测试咆哮。他们说它们很脆,维护成本高。
我同意它们在编写、读取和调试方面很混乱,而且工具可能会更好。
但他们脆弱的名声也是许多团队有的可怕习惯的结果,那就是破坏用户空间。
“快速行动,打破常规”,“尽早发布,经常发布”,“功能标志”以及所有那些非常聪明和成功的人卖给你的东西。你知道他们也做什么吗?让用户感到困惑,破坏客户的生产力,将支持变成猫捉老鼠的游戏,总而言之,粉碎了您的可靠性光环。
现在,我了解到,在产品的早期阶段,e2e测试基本上是一次性的。你正在学习,你没有稳定性保证,等等。需要保持灵活、精益和快速。
通常,只有少数几个可以缓解这种情况。主要代码路径。改变 10 个测试并不是世界末日,它会很快发现很多问题。
但是一旦你的产品稳定了,我发现端到端的测试可以让你保持诚实:如果你破坏了其中的200个测试,并且它们突然要花很多钱来更新,那么你可能正在做一些对用户不利的事情。
它们也是非技术人员能够很好地理解并可以做出贡献的测试。他们讲述用户故事。
但是,是的,它们很难书写和阅读。副作用、时间、混合上下文和跨越边界使它们变得混乱。我们也只有马马虎虎的工具包,测试 GUI 或 TUI 充其量只是我。如果您必须测试 PDF 输出,愿上帝怜悯。
此外,它们又慢又重,你当然不想在 Git 预提交钩子上运行它们。
尽早做 e2e,但只是一点点。甚至可能只有一个。这样可以保持多汁的股息,并且较低的进入成本。即使对于 CLI,也可以查看此示例中的 ROI,该示例用于测试发送 SMS 警报的命令行工具:
import pytest
import subprocess
import time
from twilio.rest import Client# Twilio is a service that let you send text messages programmatically
account_sid = os.environ['TWILIO_SID']
auth_token = os.environ['TWILIO_TOKEN']
to_number = os.environ['TEST_USER_PHONE_NUMBER']
from_number = os.environ['TEST_SERVICE_PHONE_NUMBER']def test_send_sms():test_message = "This is a test message"# Run the CLI command in a different processsubprocess.run(['python', 'send_sms.py', test_message, to_number], check=True)# Wait for the message to be sent and receivedtime.sleep(10)twilio_client = Client(account_sid, auth_token)messages = twilio_client.messages.list(to=to_number, from_=from_number, limit=1)assert len(messages) > 0assert messages[0].body == test_message
我们行使一切,参数解析,网络调用,接收,消息完整性,我们的帐户订阅已支付(尽管它非常重要并且搞砸了世界各地的许多公司,但没有人测试过)…
当然,它有很多问题:
- 网络或 Twilio 可能会瘫痪。
- sleep时间可能会有一天关闭。
- 我们不依赖向该号码发送消息的其他任何内容。
- 如果你搞砸了,比如引入一个循环调用该测试的错误,它可能会花费你很多钱。
但是你不能躲在关注点的分离后面,如果链条的任何部分薄弱,你的产品坏了,你就会知道。
一旦产品成熟,就要加倍努力。将它们与您的单元和集成测试保持良好分离。它们不应该相互影响。您应该能够破坏私有 API,而不会完全影响 e2e。您应该能够更改您练习 UI 的方式,而无需使用较小的部分来实现它。
最后,我再说一遍,请记住,测试是一个频谱。您不必处于绝对的一端,端到端才有价值。使用 FastAPI 查看该示例:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from our_project.site import fast_api_appclient = TestClient(fast_api_app)def test_read_user_profile():response = client.get("/me")assert response.status_code == 200assert response.json() == {"username": "BiteCode", "id": "987890789790"}
它测试整个 API 端点,包括数据库调用,但不会旋转真实服务器,因为它使用创建 Python HTTP 请求对象而不是解析字节字符串的测试客户端。它也没有执行真正的客户端解析响应。
Is that e2e? Is that integration testing? Maybe it’s Maybelline.
谁在乎,它很有用。
把它放在其中一个文件夹中,同意你的团队始终如一地这样做,然后转到下一个可交付成果。
回测(Backtesting)
回溯测试是测试中被忽视的领域,你会发现它主要发生在机构中的大型、有风险的长跑运动员身上。
这是一个积累输入和输出的过程,你知道这些输入和输出应该对你的系统有效,然后定期向它提供整个数据集,以检查它是否仍然像这样运行。
它是回归和端到端测试的混合体,两者各有利弊。
它成本高昂、速度慢,并且会使您的功能集变得石化。
但是,您这样做的时间越长,您的系统就越可靠,尤其是在错误和边缘情况的长尾中。有些用户会喜欢你,因为你一直在他们身边,有些用户会讨厌你,因为你从未现代化。此外,您还必须大量处理架构版本控制。
简而言之,它非常适合银行支付系统,而对于热门的启动手机应用程序来说完全不够用。
它是什么样子的?
想象一下,一个交易者想要改变他的加密货币机器人行为,但希望看到在相同的市场下,与之前的策略相比,这将如何影响他的收益:
import pandas as pd# Load historical data# Kryll is a veteran token that powers an automated trading platform,
# which, funnily, provide a UI to create strategies and backtest
# them without code. But pandas is free :)df = pd.read_csv('kryll_historical_data.csv', parse_dates=['Date'])
df.set_index('Date', inplace=True)# Calculate moving averages.
short_window = 40
long_window = 100df['SMA40'] = df['Close'].rolling(window=short_window, min_periods=1).mean()
df['SMA100'] = df['Close'].rolling(window=long_window, min_periods=1).mean()# Define the trading signals
df['Signal'] = 0
df['Signal'][short_window:] = np.where(df['SMA40'][short_window:] > df['SMA100'][short_window:], 1, 0)
df['Position'] = df['Signal'].diff()# Initialize backtesting variables
initial_capital = 100000.0
positions = pd.DataFrame(index=df.index).fillna(0.0)
portfolio = pd.DataFrame(index=df.index).fillna(0.0)# Simulate trades. This is BS, but have you worked in finance?
positions['Kryll'] = df['Position'] * initial_capital / df['Close']
portfolio['Positions'] = (positions.multiply(df['Close'], axis=0)).sum(axis=1)
portfolio['Cash'] = initial_capital - (positions.diff().multiply(df['Close'], axis=0)).sum(axis=1).cumsum()
portfolio['Total'] = portfolio['Positions'] + portfolio['Cash']# Calculate returns
portfolio['Returns'] = portfolio['Total'].pct_change()# Display the portfolio and performance metrics
print(portfolio)# Plot the results
import matplotlib.pyplot as pltfig, ax = plt.subplots(figsize=(12, 8))
ax.plot(df.index, portfolio['Total'], label='Portfolio Value')
ax.plot(df.index, df['Close'], label='Kryll Close Price', alpha=0.5)
ax.set(title='Backtest of SMA Crossover Strategy', xlabel='Date', ylabel='Value')
ax.legend()
plt.show()
我保留了量化代码的美妙风格,包括内联导入,这样你就可以体验到我们的经济所依赖的东西。我在开玩笑,加上我根本没有测试过这个脚本,它更接近伪代码。
回溯测试不一定是完全自动化的,也不一定是一对一的匹配才有用。有时你希望你的系统表现得和以前完全一样,但有时你只是希望趋势大致相似或更好,因为你知道不可能得到完全相同的结果。
这就是它在这里的内容:我们在脚本末尾显示结果的 matplotlib 曲线,因此我们可以直观地检查结果与之前的结果进行比较。
当然,并非所有的回测都是这样的。有些需要完美的对齐并且不涉及人类,但您的数据集越大,它发生的可能性就越小,甚至不可能发生。现实充满了复杂性。
属性测试(Property tests)
也被称为“我们在 PyCon 上看到过,还记得吗?”,因为每个听说过它的人都认为它很酷,但实际这样做的人数接近 Raspberry Pi 上的引脚数量。
这个想法是运行代码,但不是测试结果,而是检查无论输入是什么,通用属性是否仍然为 true。然后,一个工具(在Python中,通常是优秀的假设)将尝试将各种垃圾传递给它,直到它崩溃。
我发誓,这非常有用。
首先选择一个单元测试来练习程序的关键部分。属性测试既缓慢又昂贵,因此您通常从小处着手。您还希望避免副作用,因为代码将以不受控制的方式运行数百万次,因此很难管理因果关系链。这是与一般模糊测试的主要区别,一般模糊测试实际上旨在制造混乱,并且这可能是可取的,尤其是对于安全性而言。
让我们回到本系列文章的第 2 部分中的 add() 示例。我们有:
import randomimport pytest
from the_code_to_test import add@pytest.fixture()
def random_number():yolo = random.randint(0, 10)yield yoloprint(f"\nWe tested with {yolo}")@pytest.fixture()
def setup_and_tear_down():print("\nThis is run before each test")yieldprint("\nThis is run after each test")def test_add_integers(setup_and_tear_down, random_number):result = add(1, 2)assert result == 3result = add(1, -2)assert result == -1assert add(0, random_number) > 0def test_add_strings(setup_and_tear_down):result = add("1", "2")assert result == "12"def test_add_floats():result = add(0.1, 0.2)assert result == pytest.approx(0.3)def test_add_mixed_types():with pytest.raises(TypeError):add(1, "2")
我们怎么知道我们测试了所有边缘情况并找出了所有错误?这当然是不可能的,但我们有多大的信心去追逐所有最明显的目标?
现在你的直觉是,对于这样一个简单的函数,域是相当明显的,我们不可能错过什么。我的意思是,来吧,这是 add() !
但像往常一样,编程是在嘲笑我们的天真,而且有龙。
假设在这里可以提供帮助,所以让我们在拥有 pip installed hypothesis-pytest
条件后再创建一个测试:
import pytest
from the_code_to_test import add
from hypothesis import given, strategies as st@given(st.one_of(st.integers(), st.floats()), st.one_of(st.text(), st.integers(), st.floats()))
def test_add_mixed_types_property(a, b):if isinstance(a, (int, float)) and isinstance(b, (int, float)):result = add(a, b)assert result == a + belse:with pytest.raises(TypeError):add(a, b)
我不会在这里详细介绍,会有一篇专门介绍属性测试的文章。但从本质上讲,我们告诉假设我们想检查属性,说明“使用 add() 时,要么我们传递相同的类型,结果为相同的类型,要么我们没有传递相同的类型,并且存在错误”。
理智行为:添加字符串,取回字符串。添加浮点数,取回浮点数。添加一个字符串和一个 int,这是一个错误。
从这个测试中,假设将生成大量输入数据的组合,运行代码并试图证明我们只不过是愚蠢的小猿猴,愚蠢地相信我们一直在控制之中。
可能出什么问题?
房间里所有因这个问题而遭受巨大痛苦的数据科学家,都已经以一种心爱的印度面包的形式尖叫着答案,但讨厌浮点值:
a = 0, b = nan@given(st.one_of(st.integers(), st.floats()), st.one_of(st.text(), st.integers(), st.floats()))def test_add_mixed_types_property(a, b):if isinstance(a, (int, float)) and isinstance(b, (int, float)):result = add(a, b)
> assert result == a + b
E assert nan == (0 + nan)
E Falsifying example: test_add_mixed_types_property(
E a=0,
E b=nan, # Saw 1 signaling NaN
E )