pytest 是一个非常灵活且强大的测试框架,它支持简单的单元测试到复杂的功能测试。显著特点是其简洁的语法,可以无需继承 TestCase 类直接使用函数来编写测试用例,并通过 assert语句 进行断言。还支持参数化测试、插件系统以及丰富的 assertion rewriting 功能,使编写测试更加便捷和直观。我们可以使用它进行自动化测试。

PS: 本文基于pytest 8.3.3,建议使用PyCharm编写并运行测试套件,方便。

安装 pytest

在终端中安装:

pip install -U pytest

在PyCharm中安装:

测试用例

在 pytest 框架中,会识别名称前缀为 test_ 或 _test 的函数为测试用例。

# C:\PythonTest\Test\test_mod.py

list_list = [1, 2, 3, 'hello!']


def test_list_fail():
    assert 'hello1!' == list_list[-1], "两个值不同"


def test_list_pass():
    assert 'hello!' == list_list[-1], "两个值不同"

test_ 前缀表示定义的函数是个测试用例。

assert 用于断言。如 assert 后面的第一个值为 True(真) ,则该测试用例继续进行直至结束;如果为 False(假) ,则测试失败,并且 pytest 会停止执行该测试用例并报告错误。 assert 后面的第一个值可以是表达式、函数返回值,甚至直接是布尔值。 assert 后面的第二个值是自定义的失败描述,是一个字符串。

函数 test_answer() 就是一个测试用例,运行后结果如下:

============================= test session starts =============================
collecting ... collected 2 items

test_mod.py::test_list_fail FAILED                                       [ 50%]
test_mod.py:3 (test_list_fail)
'hello1!' != 'hello!'

预期:'hello!'
实际:'hello1!'
<点击以查看差异>

def test_list_fail():
>       assert 'hello1!' == list_list[-1], "两个值不同"
E       AssertionError: 两个值不同
E       assert 'hello1!' == 'hello!'
E         
E         - hello!
E         + hello1!
E         ?      +

test_mod.py:5: AssertionError

test_mod.py::test_list_pass PASSED                                       [100%]

========================= 1 failed, 1 passed in 0.04s =========================

运行后,会输出一个测试报告。 test_mod.py::test_list_fail FAILED 表示测试用例- test_answer 断言失败,测试用例未通过,失败描述是:“E AssertionError: 两个值不同” 。

而 test_mod.py::test_list_pass PASSED 表示测试用例- test_hanshu、test_list 断言成功,测试用例通过。

终端中运行 C:\PythonTest\Test\test_mod.py 中所有测试用例:

cd C:\PythonTest\Test\
pytest test_mod.py

PyCharm中运行 C:\PythonTest\Test\test_mod.py 中所有测试用例:

终端中运行 C:\PythonTest\Test\test_mod.py 中的 test_list_fail 测试用例:

cd C:\PythonTest\Test\
pytest test_mod.py::test_list_fail

PyCharm中运行 C:\PythonTest\Test\test_mod.py 中的 test_list_fail 测试用例:

实际测试中,对请求结果进行断言

首先说明请求结果内容:

[
  {
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "city": "Gwenborough",
      "zipcode": "92998-3874",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
      "name": "Romaguera-Crona",
      "catchPhrase": "Multi-layered client-server neural-net",
      "bs": "harness real-time e-markets"
    }
  },
  {
    "id": 2,
    "name": "Ervin Howell",
    "username": "Antonette",
    "email": "Shanna@melissa.tv",
    "address": {
      "street": "Victor Plains",
      "suite": "Suite 879",
      "city": "Wisokyburgh",
      "zipcode": "90566-7771",
      "geo": {
        "lat": "-43.9509",
        "lng": "-34.4618"
      }
    },
    "phone": "010-692-6593 x09125",
    "website": "anastasia.net",
    "company": {
      "name": "Deckow-Crist",
      "catchPhrase": "Proactive didactic contingency",
      "bs": "synergize scalable supply-chains"
    }
  },
  {
    "id": 3,
    "name": "Clementine Bauch",
    "username": "Samantha",
    "email": "Nathan@yesenia.net",
    "address": {
      "street": "Douglas Extension",
      "suite": "Suite 847",
      "city": "McKenziehaven",
      "zipcode": "59590-4157",
      "geo": {
        "lat": "-68.6102",
        "lng": "-47.0653"
      }
    },
    "phone": "1-463-123-4447",
    "website": "ramiro.info",
    "company": {
      "name": "Romaguera-Jacobson",
      "catchPhrase": "Face to face bifurcated interface",
      "bs": "e-enable strategic applications"
    }
  },
  {
    "id": 4,
    "name": "Patricia Lebsack",
    "username": "Karianne",
    "email": "Julianne.OConner@kory.org",
    "address": {
      "street": "Hoeger Mall",
      "suite": "Apt. 692",
      "city": "South Elvis",
      "zipcode": "53919-4257",
      "geo": {
        "lat": "29.4572",
        "lng": "-164.2990"
      }
    },
    "phone": "493-170-9623 x156",
    "website": "kale.biz",
    "company": {
      "name": "Robel-Corkery",
      "catchPhrase": "Multi-tiered zero tolerance productivity",
      "bs": "transition cutting-edge web services"
    }
  },
  {
    "id": 5,
    "name": "Chelsey Dietrich",
    "username": "Kamren",
    "email": "Lucio_Hettinger@annie.ca",
    "address": {
      "street": "Skiles Walks",
      "suite": "Suite 351",
      "city": "Roscoeview",
      "zipcode": "33263",
      "geo": {
        "lat": "-31.8129",
        "lng": "62.5342"
      }
    },
    "phone": "(254)954-1289",
    "website": "demarco.info",
    "company": {
      "name": "Keebler LLC",
      "catchPhrase": "User-centric fault-tolerant solution",
      "bs": "revolutionize end-to-end systems"
    }
  },
  {
    "id": 6,
    "name": "Mrs. Dennis Schulist",
    "username": "Leopoldo_Corkery",
    "email": "Karley_Dach@jasper.info",
    "address": {
      "street": "Norberto Crossing",
      "suite": "Apt. 950",
      "city": "South Christy",
      "zipcode": "23505-1337",
      "geo": {
        "lat": "-71.4197",
        "lng": "71.7478"
      }
    },
    "phone": "1-477-935-8478 x6430",
    "website": "ola.org",
    "company": {
      "name": "Considine-Lockman",
      "catchPhrase": "Synchronised bottom-line interface",
      "bs": "e-enable innovative applications"
    }
  },
  {
    "id": 7,
    "name": "Kurtis Weissnat",
    "username": "Elwyn.Skiles",
    "email": "Telly.Hoeger@billy.biz",
    "address": {
      "street": "Rex Trail",
      "suite": "Suite 280",
      "city": "Howemouth",
      "zipcode": "58804-1099",
      "geo": {
        "lat": "24.8918",
        "lng": "21.8984"
      }
    },
    "phone": "210.067.6132",
    "website": "elvis.io",
    "company": {
      "name": "Johns Group",
      "catchPhrase": "Configurable multimedia task-force",
      "bs": "generate enterprise e-tailers"
    }
  },
  {
    "id": 8,
    "name": "Nicholas Runolfsdottir V",
    "username": "Maxime_Nienow",
    "email": "Sherwood@rosamond.me",
    "address": {
      "street": "Ellsworth Summit",
      "suite": "Suite 729",
      "city": "Aliyaview",
      "zipcode": "45169",
      "geo": {
        "lat": "-14.3990",
        "lng": "-120.7677"
      }
    },
    "phone": "586.493.6943 x140",
    "website": "jacynthe.com",
    "company": {
      "name": "Abernathy Group",
      "catchPhrase": "Implemented secondary concept",
      "bs": "e-enable extensible e-tailers"
    }
  },
  {
    "id": 9,
    "name": "Glenna Reichert",
    "username": "Delphine",
    "email": "Chaim_McDermott@dana.io",
    "address": {
      "street": "Dayna Park",
      "suite": "Suite 449",
      "city": "Bartholomebury",
      "zipcode": "76495-3109",
      "geo": {
        "lat": "24.6463",
        "lng": "-168.8889"
      }
    },
    "phone": "(775)976-6794 x41206",
    "website": "conrad.com",
    "company": {
      "name": "Yost and Sons",
      "catchPhrase": "Switchable contextually-based project",
      "bs": "aggregate real-time technologies"
    }
  },
  {
    "id": 10,
    "name": "Clementina DuBuque",
    "username": "Moriah.Stanton",
    "email": "Rey.Padberg@karina.biz",
    "address": {
      "street": "Kattie Turnpike",
      "suite": "Suite 198",
      "city": "Lebsackbury",
      "zipcode": "31428-2261",
      "geo": {
        "lat": "-38.2386",
        "lng": "57.2232"
      }
    },
    "phone": "024-648-3804",
    "website": "ambrose.net",
    "company": {
      "name": "Hoeger LLC",
      "catchPhrase": "Centralized empowering task-force",
      "bs": "target end-to-end models"
    }
  }
]

假设我们的预期是请求结果中有 id 为 5、15 的用户信息,我们以此编写测试用例:

import requests


def user():
    url = 'https://jsonplaceholder.typicode.com/users'

    response = requests.get(url)

    results = response.json()

    id_list = []
    for x in results:
        id_list.append(x["id"])

    return id_list


def test_user():
    id_list = user()
    assert 5 in id_list, "请求结果中没有id为5的用户信息"
    assert 15 in id_list, "请求结果中没有id为15的用户信息"

运行结果:

============================= test session starts =============================
collecting ... collected 1 item

test_mod.py::test_user FAILED                                            [100%]
test_mod.py:17 (test_user)
15 != [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

预期:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
实际:15
<点击以查看差异>

def test_user():
        id_list = user()
        assert 5 in id_list, "请求结果中没有id为5的用户信息"
>       assert 15 in id_list, "请求结果中没有id为15的用户信息"
E       AssertionError: 请求结果中没有id为15的用户信息
E       assert 15 in [1, 2, 3, 4, 5, 6, ...]

test_mod.py:21: AssertionError


============================== 1 failed in 0.74s ==============================

”assert 15 in id_list, “请求结果中没有id为15的用户信息"“断言失败,报错: ”E AssertionError: 请求结果中没有id为15的用户信息“,测试用例 test_user 未通过。

测试用例组

在 pytest 中,可以构建测试用例组来放置某个功能、模块的所有测试用例,这个测试用例组就是类(class):

# C:\PythonTest\Test\test_mod.py

class TestClassOne:
    def test_one(self):
        x = "this"
        assert "h" in x, "x 的字符中没有 h"

    def test_two(self):
        x = "check"
        y = ['11', 2]
        assert type(x) == type(y), "x 与 y 的类型不一致"


class TestClassTwo:
    value = 0

    def test_three(self):
        self.value = 1
        assert self.value == 1, "value 的值不是 1"

    def test_four(self):
        assert self.value == 1, "value 的值不是 1"

运行结果:

============================= test session starts =============================
collecting ... collected 4 items

test_mod.py::TestClassOne::test_one PASSED                                   [ 25%]
test_mod.py::TestClassOne::test_two FAILED                                   [ 50%]
test_mod.py:5 (TestClassOne.test_two)
<class 'str'> != <class 'list'>

预期:<class 'list'>
实际:<class 'str'>
<点击以查看差异>

self = <test.TestClassOne object at 0x000001A24A75D430>

    def test_two(self):
        x = "check"
        y = ['11', 2]
>       assert type(x) == type(y), "x 与 y 的类型不一致"
E       AssertionError: x 与 y 的类型不一致
E       assert <class 'str'> == <class 'list'>
E        +  where <class 'str'> = type('check')
E        +  and   <class 'list'> = type(['11', 2])

test_mod.py:9: AssertionError

test_mod.py::TestClassTwo::test_three PASSED                                 [ 75%]
test_mod.py::TestClassTwo::test_four FAILED                                  [100%]
test_mod.py:18 (TestClassTwo.test_four)
0 != 1

预期:1
实际:0
<点击以查看差异>

self = <test.TestClassTwo object at 0x000001A24A786300>

    def test_four(self):
>       assert self.value == 1, "value 的值不是 1"
E       AssertionError: value 的值不是 1
E       assert 0 == 1
E        +  where 0 = <test.TestClassTwo object at 0x000001A24A786300>.value

test_mod.py:20: AssertionError


========================= 2 failed, 2 passed in 0.04s =========================

测试报告中 test_mod.py::TestClassOne::test_one 、test_mod.py::TestClassTwo::test_three 表示测试用例属于哪个测试组。

在终端中运行 C:\PythonTest\Test\test_mod.py 中的 TestClassOne 测试用例组:

cd C:\PythonTest\Test\
pytest test_mod.py::TestClassOne

在PyCharm中运行 C:\PythonTest\Test\test_mod.py 中的 TestClassOne 测试用例组:

在终端中运行 C:\PythonTest\Test\test_mod.py 中 TestClassOne 中的 test_two 测试用例:

cd C:\PythonTest\Test\
pytest test_mod.py::TestClassOne::test_two

在PyCharm中运行 C:\PythonTest\Test\test_mod.py 中 TestClassOne 中的 test_two 测试用例:

assert 断言

在 pytest 中,assert 语句用于断言某个条件是否为真(即布尔值为 True)。如果断言的条件为 True,则测试通过;如果为 False,则测试失败,并且 pytest 会报告具体的失败信息。如:

  1. 比较表达式
# 等于
assert 10 == 5  # False

# 不等于
assert 10 != 5  # True

# 大于
assert 10 > 5   # True

# 小于
assert 10 < 5   # False

# 大于等于
assert 10 >= 5  # True

# 小于等于
assert 10 <= 5  # False
  1. 逻辑表达式
# 逻辑与
assert (10 > 5) and (5 < 10)  # True

# 逻辑或
assert (10 > 5) or (5 > 10)   # True

# 逻辑非
assert not (10 > 5)           # False
  1. 成员表达式
# in 操作符
assert 5 in [1, 2, 3, 4, 5]  # True

# not in 操作符
assert 6 not in [1, 2, 3, 4, 5]  # True
  1. 身份表达式
a = [1, 2, 3]
b = [1, 2, 3]
c = a

# is 操作符
assert a is c  # True,因为 a 和 c 引用同一个对象

# is not 操作符
assert a is not b  # True,因为 a 和 b 是不同的对象
  1. 布尔值的隐式转换
# 空列表、空字符串、零等会被视为 False,而非空列表、非空字符串、非零等会被视为 True

# 空列表
assert []  # False

# 非空列表
assert [1, 2, 3]  # True

# 空字符串
assert ""  # False

# 非空字符串
assert "Hello"  # True

# 零
assert 0  # False

# 非零
assert 10  # True
  1. 布尔值
# True
assert True

# False
assert False

测试套件的目录结构

从本篇开始,我们可以写一个测试项目框架。虽说一个项目怎么写都可以,但合理的项目结构可以大大提高开发效率,并使项目更易于管理和维护。基于本章内容,目录结构可以如下:

Project/
├── Package/                  # 程序目录
│   ├── __init__.py           # 包初始化文件,可以定义一些变量或执行一些操作。当然里面什么都不写也可以。
│   ├── module1.py            # 测试程序模块,比如连接数据库操作数据,接口请求等操作,推荐按功能封装成类
│   └── module2.py            # 测试程序模块,比如连接数据库操作数据,接口请求等操作,推荐按功能封装成类
├── Test/                     # 测试用例目录
│   ├── __init__.py           # 包初始化文件
│   ├── test_module1.py       # 测试 module1 的测试用例
│   └── test_module2.py       # 测试 module2 的测试用例
├── app.py                    # 项目启动文件
├── requirements.txt          # 项目依赖项列表
└── README.md                 # 项目说明文档

一个整体的套件,需要有个启动文件 app.py ,测试时运行 app.py 文件。 这个文件可以这样设计::

# app.py
import pytest
import sys
import logging
import argparse

# 设置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


def run_tests(test_target):

    # 运行测试用例
    pytest_args = ["-v", test_target]

    try:
        # 运行测试
        exit_code = pytest.main(pytest_args)

        # 根据退出码判断测试是否成功
        exit_messages = {
            0: "全部测试用例通过",
            1: "部分测试用例未通过",
            2: "测试过程中有中断或其他非正常终止。",
            3: "内部错误",
            4: "pytest无法找到任何测试用例",
            5: "pytest遇到了命令行解析错误"
        }
        logging.info(exit_messages.get(exit_code, "未知的退出码"))
    except Exception as e:
        logging.error(f"运行测试时发生错误: {e}")
        return 1

    return exit_code


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="使用指定的命令运行 pytest 测试。")
    parser.add_argument('test_target', nargs='?', type=str, default="Test/",
                        help='指定运行命令。 (默认: Test/)')

    args = parser.parse_args()
    exit_code = run_tests(args.test_target)
    sys.exit(exit_code)

argparse 可以处理命令行参数,parser.add_argument('test_target', nargs='?', type=str, default="Test/",help='指定运行命令。 (默认: Test/)') 定义一个位置参数:test_target ,允许为空,默认值为 Test/ 。当运行 app.py 无命令行参数时,会默认运行 Test/ 目录下的所有测试用例。

终端启动:

  • 运行所有测试:
cd C:\PythonTest\
python app.py
  • 运行特定文件:
cd C:\PythonTest\
python app.py Test/test_module1.py
  • 运行特定文件中的特定测试用例:
cd C:\PythonTest\
python app.py Test/test_module1.py::test_case
  • 运行特定文件中的特定测试组:
cd C:\PythonTest\
python app.py Test/test_module1.py::TestClass
  • 运行特定文件中的特定测试组中的特定测试用例:
cd C:\PythonTest\
python app.py Test/test_module1.py::TestClass::test_case

PyCharm启动:

运行其它测试用例或者测试组,只需要在步骤七输入相关参数,针对不同的测试策略,可以创建不同的运行配置。


THEEND



© 转载需要保留原始链接,未经明确许可,禁止商业使用。CC BY-NC-ND 4.0