自动化测试pytest
默认是不打印 logger 或print的日志,可以用下面的方法来打印
python -m pytest -s --capture=no --log-cli-level=INFO test_all.py
Pytest 命名规范
类名必须Test开头
类里方法名必须 test_开头或者 _test 结尾
文件名必须 test_开头
如果想要修改默认的配置,则需要创建一个 pytest.ini 配置文件。无论是主函数模式运行,还是命令行运行,都会读这个配置文件。
入门示例
创建一个all.py文件,同时创建一个user文件夹
import pytest
if __name__ == "__main__":
pytest.main(["-sv","./user", "-n=2"])
在user文件夹下,创建一个 test_get_user.py文件
class TestGetUser:
def test_get_user(self):
print("test get user function")
此时pytest会执行 user 文件夹下的所有测试case,当然如果main 函数里不指定参数也是可以的,则会执行当前文件夹以及遍历其子文件夹下的所有测试案例。
如果使用pytest cli那么命令为 pytest -sv ./user
如果想要用多线程的话,可以加上 -n 参数,比如 pytest -sv ./user -n 2
如果想要让测试案例失败的时候,重跑,那么main函数里可以加 "--reruns=2",这样当失败的时候,会再重试2次(一共3次)
简化版
比如下面的目录结构,mymath.py里只有一个 add(num1, num2) 函数,在pycharm里,将 src 和test所在的目录,设置为 Source Root,
.
├── src
│ └── mymath.py
└── test
└── all.py
all.py 的内容为
from src.mymath import add
def test_add():
result = add(3,4)
assert result == 7
那么只需要执行下面的测试命令即可
python -m pytest test/all.py
coverage
使用coverage可以轻松查看pytest的覆盖率
# run test
python -m pytest test_all.py
# run report by coverage command
coverage run -m pytest test_all.py
coverage report
# get html report
coverage html
pytest 使用
改变执行顺序
通过pytest.mark.run 来改变之行顺序,默认情况下,pytest是按函数名从上往下之行的。这个跟unitest不同,unitest是根据函数名ascii顺序执行。
@pytest.mark.run(order=2)
def test_xxx():
print("xxxx")
分组执行
如果我们的测试比较复杂,分不同的模块(文件夹),下面分别又有不同的测试样例,我们想测试某些文件夹下特定的某几个文件。比如执行冒烟测试,分接口测试等。
此时可以在需要进行测试的函数上面,通过 @pytest.mark.xxxx 做标记,注意xxx是可以自己随便写的,比如我们写smoke,usermanage, productmanage,即使分布在不同的模块下,不同的函数里,我们也可以在测试的时候,来执行那类标记被执行
@pytest.mark.smoke
def test_aa():
print("aa")
@pytest.mark.usermanage
def test_bb():
...
@pytest.mark.productmanage
def test_cc():
...
另外,还需要创建一个 pytest.ini 文件,将这些自定义marker注册进去
[pytest]
markers =
smoke: anything you can input here
usermanage: xxxx
productmanage: xxxx
执行的时候输入 pytest -m "smoke" test.py 则只执行 smoke里的样例。如果想一次性执行两个,则是 pytest -m "smoke or usermanage" test.py
跳过
如果想要跳过某一个测试用例,则用 pytest.mark.skip 就可以了,这样默认就不执行了
@pytest.mark.skip
def xxx():
...
在跳过的时候,还可以指定条件
bb=16
@pytest.mark.skipif(bb < 18, reason='小于18')
def test_bb():
print("bb")
pytest.fixture 与setup/teardown
setup/teardown 与 setup_class 与 teardown_class
这个方法比较直接,适合简单测试场景 在pytest里面,如果想要在执行测试前,和测试后,做一些事情。比如在测试前,第一步要打开浏览器,测试完成,最后一步关闭浏览器,那么我们就要用到 setup_method和 teardown_method的函数(对每一个测试用例,即每一个函数都执行前后都会执行这个操作),注意函数名必须这么写(小写),如果函数在类里(比如日志对象,数据库连接等),则是 setup_class 和 teardown_class(在类初始化以及执行完成只做一次)。示例:
import pytest
bb = 20
class TestAA:
def setup_class(self):
print("\nsetup class")
def setup_method(self, method):
print("\nsetup class===")
def test_aa(self):
print("aa")
@pytest.mark.skipif(bb < 18, reason='大于18')
def test_bb(self):
print("bb")
def test_cc(self):
print("cc")
def teardown_method(self, method):
print("\nteardown method+++")
def teardown_class(self):
print("\nteardown")
pytest.fixture
pytest.fixture适合更灵活,需要复用参数的复杂测试场景。在fixture里,函数名可以随便起,如果后面的参数想自动引用,则 autouse=True,如果没有,需要将这个函数名作为参数,传给后面真正的方法。 在fixture里,yield相当于会把函数分成两部分,上面是setup,后面是 teardown
class TestMain:
@pytest.fixture(scope="session", autouse=True)
def set_env(self):
os.environ["ES_HOST"] = "mydomain.com"
os.environ['REGION'] = 'ap-northeast-1'
yield
del os.environ["ES_HOST"]
del os.environ['REGION']
不过对于改环境变量,更好的办法是通过 monkeypatch的方法。如果用上面的方法,需要显性的删除 os.environ,并且所有的测试单元里,用的是同一份环境变量,这意味着,如果某一个单元测试改了环境变量的值,会影响另外一个单元测试。如果用下面的 monkeypatch,则所有的单元测试的环境变量是独立的。monkeypatch除了能改环境变量外,还能改某一个函数的返回值,某一个json的key或value等
class TestAll:
@pytest.fixture(autouse=True)
def setup_env(self, monkeypatch):
monkeypatch.setenv("TEST_ENV", "1")
测试函数参数
在 pytest 中,测试函数都是以 test_ 开头的,如果测试函数要传入参数,这个参数往往是一个 fixture (可以是 pytest 自带的 fixture,如 tmp_path,也可以是自定义的fixture)。
比如我们需要创建一个测试文件,并向这个文件里写点东西,那么我们可以用 tmp_path 这个python自带的fixture,它会自动创建这个文件,并在测试完成后删除。由于是自带的fixture,所以 test_bucket_ecr_mapping 函数可以直接使用这个作为参数
def test_bucket_ecr_mapping(tmp_path):
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('{"bucket1": "ecr1"}')
with patch('lambda_function.open', create=True) as mock_open:
mock_open.return_value.__enter__.return_value.read.return_value = '{"bucket1": "ecr1"}'
result = bucket_ecr_mapping()
assert result == {"bucket1": "ecr1"}
但如果我们传入的是一个自定义的值,那么就需要把这个变成一个fixture。示例我们把 tmp_path 改成 temp_path,则就需要把 temp_path 变成 fixture.
@pytest.fixture
def temp_path(tmp_path):
return tmp_path
def test_bucket_ecr_mapping(temp_path):
mapping_file = tmp_path / "mapping.json"
mapping_file.write_text('{"bucket1": "ecr1"}')
with patch('lambda_function.open', create=True) as mock_open:
mock_open.return_value.__enter__.return_value.read.return_value = '{"bucket1": "ecr1"}'
result = bucket_ecr_mapping()
assert result == {"bucket1": "ecr1"}
在上面的示例中,注意看 patch('lambda_function.open', create=True)
这一部分,虽然 open() 函数是python builtin的,在 lambda_function里并没有直接定义,且 lambda_function里的子函数才会用到,可是在做 from lambda_function import ... 或者 import_funciton 的时候,整个顶层代码都会被执行,所有的函数和类都会被定义,所以即使子函数里用了 open(),也会生效。create=True这个参数,是因为 patch 的 open 并不是模块的属性,所以要允许 mock 创建一个不存在的属性
使用 mock 方法来patch 函数返回值
pytest 可以和 unittest 结合使用,比如当我们在 lambda_function.py 文件里,有一个 get_string_data 的函数,这个函数需要访问外部数据,但我们每次测试的时候,不想让这个接口访问外部数据,那么就可以用 mock的 patch方法,来模拟返回值。在模拟的时候,可以用with上下文管理器结合yield方法,也可以直接用装饰器。两种用法都是可以的
import pytest
from unittest.mock import patch
@pytest.fixture(autouse=True)
def test_get_string_data(self):
with patch('lambda_function.get_string_data') as mock_get_string_data:
mock_get_string_data.return_value = "this is mock return"
yield mock_get_string_data
@patch("lambda_function.my_list", new=["a","b"])
def test_main(self):
from lambda_function import lambda_handler
lambda_handler(None, None)
使用 MagicMock 来指定mock一个类函数或方法
当我们需要mock一个类或函数的时候,我们想要模拟这个类方法的行为,比如requests.Session().get()的这个方法,我们模拟的 requests.Session()是有 get()的这个方法,此时我们可以用 MagicMock() 来模拟一个方法,之后用 return_value来表示这个方法的返回值,如果返回值也是一个方法,比如是一个get()方法,就可以用 return_value.get.return_value=MagicMock()的写法。 在@patch("requests.Session")的装饰器里,patch方法,会默认就将 requests.Session 创建一个 MagicMock(),然后在执行这个装饰器下面函数的时候,会将这个创建的 MagicMock()作为第一个参数,传给下面的装饰器函数,比如我下面的例子,则会自动传给 mock_session。而mock_response本身就是 pytest.fixture指定的一个 MagicMock(),其有两个属性,一个是 text,一个是status_code。我们在 mock_session_instance.get.return_value = mock_response的这行代码,就将 mock_session 的 get()方法(即 requests.Session().get()方法)和 mock_response进行了绑定
@pytest.fixture
def mock_response():
mock = MagicMock()
mock.text = "Example Domain"
mock.status_code = 200
return mock
@patch("requests.Session")
def test_connect_to_somewhere(mock_session, mock_response):
mock_session_instance = mock_session.return_value
mock_session_instance.get.return_value = mock_response
url = "https://www.example.com"
connection = ConnectToSomewhere(url)
assert connection.url == url
assert connection.response.status_code == 200
assert connection.response.text == "Example Domain"
mock_session_instance.get.assert_called_once_with(url)
conftest.py
整个项目中所有需要使用的 fixture 固件,都会放到 conftest.py 文件里,pytest会自动引用,无需手动指定
使用parametrize进行参数化测试
示例一
当需要执行pytest,有多个mock的数据,希望对于这些数据进行测试,可以用 parametrize 来做,比如:
messages = [
"message #1",
"message #2",
"message #3"
]
def my_print(i):
print(f"print {i}")
class TestAll:
@pytest.mark.parametrize("msg", messages)
def test_print(self, msg):
my_print(msg)
注意:上述示例中, parametrize 会将 messages 列表里的每一个元素,在每一次测试的时候都传给 msg,下面的 test_print 函数里的参数,也必须叫 msg才行。
在真实的使用场景中,有可能 messages 列表是某一个方法的返回值,此时我们结合 unittest.mock 的 patch方法,将这个 messages 列表,作为方法的返回值,然后 test_print()里,就能用这个mock的返回值了
示例二
比如我的代码是这样的,此时如果对每一个场景都单独写一个test函数,就会很麻烦,比如年纪大于18写一个,小于18写一个,年龄格式不对的多种场景(输入的是文本,年龄是负数,是小数等),那么要写太多的test_is_audit函数,会很麻烦
def is_adult(age: int):
if not isinstance(age, int) or age < 0:
raise ValueError("Age must be an integer and greater than 0")
if age >= 18:
return True
else:
return False
此时我们可以用 pytest.mark.parametrize 来进行参数化设置。在pytest.mark.parametrize里,第一个参数里存的是要给测试函数传的参数,如果有多个,用逗号分隔。第二个参数是一个list,里面存的是第一个参数对应的值,每一个list代表了一组值,可以测试多组。 由于上面的测试,包含函数成功返回,和异常抛出错误,所以我们分成两个test function进行测试.
import pytest
from data import is_adult
@pytest.mark.parametrize("age, expected", [
(20, True),
(18, True),
(17, False),
(0, False),
])
def test_is_adult(age, expected):
assert is_adult(age) == expected
@pytest.mark.parametrize("invalid_age", [
"xxx",
-1,
3.14,
])
def test_is_adult_invalid_input(invalid_age):
with pytest.raises(ValueError, match="Age must be an integer and greater than 0"):
is_adult(invalid_age)
if __name__ == '__main__':
pytest.main()
Pycharm debug
在 debug python代码的时候,程序会卡在断点的那一样的位置,那一行的代码处于未被执行的状态。pycharm 对查看断点有几个按钮:
step over: 直接执行这一行的代码
step into:进入到这一行代码内部看具体执行过程
Step into my code:跳过系统包和第三方包,只看自己的代码的执行过程。由IDE判断哪些包是第三方。
step out:在 step into 的过程中,将 step into的剩余部分一次性执行完。step into + step out 相当于 step over
Appendix
示例一:读文件
比如有一个main.py文件,
def demo():
try:
with open("README.md", "r") as f:
content = f.read()
except FileNotFoundError as e:
raise FileNotFoundError
except:
raise
return content
def demo2():
try:
f = open("README.md", "r")
content = f.read()
f.close()
except FileNotFoundError as e:
raise FileNotFoundError
except:
raise
return content
此时有一个 test_all.py 文件。注意上述 main.py里,主要区别的地方在于 一个使用了 with 上下文管理器,一个没有使用。这个我们在 mock open 这个方法的时候,就要根据如何使用来决定要mock的方法是什么。
import pytest
from unittest.mock import patch, MagicMock
from main import demo,demo2
def test_demo():
with patch('main.open', create=True) as mock_open:
mock_open.return_value.__enter__.return_value.read.return_value = "Test"
result = demo()
assert result == "Test"
mock_open.assert_called_once_with("README.md", "r")
with patch('main.open', side_effect=FileNotFoundError):
with pytest.raises(FileNotFoundError):
demo()
with patch('main.open', side_effect=Exception("other error")):
with pytest.raises(Exception):
demo()
def test_demo2():
with patch('main.open', create=True) as mock_open:
mock_open.return_value.read.return_value = "Test"
mock_open.return_value.close.return_value = MagicMock()
result = demo2()
assert result == "Test"
mock_open.assert_called_once_with("README.md", "r")
mock_open.return_value.close.assert_called_once()
with patch('main.open', side_effect=FileNotFoundError):
with pytest.raises(FileNotFoundError):
demo2()
with patch('main.open', side_effect=Exception("other error")):
with pytest.raises(Exception):
demo2()
示例二: 使用 moto 来mock AWS的资源
假设我们要mock AWS Secrets Manager 的get secret value 这个动作,那么我们用 moto 要先调用 AWS的API创建出来这个资源,之后才能mock 使用这个资源。 示例
import boto3
def get_secret_value(secret_id):
secretsmanager = boto3.client("secretsmanager")
result = secretsmanager.get_secret_value(SecretId=secret_id)
secret_string = result["SecretString"]
return secret_string
那么我们的pytest 应该这样写
import boto3
from moto import mock_aws
from main import get_secret_value
@pytest.fixture
def aws_credential():
import os
os.environ['AWS_ACCESS_KEY_ID'] = "testing"
os.environ['AWS_SECRET_ACCESS_KEY'] = 'testing'
os.environ['AWS_SECURITY_TOKEN'] = 'testing'
os.environ['AWS_SESSION_TOKEN'] = 'testing'
@pytest.fixture
def mock_secretsmanager(aws_credential):
with mock_aws():
yield boto3.client("secretsmanager", region_name="ap-northeast-1")
@mock_aws
def test_get_secret_value(mock_secretsmanager):
secret_id = "test-secret"
secret_value = "test-value"
mock_secretsmanager.create_secret(Name=secret_id, SecretString=secret_value)
value = get_secret_value(secret_id)
assert value == secret_value
有关moto,其实在最新版本里,mock_aws 会自动替换所有的 boto3 的请求,所以不用再像以前那样对每一个资源都做fixture了。示例 (注意,我们一般把s3这类资源放到了外部,这样能够复用)
s3 = boto3.client("s3")
def create_s3_bucket(bucket_name, s3_client = None):
s3 = s3_client or boto3.client('s3')
s3.create_bucket(Bucket=bucket_name)
return bucket_name
pytest文件可以直接这样写就行了
import pytest
import boto3
from moto import mock_aws
from main import create_s3_bucket
@mock_aws
def test_create_s3_bucket():
"""
测试创建S3存储桶的功能
使用moto库模拟S3服务
"""
# 准备测试数据
bucket_name = "test-bucket"
# 调用被测函数
result = create_s3_bucket(bucket_name)
# 验证结果
assert result == bucket_name
# 验证桶是否真的被创建
s3_client = boto3.client('s3')
response = s3_client.list_buckets()
buckets = [bucket['Name'] for bucket in response['Buckets']]
assert bucket_name in buckets
@mock_aws
def test_create_s3_bucket_already_exists():
"""
测试当桶已存在时的行为
"""
bucket_name = "existing-bucket"
# 先创建一个桶
s3_client = boto3.client('s3')
s3_client.create_bucket(Bucket=bucket_name)
try:
# 尝试再次创建同名桶
create_s3_bucket(bucket_name)
# 如果没有引发异常,测试失败
assert False, "应该引发BucketAlreadyExists或者ClientError异常"
except (s3_client.exceptions.BucketAlreadyExists, Exception) as e:
# 验证异常类型是否符合预期
assert "BucketAlreadyExists" in str(e) or "ClientError" in str(e)
同样,如果想要 mock AWS S3 的API,比如GetObject,那需要先用 moto 创建一个S3 bucket,在在这个mock的bucket里,调用PutObject API给放一些内容,之后在需要测试的函数里,正常调这个moto创建的资源。
最后更新于