使用DDT实现自动化测试数据驱动

什么是数据驱动?

数据驱动,指在自动化测试中处理测试数据的方式。

通常测试数据与功能函数分离,存储在功能函数的外部位置。在自动化测试运行时,数据驱动框架会读取数据源中的数据,把数据作为参数传递到功能函数中,并会根据数据的条数多次运行同一个功能函数。

数据驱动的数据源可以是函数外的数据集合、CSV 文件、Excel 表格、TXT 文件,以及数据库等。


数据驱动的好处有哪些?

1.数据驱动能够减少重复代码

下面我们通过一个例子来看下数据驱动是如何减少代码重复的。

# 伪代码,仅供演示

def book_order(user, product, num):

    # 你的函数逻辑

    pass


# 如果没有数据驱动,你的代码是这样的:

book_order('张三', '前端自动化测试框架Cypress从入门到精通', 1)

book_order('李四', '测试开发入门与实战', 1)

book_order('王五', '[测试开发入门与实战,前端自动化测试框架Cypress从入门到精通]', 50)

没有数据驱动时,并且同一个功能函数存在多个测试数据,你只能多次调用这个功能函数;另外一旦某一个测试数据有更改/删除,你需要在函数调用里去更改相应的测试数据,非常不方便。

但有了测试驱动时,你的代码可能是下面这个样子。

# data_book指向一个文件,这个文件里存储有你所有的测数据。

data_book = './tests/data/testdata.csv'

# dataDrivenDecorator是你实现数据驱动的装饰器

@dataDrivenDecorator(data_book)

def book_order(user, product, num):

    # 你的函数逻辑

    pass

这种情况下, 你无须进行多次调用,而且当你的测试数据发生改变时, 你仅需要更改数据源文件的数据就可以了。


2.数据所属的测试用例失败,不会影响到其他测试数据对应的测试用例

同样举一个例子,没有数据驱动之前,假设我们有这样的一个函数:

test_data = [0, 1, 0, 1]

def test_without_data_driven(records):

    for x in records:

        assert x > 0

test_without_data_driven(test_data)

当你运行这段代码时,因为 test_data 的第一个值是 0, 它不大于 0。所以断言失败,所有 test_data 这个函数 0 后面的测试数据都没有执行。

如果有了数据驱动,则数据驱动会把这一个测试按照测试数据分解成多个测试,所有第一个测试数据失败不也会影响到后面的测试结果。

了解了数据驱动的众多好处,我们来看下在 Python 中,应用比较广泛的两个数据驱动的框架。一个是 DDT(Data-Driven Tests),它是 unittest 框架中实现数据驱动的不二之选;另外一个是 parameterized,它是 pytest 能够实现数据驱动的秘诀。


DDT 含有哪些装饰器

1.一个类装饰器

ddt 这个类装饰器必须装饰在 TestCase 的子类上,TestCase 是 unittest 框架中的一个基类,它实现了 Test Runner 驱动测试运行所需的接口(interface)。


2.两个方法装饰器

分别是 data 和 file_data。其中 data 装饰器,直接提供测试数据;file_data 装饰器则从 JSON 或 YAML 文件加载测试数据。

DDT 的使用步骤如下:

1、使用 @ddt 装饰你的测试类;

2、使用 @data 或者 @file_data 装饰你需要数据驱动的测试方法;

3、如一组测试数据有多个参数,则需 unpack,使用 @unpack 装饰你的测试方法。


DDT 使用详解

先安装 DDT:

pip install ddt

然后我以 lagouAPITest 框架里,tests 文件夹下的 test_baidu.py 这个文件为例,来讲解下 ddt 的使用。


1.ddt 直接提供数据

# coding=utf-8

from ddt import ddt, data, file_data, unpack

from selenium import webdriver

import unittest

import time

@ddt        # ddt一定是装饰在TestCase的子类上

class Baidu(unittest.TestCase):

    def setUp(self):

        self.driver = webdriver.Chrome()

        self.driver.implicitly_wait(30)

        self.base_url = "http://www.baidu.com/"

    @data(['iTesting', 'iTesting'], ['helloqa.com', 'iTesting'])                # data表示data是直接提供的。

    @unpack                # unpack表示,对于每一组数据,如果它的值是list或者tuple,那么就分拆成独立的参数。

    def test_baidu_search(self, search_string, expect_string):

        driver = self.driver

        driver.get(self.base_url + "/")

        driver.find_element_by_id("kw").send_keys(search_string)

        driver.find_element_by_id("su").click()

        time.sleep(2)

        search_results = driver.find_element_by_xpath('//*[@id="1"]/h3/a').get_attribute('innerHTML')

        print(search_results)

        self.assertEqual(expect_string in search_results, True)

    def tearDown(self):

        self.driver.quit()


if __name__ == "__main__":

    unittest.main(verbosity=2)

在这个例子中,我直接使用了 @data 装饰器。在这个装饰器中,我给出了测试的 2 组数据,分别是 ['iTesting', 'iTesting'] 和 ['helloqa.com', 'iTesting'];然后我使用 @unpack 装饰器把每一组数据的数据 unpack 成一个个的参数传给我的函数 test_baidu_search。

直接运行这个文件,结果如下:

你注意下,虽然我们只有一个测试用例 test_baidu_search。但在生成的测试报告里,显示“Run 2 tests in 17.172s”,也就是 test_baidu_search 运行了 2 次,这就是 DDT 在起作用

这是多组参数,每组多个数据的情况,如果每组仅有一个数据呢?你仅需要更改如下:

# 如仅有一个参数,那么直接在data里写参数就好。

# 仅有一个参数的情况下,无须再用@unpack装饰测试方法。

@data('data1', 'data2')


2.ddt 使用函数提供数据

ddt 直接提供数据,除去上述的直接把数据写在 @data() 的参数中外,还有一个情况,即数据先从函数获取,然后再写入 @data() 的参数中。

# coding=utf-8

from ddt import ddt, data, file_data, unpack

from selenium import webdriver

import unittest

import time

def get_test_data():

    # 这里写你获取测试数据的业务逻辑。

    # 获取到后,把数据返回即可。

    # 注意,如果多组数据,需要返回类似([数据1-参数1, 数据1-参数2],[数据2-参数1, 数据2-参数2])这样的格式,方便ddt.data()解析

    results = ['iTesting', 'iTesting'], ['helloqa.com', 'iTesting']

    return results

@ddt

class Baidu(unittest.TestCase):

    def setUp(self):

        self.driver = webdriver.Chrome()

        self.driver.implicitly_wait(30)

        self.base_url = "http://www.baidu.com/"

    # data表示data是直接提供的。注意data里的参数我写了函数get_test_data()的返回值,并且以*为前缀,代表返回的是可变参数。

    @data(*get_test_data())

    @unpack

    def test_baidu_search(self, search_string, expect_string):

        driver = self.driver

        driver.get(self.base_url + "/")

        driver.find_element_by_id("kw").send_keys(search_string)

        driver.find_element_by_id("su").click()

        time.sleep(2)

        search_results = driver.find_element_by_xpath('//*[@id="1"]/h3/a').get_attribute('innerHTML')

        print(search_results)

        self.assertEqual(expect_string in search_results, True)

    def tearDown(self):

        self.driver.quit()

if __name__ == "__main__":

    unittest.main(verbosity=2)

在本例中,我创建了一个函数 get_test_data() 用于获取我的测试数据。这个函数可以带参数,也可以不带参数,具体需要根据你的业务逻辑来。

注意:get_test_data() 的返回值,一定需要遵守 ddt.data() 可接受的数据格式。即:

一组数据,每个数据为单个的值;多组数据,每组数据为一个列表或者一个字典


3.ddt 使用文件提供数据 — JSON 和 YAML

除了使用 @ddt 直接提供数据,DDT 还支持通过文件加载数据。

不过默认只支持两种格式 YAML 和 JSON,只有以“.yml” 或者“.yaml” 结尾的会被认作 YAML 文件,其他格式都将被认为是 JSON 文件。

使用 JSON 文件

如果把上述用例改成使用 JSON 文件,则我们的用例看起来是这样的:

|--lagouAPITest

    |-- .....

    |--tests

        |--test_baidu.py

        |--test_baidu.json

        |--__init__.py

首先,我们创建一个跟 test_baidu.py 同名的文件 test_baidu.json,内容如下:

{ "case1": {

  "search_string": "itesting",

  "expect_string": "iTesting"

  },

  "case2": {

  "search_string": "itesting",

  "expect_string": "iTesting"

  }

}

然后更新 test_baidu.py,更新后的代码如下所示:

# -*- coding: utf-8 -*-

from ddt import ddt, data, file_data, unpack

from selenium import webdriver

import unittest

import time

@ddt

class Baidu(unittest.TestCase):

    def setUp(self):

        self.driver = webdriver.Chrome()

        self.driver.implicitly_wait(30)

        self.base_url = "http://www.baidu.com/"

    # 此处测试数据从文件读取,使用@file_data装饰器

    # 文件路径是相对于Baidu这个测试类的相对路径

    # 使用外部文件方式Load数据无须使用unpack

    @file_data('test_baidu.json')

    def test_baidu_search(self, search_string, expect_string):

        driver = self.driver

        driver.get(self.base_url + "/")

        driver.find_element_by_id("kw").send_keys(search_string)

        driver.find_element_by_id("su").click()

        time.sleep(2)

        search_results = driver.find_element_by_xpath('//*[@id="1"]/h3/a').get_attribute('innerHTML')

        print(search_results)

        self.assertEqual(expect_string in search_results, True)

    def tearDown(self):

        self.driver.quit()

if __name__ == "__main__":

    unittest.main(verbosity=2)

可以看到,使用 @file_data 这个装饰器,与使用 @data 的装饰器有一点不同:

(1)@file_data 这个装饰器里,文件的路径是相对于这个测试类本身来说的。在本例中为 Baidu 这个测试类所处的文件的相对位置;

(2)使用 @file_data 无须使用 unpack,即使同一组数据的参数有多个。

使用 YAML 文件:

如果想在 python 中使用 yaml 文件,则需要安装 PyYAML。

pip install pyyaml

安装好后,我们在test_baidu.json的同级目录下,创建一个文件test_baidu.yaml,内容如下:

"case1":

  "search_string": "itesting"

  "expect_string": "iTesting"


"case2":

  "search_string": "itesting"

  "expect_string": "iTesting"

然后,我们更改 test_baidu.py,更改后的内容如下:

# -*- coding: utf-8 -*-

from ddt import ddt, data, file_data, unpack

from selenium import webdriver

import unittest

import time

# 使用yaml文件前先尝试导入,导入失败则将skip使用yaml数据驱动的测试用例

try:

    import yaml

except ImportError:

    have_yaml_support = False

else:

    have_yaml_support = True

needs_yaml = unittest.skipUnless(

    have_yaml_support, "Need YAML to run this test"

)

@ddt

class Baidu(unittest.TestCase):

    def setUp(self):

        self.driver = webdriver.Chrome()

        self.driver.implicitly_wait(30)

        self.base_url = "http://www.baidu.com/"

    # 使用yaml文件必须使用@needs_yaml装饰

    @needs_yaml

    @file_data('test_baidu.yaml')

    def test_baidu_search(self, search_string, expect_string):

        driver = self.driver

        driver.get(self.base_url + "/")

        driver.find_element_by_id("kw").send_keys(search_string)

        driver.find_element_by_id("su").click()

        time.sleep(2)

        search_results = driver.find_element_by_xpath('//*[@id="1"]/h3/a').get_attribute('innerHTML')

        print(search_results)

        self.assertEqual(expect_string in search_results, True)

    def tearDown(self):

        self.driver.quit()

if __name__ == "__main__":

    unittest.main(verbosity=2)

你可以看到,与使用 JSON 文件不同, 使用 YAML 文件必须要先安装 PyYaml。然后为了防止 yaml 导入失败,我定义了 needs_yaml 这个装饰器,用来给我的程序加个安全判断。如果导入失败,则所有以 needs_yaml 装饰的测试用例将不会执行。


4.ddt 使用文件提供数据 — 其他格式数据文件

因为 ddt 默认只支持 JSON 和 YAML 格式的数据。但是我想使用其他数据格式怎么办?

常用的方式有如下两种:

先读取其他格式的文件(例如 Excel 格式),然后创建 ddt 支持的 JSON 或者 YAML 文件,最后把获取到的数据写入这个文件,再使用 @file_data() 即可;

创建一个函数,在函数中读取其他格式的文件并获取数据,将数据直接返回为 @ddt.data() 支持的格式调用即可。


DDT 的原理解析

了解了 ddt 的使用,不知你有没有想过如下问题:

1、ddt 是如何把你的测试数据转给你的测试用例的?

2、当你的一组数据有多个参数时,ddt 是如何 unpack 的?

3、当你有多组数据时,ddt 拆分测试用例是如何命名的?

下面我们就来一一揭晓 ddt 实现数据驱动的秘密。


其实 ddt 的实现核心就是**@ddt(cls)这个装饰器,而这个装饰器的核心代码是 wrapper**这个内函数,下面我直接把 wrapper 的源码贴上来,我们一起看看:

def wrapper(cls):

    # 先遍历被装饰类的name, 和func

    # 对于func,先看被装饰的是DATA_ATTR还是FILE_ATTR

    for name, func in list(cls.__dict__.items()):

        # 如果被装饰的是DATA_ATTR

        if hasattr(func, DATA_ATTR):

            #获取@data提供数据的index和内容并且遍历它们

            for i, v in enumerate(getattr(func, DATA_ATTR)):

                # 重新生成新的测试函数名,这个函数名会展示在测试报告中

                test_name = mk_test_name(

                    name,

                    getattr(v, "__name__", v),

                    i,

                    fmt_test_name

                )

                test_data_docstring = _get_test_data_docstring(func, v)

                # 如果类函数被@unpack装饰

                if hasattr(func, UNPACK_ATTR):

                    # 如果提供的数据是tuple或者list

                    if isinstance(v, tuple) or isinstance(v, list):

                        # 则添加一个case到测试类中

                        # list或tuple传不定数目的值, 用*v即可。

                        add_test(

                            cls,

                            test_name,

                            test_data_docstring,

                            func,

                            *v

                        )

                    else:

                        # unpack dictionary

                        # 添加一个case到测试类中

                        # dict中传不定数目的值,用**v

                        add_test(

                            cls,

                            test_name,

                            test_data_docstring,

                            func,

                            **v

                        )

                else:

                    # 如不需要unpack,则直接添加一个case到测试类

                    add_test(cls, test_name, test_data_docstring, func, v)

            # 删除原来的测试类

            delattr(cls, name)

        # 如果被装饰的是file_data

        elif hasattr(func, FILE_ATTR):

            # 获取file的名称

            file_attr = getattr(func, FILE_ATTR)

            # 根据process_file_data解析这个文件

            # 在解析的最后,会调用mk_test_name生成多个测试用例

            process_file_data(cls, name, func, file_attr)

            # 测试用例生成后,会删除原来的测试用例

            delattr(cls, name)

    return cls


来分析下这段代码, 对于每一个被 @ddt 装饰的测试类,ddt 首先去遍历测试类的自有属性,从而得出这个测试类有哪些测试方法,这部分主要靠这条语句:

# wrapper源码第4行

for name, func in list(cls.__dict__.items()):

然后,ddt 去判断所有的 func(即类函数)里,有没有装饰器 @data 或者 @file_data,主要靠这两条语句:

# 被@data装饰, wrapper源码第6行

if hasattr(func, DATA_ATTR):

# 被file_data 装饰,wrapper源码第47行

elif hasattr(func, FILE_ATTR):

接着程序会进入两条分支:被 @data 装饰,即由 ddt 直接提供数据;被 @file_data 装饰,即数据由外部文件提供。


1.被 @data 装饰,即由 ddt 直接提供数据

如果数据是直接通过 @data 提供的,那么为每一组数据新生成一个测试用例名称。

# 在本例中, i, v的第一次循环,值为

# i:0 v:['iTesting', 'iTesting']

# wrapper源码第8行

for i, v in enumerate(getattr(func, DATA_ATTR)):

    test_name = mk_test_name(

        name,

        getattr(v, "__name__", v),

        i,

        fmt_test_name

    )

test_name 生成使用的是函数 mk_test_name。

注意:ddt 在此时实现了把你的测试数据转给你的测试用例。 其实不是通过传递,而是通过把测试数据拆分,并且生成新测试用例的方式来达成的。

而在函数 mk_test_name 里,ddt 更是把原来的测试函数通过特定的规则,拆分成不同的测试函数。

test_name = mk_test_name(name,getattr(v, "__name__", v),i,fmt_test_name)

mk_test_name 的参数里:

1、name 是原测试函数的名字

2、v 是、我们的一组测试数据

3、i 是这组数据的 index

4、fmt_test_name 指定新的 test 函数的名字的格式,这个格式是按照原来测试函数名 index 第一个测试数据_第二个测试数据这样的格式。

例如,我们的测试数据 ['iTesting','iTesting'] 会被转换成test_baidu_search_1_['iTesting', 'iTesting']',但是由于符号 '[' 和 '' 以及 ',' 是不合法的字符,故会被 '_' 替换,故最终新生成的测试用例名是test_baidu_search_1___iTesting____iTesting__ 这块的逻辑在函数 mk_test_name 的最后两行:

# ddt内容函数mk_test_name,test_name处理逻辑如下

test_name = "{0}_{1}_{2}".format(name, index, value)

return re.sub(r'\W|^(?=\d)', '_', test_name)

紧接着,ddt 又去查找你的测试类函数,看它有没有被 @unpack 装饰。如果有,就意味着我们的测试类函数有多个参数,这个时候就需要把我们的测试数据 unpack,这样我们的测试类函数的各个参数才能接收到传入的值。

这样,ddt 把上一步生成的 test_name 和刚刚 unpack 的值(数据是 list、tuple,还是 dictionary,决定了 unpack 采用 *v 还是 **v),通过 add_test 来新生成一个测试用例,注册到我们的测试类下面,所有这些动作是在下面这段代码里完成的。

# wrapper源码里的18行到43行

if hasattr(func, UNPACK_ATTR):

    if isinstance(v, tuple) or isinstance(v, list):

        add_test(

            cls,

            test_name,

            test_data_docstring,

            func,

            *v

        )

    else:

        # unpack dictionary

        add_test(

            cls,

            test_name,

            test_data_docstring,

            func,

            **v

        )

else:

    add_test(cls, test_name, test_data_docstring, func, v)

注意:

这个时候测试类中是多了测试函数的,多了多少个,要取决于 ddt 提供的测试数据的组数,有几组就生成几个测试用例,并且都注册到原测试类中去;

unpack 其实就是为了把一个测试用例的多个测试数据全部传入新生成的测试函数中去,这些测试数据和测试函数的参数一一对应。

最后,ddt 会把最初的那个原始测试类方法给删除(因为原测试函数已经根据各组数据变成了新的测试函数)。

# wrapper源码45行

delattr(cls, name)

通过这样的方式,ddt 根据测试数据的组数,通过函数 mk_test_name 生成多组测试用例,并通过 add_test 函数注册到 unittest的TestSuite 里去。


2.被 @file_data 装饰,即数据由外部文件提供

如果测试函数被 @file_data 装饰,ddt 则会先获取 file_data 里的数据文件名称,然后通过函数 process_file_data 里进行下一步处理。

# wrapper源码的第49到52行

file_attr = getattr(func, FILE_ATTR)

process_file_data(cls, name, func, file_attr)

看起来只有短短的两行,其实 ddt 在函数 process_file_data 内部做了很多操作。

首先 ddt 会先拿到我们提供的数据文件的绝对地址,并通过后缀名判断它是 yaml 文件还是 json 文件,然后分别调用 yaml 或者 json 的 load 方法拿到文件里提供的数据。

拿到数据后,最终也是通过 mk_test_name 函数和 add_test 函数,生成多条测试用例,并且注册到 unittest 的 TestSuite 里去。

最后一样是删除原来的测试函数:

# wrapper源码54行

delattr(cls, name)

这就是 ddt 的整个实现逻辑了。


总结

    今天我们了解了 unittest 里数据驱动 DDT 的安装、使用,以及实现原理。通过对其源代码的解析,我们掌握DDT 是如何实现按照数据组数生成测试用例、更新测试方法名,以及根据数据类型 unpack 测试数据的。

    DDT 的源代码非常经典,代码行数又不多,值得我们深读。仔细琢磨并研究透 DDT 的源码,有助于你的测试开发技术突飞猛进。

    我希望你能用单步调试的方式,结合本节课所讲,边执行测试代码边走读 DDT 代码,这样有助于你加深理解。

在此留一个课后作业:

在本课时“ddt 使用文件提供数据——其他格式数据文件”这一小节中,我提及了使用其他数据格式进行数据驱动的方法,但是没有给出代码示例。

希望你结合本节所讲内容,以 Excel 格式的数据为例, 将 Excel 中的数据作为数据源提供给 DDT 使用

Tips:读写 Excel 可以使用相关的 Library,例如“读”可以选择 xlrd、“写”可以选择 xlwt

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容