Python测试驱动开发(TDD)

Python测试驱动开发(TDD)

前言:TDD是一种敏捷开发模式,而不是测试方法
测试很难 ——- 难在坚持,一直做下去。
现在花时间编写的测试不会立即显出功效,要等到很久以后才有作用 ——- 或许几个月之后避免在重构过程中引入问题,或者升级依赖时捕获回归异常。或许测试会从一种很难衡量的方式回报你,促使你写出设计更好的代码,但你却误以为没有测试也能写出如此优雅的代码。

项目github地址

https://github.com/Tyrone-Zhao/Test-Driven-Development

目录

单元测试的好处

编程就像从井里打水

编程其实很难,我们的成功往往得益于自己的聪明才智。假如我们不那么聪明,TDD就能助我们一臂之力。Kent Beck(TDD理念基本就是他发明的)打了个比方。试想你用绳子从井里提一桶水,如果井不太深,而且桶不是很满,提起来很容易。就算提满满一桶水,刚开始也很容易。但要不了多久你就累了。TDD理念好比是一个棘轮,你可以使用它保存当前的进度,休息一会儿,而且能保证进度绝不倒退。这样你就没必要一直那么聪明了。Test All The Things!

细化测试每个函数的好处

程序变复杂后问题就来了,到时你就知道测试的重要性了。你要面临的危险是,复杂性逐渐靠近,而你可能没发觉,但不久之后你就会变成温水煮青蛙。
首先,写测试很简单,写起来不会花很长时间,所以,别抱怨,只管写就是了。
其次,占位测试很重要。先为简单的函数写好测试,当函数变复杂后,这道心理障碍就容易迈过去。你可能会在函数中添加一个if语句,几周后再添加一个for循环,不知不觉间就将其变成一个基于元类(meta-class)的多态树结构解析器了。因为从一开始你就编写了测试,每次修改都会自然而然地添加新测试,最终得到的是一个测试良好的函数。相反,如果你试图判断函数什么时候才复杂到需要编写测试的话,那就太主观了,而且情况会变得更糟,因为没有占位测试,此时开始编写测试需要投入很多精力,每次改动代码都冒着风险,你开始拖延,很快青蛙就煮熟了。

单元测试与功能测试的区别

单元测试和功能测试之间的界线有时不那么清晰。不过二者之间有个基本区别:功能测试站在用户角度从外部测试应用,单元测试则站在程序员的角度从内部测试应用。
采用的工作流程大致如下:
1)先写功能测试,从用户的角度描述应用的新功能
2)功能测试失败后,想办法编写代码让它通过(或者说至少让当前失败的测试通过)。此时,使用一个或多个单元测试定义希望代码实现的效果,保证为应用中的每一行代码(至少)编写一个单元测试
3)单元测试失败后,编写最少量的应用代码,刚好让单元测试通过。有时,要在第2步和第3步之间多次往复,直到我们觉得功能测试有一点进展为止。
4)然后,再次运行功能测试,看能否通过,或者有没有进展。这一步可能促使我们编写一些新的单元测试和代码等。

由此可以看出,在整个过程中,功能测试站在高层驱动开发,而单元测试从低层驱动我们做些什么。


功能测试代码, 测试主要功能(冒烟测试),数据有效性验证。详细代码可参见上面的github地址中的functional_test/

TDD的重要思想是必要时一次只做一件事。即每次只做必要的操作,让功能测试向前迈出一小步即可。


单元测试代码,视图逻辑测试,数据模型测试,模版表单测试。
Mock,参数校验:

from unittest.mock import patch, call
[...]
    @patch("accounts.views.auth")
    def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):
        self.client.get("/accounts/login?token=abcd123")
        self.assertEqual(
            mock_auth.authenticate.call_args,
            call(uid="abcd123")
        )
  • 这里的patch mock了accounts.views.auth模块,而且把mock的范围限定在下面的测试函数范围内,测试函数执行完毕,mock即被取消
  • mock掉的auth模块中的所有属性和方法也同样是mock对象,通过在参数重传递mock对象为mock_auth来实现mock对象的调用
  • self.client.get()方法为django内置的模拟客户端,可以模拟客户端发送请求,示例中请求了”/accounts/login?token=abcd123”的url
  • 对应请求url的回调函数为auth模块中的authenticate方法,用mock对象的call_args属性获取其请求的参数,返回一个call(*args, **args)对象
  • 下面用mock模块中导入的call方法构建预期结果,使用TestCase类的assertEqual()方法断言结果,至此一个单元测试函数完毕
单元测试与集成测试的区别以及数据库

追求纯粹的人会告诉你,真正的单元测试绝不能设计数据库操作。不仅测试代码,而且还依赖于外部系统,如数据库的测试叫做集成测试更确切。


“单元测试/编写代码“循环

TDD中的单元测试/编写代码循环
1)在终端里运行单元测试,看它们是如何失败的。
2)在编辑器中改动最少量的代码,让当前失败的测试通过
然后不断重复。
想保证编写的代码无误,每次改动的幅度就要尽量小。这么做才能确保每一部分代码都有对应的测试监护。
乍一看工作量很大,初期也的确如此。但熟练之后你便会发现,即使步伐迈得很小,编程的速度也很快。

良好的单元测试实践方法要求,一个测试只能测试一个功能,多个功能需要写成多个测试。因为如果一个测试中有多个断言,一旦前面的断言导致测试失败,就无法得知后面的断言情况如何。

遵守不测试常量规则

单元测试要测试的其实是逻辑、流程控制和配置。编写断言检测HTML字符串中是否有指定的字符序列,不是单元测试应该做的。

重构的首要原则是不能没有测试,严格的TDD流程中,可以遵循以下顺序:
功能测试 -> 单元测试 -> 单元测试/编写代码循环 -> 重构代码

有用的TDD概念

  • 回归
    新添加的代码破坏了应用原本可以正常使用的功能。
  • 意外失败
    测试在意料之外失败了。这意味着测试中有错误,或者测试帮我们发现了一个回归,因此要在代码中修正。
  • 遇红/变绿/重构
    描述TDD流程的另一种方式。先编写一个测试看着它失败(遇红),然后编写代码让测试通过(变绿),最后重构,改进实现方式。
  • 三角法
    添加一个测试,专门为某些现有的代码编写用例,以此推断出普适的实现方式(在此之前的实现方式可能作弊了)。
  • 事不过三,三则重构
    判断何时删除重复代码使用的经验法则。如果两段代码很相似,往往还要等到第三段相似代码出现,才能确定重构时哪一部分是真正共通、可重用的。
  • 记在便签上的待办事项清单
    在便签上记录编写代码过程中遇到的问题,等手头的工作完成后再回过头来解决。

三种功能测试调试技术:行间print语句、time.sleep以及改进的错误消息:
如assert[Equal|In|True|其他](something, “错误消息”)

不要预先做大量的设计

TDD和软件开发中的敏捷运动联系紧密。敏捷运动反对传统软件工程实践中预先做大量设计的做法。敏捷理念认为,在实践中解决问题比理论分析能学到更多,要尽早把最简可用应用放出来,根据实际使用中得到的反馈逐步向前推进设计。当然,稍微思考一下设计往往能帮我们更快地找到答案。

使用递增的步进式方法修改现有代码,而且保证代码在修改前后都能正常运行。

YAGNI
关于设计的思考一旦开始就很难停下来,我们会冒出各种想法:或许想给每个清单起个名字或加个标题,或许想使用用户名和密码识别用户,或许想给产品页面添加一个较长的备注和简短的描述,或许想存储某种顺序,等等。但是,要遵守敏捷理念的另一个信条:”YAGNI”(读作yag-knee)。它是”You ain’t gonna need it”(你不需要这个)的简称。
有时我们冒出一个想法,觉得可能需要,可问题是,不管想法有多好,大多数情况下最终你都用不到这个功能。

REST(式)
“表现层状态转化”(representational state transfer, REST)是Web设计的一种方式。设计面向用户的网站时,不必严格遵守REST规则,可是从中能得到一些启发。想看看REST API是什么样子,可以查看我的另一篇博文。

确保出现回归测试

独立用户场景下的功能测试通过后,要注意多用户场景下的功能测试回归。
重构代码,或者开发新功能时修改了旧有代码,要注意单元测试和功能测试的回归。

如何测试设计和布局

简单来说,不应该为设计和布局编写测试。因为这太像是测试常量,所以写出的测试不太牢靠
这说明设计和布局的实现过程极具技巧性、涉及CSS和静态文件。因此,可以编写一些简单的“冒烟测试“,确认静态文件和CSS起作用即可。把代码部署到生产环境时,冒烟测试能协助我们发现问题。
但是如果某部分样式需要很多客户端JavaScript代码才能使用(如动态缩放),就必须为此编写一些测试。
要试着编写最简的测试,确信设计和布局起作用即可,不必测试具体的实现。我们的目标是能自由修改设计和布局,且无须时不时地调整测试。

功能测试代码, 布局和样式测试:

from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest


class LayoutAndStylingTest(FunctionalTest):

    def test_layout_and_styling(self):
        # 小明访问首页
        self.browser.get(self.live_server_url)
        self.browser.set_window_size(1024, 768)

        # 他看到输入框完美地居中显示
        inputbox = self.get_item_input_box()
        self.assertAlmostEqual(
            inputbox.location["x"] + inputbox.size["width"] / 2,
            512,
            delta=10
        )

        # 他新建了一个清单,看到输入框仍完美地居中显示
        inputbox.send_keys("测试")
        inputbox.send_keys(Keys.ENTER)
        self.wait_for_row_in_list_table("1: 测试")
        inputbox = self.get_item_input_box()
        self.assertAlmostEqual(
            inputbox.location["x"] + inputbox.size["width"] / 2,
            512,
            delta=10
        )

TDD以及部署的危险区域

部署的过程中一些危险区域如下:
1)静态文件(CSS、JavaScript、图片等)
Web服务器往往需要特殊的配置才能伺服静态文件
2)数据库
可能会遇到权限和路径问题,还要小心处理,在多次部署之间不能丢失数据
3)依赖
要保证服务器上安装了网站依赖的包,而且版本要正确

不过这些问题有相应的解决方案:
1)使用与生产环境一样的基础架构部署过渡网站(staging site),这么做可以测试部署的过程,确保部署真正的网站时操作正确。
2)可以在过渡网站中运行功能测试,确保服务器中安装了正确的代码和依赖包。而且为了测试网站的布局,我们编写了冒烟测试,这样就能知道是否正确加载了CSS。
3)与在本地设备上一样,当服务器上运行多个Python应用时,可以使用虚拟环境管理包和依赖。
4)最后,一切操作都自动化完成。使用自动化脚本部署新版本,使用同一个脚本把网站部署到过渡环境和生产环境,这么做能尽量保证过渡网站和线上网站一样。

调试技巧
查看Nginx的错误日志,存储在/var/log/nginx/error.log中。
检查Nginx的配置:nginx -t
确保浏览器没有缓存过期的响应。按下Ctrl键的同时点击刷新按钮。

别忘了重构

TDD不是万能灵药。它要求你在测试通过后花点实践重构,改进设计。否则“技术债务“将高高筑起。
不过,重构的最佳方法往往不那么容易想到,可能等到写下代码之后的几天、几周甚至几个月,处理完全无关的事情时,突然灵光一闪才能想出来。

在解决其他问题的途中,应该停下来去重构以前的代码吗?
要视情况而定。不能冒险在无法正常运行的应用中重构,可以在便签上做个记录,等测试组件能全部通过之后再重构。

关于重构的小贴士
1)把测试放在单独的文件夹中
功能测试可以按照特定功能或用户故事的方式组织。
单元测试分拆成文件,放在一个Python包中。
2)编写测试的主要目的是让你重构代码!一定要重构,尽量让代码(包括测试)变得简洁。
3)测试失败时别重构
如果测试的对象还没实现,可以先为测试方法加上@skip装饰器。
记下想重构的地方,完成手头上的活儿,等应用处于正常状态时再重构。
提交代码之前别忘了删掉所有@skip装饰器!

尽早合并代码

精益理论中的“尽早部署“有个推论,即“尽早合并代码“。编写表单可能要花很多时间,不断添加各种功能—做了各种工作,得到一个功能完善的表单类,但发布应用后才发现大多数功能实际并不需要。
因此,要尽早试用新编写的代码。

要判断何时应该编写测试确认我们没有犯错

测试时要判断何时应该编写测试确认我们没有犯错。一般而言,做决定时要谨慎。
不可能编写测试检查所有可能出错的方式。如果有一个函数计算两数之和,可以编写一些测试:

    assert adder(1, 1) == 2
    assert adder(2, 1) == 3

但不应该认为实现这个函数时故意编写了有违常理的代码:

    def adder(a, b):
        # 不可能这么写
        if a == 3:
            return 666
        else:
            return a + b

判断时你要相信自己不会故意犯错,只会不小心犯错。

单元测试需要一个断言框架和报告程序,或许还要选择一个技术模拟库

在JavaScript领域,测试工具的选择有许多种,如jsUnit、Qunit、Mocha、Chutzpah、Karma、Testacular、Jasmine等。选择其中一个工具后,还得选择一个断言框架报告程序,或许还要选择一个驭件(spy侦件、fake伪件、stub桩件)技术库。

示例项目中使用的是QUnit,简单,根Python单元测试很像,而且能很好地和jQuery配合使用。
代码可以参考上面的github地址中list/static/tests/test.html
Qunit

JavaScript测试在TDD循环中的位置

JavaScript测试在双重TDD循环中处于什么位置?答案是,JavaScript测试和Python单元测试扮演的角色完全相同。
1)编写一个功能测试,看着它失败
2)判断接下来需要哪种代码,Python还是JavaScript?
3)使用选中的语言编写单元测试,看着它失败。
4)使用选中的语言编写一些代码,让测试通过。
5)重复上述步骤

一些缺憾

1)编写JavaScript时,应该尽量利用编辑器提供的协助,避免常见的问题。试一下句法/错误检查工具,如jsLinter、jshint。
2)使用Phantomjs可以让JavaScript测试在命令行中运行。
Phantomjs
3)前端开发圈目前流行angular.js和React这样的MVC框架。这些框架的教程大都使用一个RSpec式断言库,名为Jasmine。如果你想使用MVC框架,使用Jasmine比Qunit更方便。

探索性编程、探究及去掉探究代码

学习新工具,或者研究新的可行性方案时,一般都可以适当地把严格的TDD流程放在一边,不编写测试或编写少量的测试,先把基本的原型开发出来。
这种创建原型的过程一般叫作“探究“(spike)。

最好在一个新分支中去探究,去掉探究代码时再回到主分支。

把探究所得应用到真实的代码基中。要完全摒弃探究代码,然后从头开始,用TDD流程再实现一次。去掉探究代码后实际编写的代码往往与最初有很大不同,而且通常更好。
该不该这么做请视情况而定!

在Python中使用模拟技术

模拟技术,是在单元测试中测试外部依赖的通用方式。只要与第三方API交互都适合使用驭件测试。
代码有外部副作用时也是如此,例如调用API、发推文、发短信等等。我们并不想真的通过互联网发推文或者调用API。但又必须找到一种方法,测试代码是否正确。驭件(mock)正是我们寻找的答案。

from unittest.mock import patch


@patch("accounts.views.auth")
class LoginViewTest(TestCase):
    ''' 登录视图测试 '''

    [...]

    @patch("accounts.views.send_mail")
    def test_sends_link_to_login_using_token_uid(self, mock_send_mail,
                                                 mock_auth):
        ''' 测试含有token的登录链接被发送到指定邮件地址 '''
        self.client.post("/accounts/send_login_email", data={
            "email": "200612453@qq.com"
        })

        token = Token.objects.first()
        expected_url = f"http://testserver/accounts/login?token={token.uid}"
        (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
        self.assertIn(expected_url, body)

使用驭件可能导致“与实现紧密耦合“。我们知道,通常最好测试行为,而不测试实现细节;测试发生了什么,而不测试是如何发生的。驭件往往在如何做这条路上走的太远,而很少关注“是什么“。
如果能有效减少测试之间的重复,就有充分的理由使用驭件。这是避免组合爆炸的一种方式。

把ORM代码放到辅助方法中

有些人喜欢尽量减少应用中使用的ORM代码量,尤其不喜欢在视图层和表单层使用ORM代码。
一个原因是,测试这几层时更容易;另一个原因是,必须定义辅助方法,这样能更清晰地表述域逻辑。请对比这段代码:

list_ = List()
list_.save()
item = Item()
item.list = list_
item.text = self.cleaned_data["text"]
item.save()

和这段代码:

List.create_new(first_item_text=self.cleaned_data["text"])

辅助方法同样可用于读写查询。假设有这样一行代码:

Book.objects.filter(in_print=True, pub_date__lte=datetime.today())

和如下的辅助方法相比,孰好孰坏一目了然:

Book.all_available_books()

定义辅助方法时,可以起个适当的名字,表明它们在业务逻辑中的作用。使用辅助方法不仅可以让代码的条理变得更清晰,还能把所有ORM调用都放在模型层,因此整个应用不同部分之间的耦合更松散

以复杂度为准则

处理复杂问题时才能体现隔离测试(真正的单元测试)的优势。
一旦应用变得复杂,比如视图和模型之间分了更多层,需要编写辅助方法或自己的类,那多编写一些隔离测试就能从中受益了。

不同测试类型以及解耦ORM代码的利弊

1)功能测试
从用户的角度出发,最大程度上保证应用可以正常运行。
但反馈循环用时长。
无法帮助我们写出简介的代码。
2)整合测试(依赖于ORM或框架(如Django、Flask)测试客户端等)
编写速度快。
易于理解。
发现任何集成问题都会提醒你。
但是,并不总能得到好的设计(这取决于你自己)。
一般运行速度比隔离测试慢。
3)隔离测试(使用驭件)
涉及的工作量最大。
可能难以阅读和理解。
但是,这种测试最能引导你实现更好的设计。
运行速度最快。
4)解耦应用代码和ORM代码
力求隔离测试的后果之一是,我们不得不从视图和表单等处删除ORM代码,把它们放到辅助函数或者辅助方法中。如果从解耦应用代码和ORM代码的角度看,这么做有好处,还能提高代码的可读性。
当然,要结合实际情况判断是否值得付出额外精力去做。

CI和Selenium最佳实践

把Jenkins安装在过渡服务器或生产服务器上可不是个好主意,因为有很多操作要交给Jenkins完成,比如重新引导过渡服务器。
为了提升Jenkins的安全性,还要设置HTTPS。可以让Nginx使用自签名的证书,把发给443端口的请求转发给8080端口。这样设置之后,甚至可以让防火墙阻断8080端口。

设置虚拟显示器,在无界面的环境中运行功能测试

1)无界面浏览器(headless browser),如PhantomJS或SlimerJS。
2)设置虚拟显示器,使用”Xvfb”,MAC需要自己下载XQuartz,Jenkins插件中配置/opt/X11/bin/
3)Jenkins中启用虚拟显示器勾选”Start Xvfb before the build, and shut it down after.”
4)自己在功能测试脚本中添加失败截图方法,方便调试(因为没有没显示器)。
5)用Fabric或Ansible等工具将通过单元测试的构建自动部署到过渡服务器并进行功能测试。
6)部署到生产服务器
Jenkins

在不同的测试类型之间正确权衡

1)隔离测试(纯粹的单元测试)与整合测试
单元测试的主要作用应该是验证应用的逻辑是否正确。隔离测试只能测试一部分代码,测试是否通过与其他任何外部代码都没有关系。纯粹的单元测试是指,对于一个函数的测试而言,只有这个函数能让测试失败。如果这个函数依赖于其他系统且破坏这个系统会导致测试失败,就说明这是整合测试。这个系统可以是外部系统,例如数据库,也可以是我们无法控制的另一个函数。不管怎样,只要破坏系统会导致测试失败,这个测试就没有完全隔离,因此也就不是纯粹的单元测试。整合测试并非不好,只不过可能意味着同时测试多个功能。
2)集成测试
集成测试用于检查被你控制的代码是否能和你无法控制的外部系统完好集成。集成测试往往也是整合测试。
3)系统测试
如果说集成测试检查的是与外部系统的集成情况,那么系统测试就是检查应用内部多个系统之间的集成情况。例如,检查数据库、静态文件和服务器配置在一起是否能正常运行。
4)功能测试和验收测试
验收测试的作用是从用户的角度检查系统是否能正常运行。(用户能接受这种行为吗?)验收测试很难不写成全栈端到端测试。前文中,使用端到端测功能测试代替验收测试和系统测试。

要熟练的编写单元测试,需要进入”神赐的心流状态(形容极度专注)”。XD。
如果应用的核心使用函数式编程范式编写(完全没有副作用),因此可以使用完全隔离、纯粹的单元测试,根本无需使用驭件
1> 务实为本
跟着感觉走,先编写下意识觉得应该编写的测试,然后再根据需要修改。在实践中学习。
2> 关注想从测试中得到什么
目标是正确性、好的设计和快速的反馈循环
3> 架构很重要
架构在某种程度上决定了所需的测试类型。业务逻辑与外部依赖隔离得越好,代码的模块化成都越高,在单元测试、集成测试和端到端测试之间便能达到越好的平衡。

使用Sinon.js测试客户端Ajax

没有模拟库是无法测试Ajax的。不同的测试框架和工具采用不同的模拟库,而Sinon是通用的。Sinon还提供了JavaScript驭件。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width">
    <title>Javascript tests</title>
    <link rel="stylesheet" type="text/css" href="qunit-2.6.1.css">
</head>
<body>
    <div id="qunit"></div>
    <div id="qunit-fixture">
        <form id="id_item_form">
            <input name="text" />
            <input type="hidden" name="csrfmiddlewaretoken" value="tokey" />
            <div class="has-error">
                <span class="help-block">Error Text</span>
            </div>
        </form>
        <table id="id_list_table">
        </table>
    </div>

    <script src="../node_modules/jquery/dist/jquery.min.js"></script>
    <script src="../node_modules/sinon/pkg/sinon.js"></script>
    <script src="qunit-2.6.1.js"></script>
    <script src="list.js"></script>

    <script type="text/javascript">
        /* global sinon */

        var server, sandbox;
        QUnit.testStart(function(){
            server = sinon.fakeServer.create();
            sandbox = sinon.createSandbox();
        });
        QUnit.testDone(function(){
            server.restore();
            sandbox.restore();
        });

        QUnit.test("should call updateItems on initialize", function (assert) {
            var url = "/getitems/";
            sandbox.spy(window.Superlists, "updateItems");
            window.Superlists.initialize(url);
            assert.equal(
                window.Superlists.updateItems.lastCall.args,
                url
            )
        });

        [...]

可以通过server.respond()准确控制异步代码的流程:

 QUnit.test("updateItems should fill in lists table from ajax response", function (assert) {
            var url = "/getitems/";
            var responseData = [
                {"id": 101, "text": "待办事项1"},
                {"id": 102, "text": "待办事项2"},
            ];
            server.respondWith("GET", url, [
                200, {"Content-Type": "application/json"}, JSON.stringify(responseData)
            ]);
            window.Superlists.updateItems(url);

            server.respond();

            var rows = $("#id_list_table tr");
            assert.equal(rows.length, 2);
            var row1 = $("#id_list_table tr:first-child td");
            assert.equal(row1.text(), "1: 待办事项1")
            var row2 = $("#id_list_table tr:last-child td");
            assert.equal(row2.text(), "2: 待办事项2");
        });

1)需要注意,初始化之后才能设置服务器相应,因为我们想设置的是提交表单时发送的POST请求的响应,而不是一开始那个GET请求的响应。JavaScript测试最难掌握的技术之一便是控制执行顺序。
2)仅当开始那个GET请求处理完毕之后,才开始模拟辅助函数。sandbox.spy调用的作用与Python测试中的patch一样,把指定对象替换为驭件。
3)模拟的updateItems函数现在多了一些属性,例如lastCall和lastCall.args(类似于Python驭件的call_args)。

TDD速查表

——————未完待续———————-

项目开始阶段
TDD基本流程
测试不止要在开发环境中运行
通用的测试最佳实践
Selenium功能测试最佳实践
由外而内,测试隔离与整合测试,模拟技术

参考资料: 《Python测试驱动开发》第2版、《Python测试之道》

  • 7
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值