Python 接口自动化测试

一、基础准备

1. 环境搭建

  工欲善其事必先利其器,废话不多说。我们先开始搭建环境。

  1. # 创建项目目录

  2. mkdir InterfaceTesting

  3. # 切换到项目目录下

  4. cd InterfaceTesting

  5. # 安装虚拟环境创建工具

  6. pip install virtualenv

  7. # 创建虚拟环境,env代表虚拟环境的名称,可自行定义

  8. virtualenv env

  9. # 启动虚拟环境,执行下面命令后会发现路径上有 (env) 字样的标识

  10. source env/Scripts/activate

  11. # 查看 (env) 环境下使用的 Python 和 pip 工具版本

  12. ls env/Scripts/

  13. # *** 安装 requests ***

  14. pip install requests

  15. # 退出虚拟环境,退出后路径上的 (env) 字样的标识消失

  16. cd env/Scripts/

  17. deactivate

  18. # 导出环境所需要的模块的清单

  19. pip freeze >> requirements.txt

  20. # 上传 GitHub 时,将下面项忽略上传

  21. echo env/ >> .gitignore

  22. echo InterfaceTesting.iml >> .gitignore

  23. echo __pycache__/ >> .gitignore

  24. # 将代码传至 GitHub

  25. # 本地仓初始化

  26. git init

  27. # 创建本地仓与 GitHub 仓的远程链接

  28. git remote add github 你的github仓的地址

  29. # 将代码添加到暂存区

  30. git add .

  31. # 将代码提交到

  32. git commit -m "init environment"

  33. # 将代码上传到GitHub仓中

  34. git push github master

初始化环境的项目结构示例如下:

2. 接口基础知识
2.1 接口分类

接口一般来说有两种,一种是程序内部的接口,一种是系统对外的接口。

  1. (1) webservice接口:走soap协议通过http传输,请求报文和返回报文都是xml格式的,我们在测试的时候都要通过工具才能进行调用,测试。

  2. (2) http api 接口:走http协议,通过路径来区分调用的方法,请求报文都是key-value形式的,返回报文一般都是json串,有get和post等方法。

2.2 接口请求类型

根据接口的请求方法,常用的几种接口请求方式:

  1. (1) GET:从指定资源获取数据

  2. (2) POST:向指定的资源请求被处理的数据(例如用户登录)

  3. (3) PUT:上传指定的URL,一般是修改,可以理解为数据库中的 update

  4. (4) DELETE:删除指定资源

二、Requests 快速上手

1. requests基础

  所有的数据测试目标以一个开源的接口模拟网站【HTTPBIN】为测试对象。

1.1 发送请求
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : requests_send_request.py

  5. @Time : 2019/9/2 11:54

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. import requests

  13. # 1.requests请求方式

  14. # (1) GET请求方式

  15. httpbin_get = requests.get('http://httpbin.org/get', data={'key': 'value'})

  16. print('httpbin_get: ', httpbin_get.text)

  17. # (2) POST请求方式

  18. httpbin_post = requests.post('https://httpbin.org/post', data={'key': 'value'})

  19. print('httpbin_post: ', httpbin_post.text)

  20. # (3) PUT请求方式

  21. httpbin_put = requests.put('https://httpbin.org/put', data={'key': 'value'})

  22. print('httpbin_put: ', httpbin_put.text)

  23. # (4) DELETE请求方式

  24. httpbin_delete = requests.delete('https://httpbin.org/delete', data={'key': 'value'})

  25. print('httpbin_delete', httpbin_delete)

  26. # (5) PATCH亲求方式

  27. httpbin_patch = requests.patch('https://httpbin.org/patch', data={'key': 'value'})

  28. print('httpbin_patch', httpbin_patch)

1.2 参数传递

  常用的参数传递形式有四种:【GitHub示例

  1. (1)字典形式的参数:payload = {'key1': 'value1', 'key2': 'value2'}

  2. (2) 元组形式的参数:payload = (('key1', 'value1'), ('key2', 'value2'))

  3. (3) 字符串形式的参数:payload = {'string1', 'value1'}

  4. (4) 多部份编码的文件:files = {

  5. # 显示设置文件名、文件类型和请求头

  6. 'file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel', {'Expires': '0'})

  7. }

  8. #!/usr/bin/env python

  9. # -*- encoding: utf-8 -*-

  10. """

  11. @File : requests_transfer_parameter.py

  12. @Time : 2019/9/2 12:39

  13. @Author : Crisimple

  14. @Github : https://crisimple.github.io/

  15. @Contact : Crisimple@foxmail.com

  16. @License : (C)Copyright 2017-2019, Micro-Circle

  17. @Desc : 参数传递:字典、元组、字符串、文件

  18. """

  19. import requests

  20. # 2. 参数传递

  21. # (1) 传参参数为字典形式: 数据字典会在发送请求时会自动编码为表单形式

  22. def transfer_dict_parameter():

  23. payload = {

  24. 'key1': 'value1',

  25. 'key2': 'value2'

  26. }

  27. transfer_dict_parameter_result = requests.post('https://httpbin.org/post', params=payload)

  28. print("transfer_dict_parameter_url: ", transfer_dict_parameter_result.url)

  29. print("transfer_dict_parameter_text: ", transfer_dict_parameter_result.text)

  30. transfer_dict_parameter()

  31. # (2) 传参参数为元组形式: 应用于在表单中多个元素使用同一 key 的时候

  32. def transfer_tuple_parameter():

  33. payload = (

  34. ('key1', 'value1'),

  35. ('key1', 'value2')

  36. )

  37. transfer_tuple_parameter_result = requests.post('https://httpbin.org/post', params=payload)

  38. print('transfer_tuple_parameter_url: ', transfer_tuple_parameter_result.url)

  39. print('transfer_tuple_parameter_text: ', transfer_tuple_parameter_result.text)

  40. transfer_tuple_parameter()

  41. # (3) 传参参数形式是字符串形式

  42. def transfer_string_parameter():

  43. payload = {

  44. 'string1': 'value'

  45. }

  46. transfer_string_parameter_result = requests.post('https://httpbin.org/post', params=payload)

  47. print('transfer_string_parameter_url: ', transfer_string_parameter_result.url)

  48. print('transfer_string_parameter_text: ', transfer_string_parameter_result.text)

  49. transfer_string_parameter()

  50. # (4) 传参参数形式:一个多部分编码(Multipart-Encoded)的文件

  51. def transfer_multipart_encoded_file():

  52. interface_url = 'https://httpbin.org/post'

  53. files = {

  54. # 显示设置文件名、文件类型和请求头

  55. 'file': ('report.xls', open('report.xls', 'rb'), 'application/vnd.ms-excel', {'Expires': '0'})

  56. }

  57. transfer_multipart_encoded_file_result = requests.post(url=interface_url, files=files)

  58. print('transfer_multipart_encoded_file_result_url: ', transfer_multipart_encoded_file_result.url)

  59. print('transfer_multipart_encoded_file_result_url: ', transfer_multipart_encoded_file_result.text)

  60. transfer_multipart_encoded_file()

1.3 接口响应

  给接口传递参数,请求接口后,接口会给我们我们响应返回,接口在返回的时候,会给我们返回一个状态码来标识当前接口的状态。

(1)状态码

GitHub示例

  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : response_code.py

  5. @Time : 2019/9/2 15:41

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. import requests

  13. # 1. 返回接口状态码:200

  14. def response_200_code():

  15. interface_200_url = 'https://httpbin.org/status/200'

  16. response_get = requests.get(interface_200_url)

  17. response_get_code = response_get.status_code

  18. print('response_get_code: ', response_get_code)

  19. response_200_code()

  20. # 2.返回接口状态码:400

  21. def response_400_code():

  22. interface_400_url = 'https://httpbin.org/status/400'

  23. response_get = requests.get(interface_400_url)

  24. response_get_code = response_get.status_code

  25. print('response_get_code: ', response_get_code)

  26. response_400_code()

(2)响应头

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : response_content.py

  5. @Time : 2019/9/2 15:41

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. import requests

  13. # 1. 返回接口状态码:

  14. # (1). 返回接口状态码:200

  15. def response_200_code():

  16. interface_200_url = 'https://httpbin.org/status/200'

  17. response_get = requests.get(interface_200_url)

  18. response_get_code = response_get.status_code

  19. print('response_get_code: ', response_get_code)

  20. response_200_code()

  21. # (2).返回接口状态码:400

  22. def response_400_code():

  23. interface_400_url = 'https://httpbin.org/status/400'

  24. response_get = requests.get(interface_400_url)

  25. response_get_code = response_get.status_code

  26. print('response_get_code: ', response_get_code)

  27. response_400_code()

  28. # (3) 重定向接口返回状态码:301

  29. def response_301_code():

  30. interface_url = 'https://butian.360.cn'

  31. response_get = requests.get(interface_url)

  32. response_get_code = response_get.status_code

  33. print('response_get_code: ', response_get_code)

  34. response_301_code()

  35. # ------------------------------------------------------

  36. # 2. 响应内容

  37.   响应内容的请求头、查看文本、编码方式、二进制响应、原始响应。

  38. def response_contents():

  39. url = 'https://httpbin.org/get'

  40. response_get = requests.get(url=url)

  41. # 响应头

  42. print('response_get_headers', response_get.headers)

  43. # 响应文本

  44. print('response_get_text: ', response_get.text)

  45. # 文本编码方式

  46. print('response_get_encoding: ', response_get.encoding)

  47. # 二进制响应内容

  48. print('response_get_content: ', response_get.content)

  49. # 原始响应内容

  50. origin_content = response_get.raw

  51. origin_content_read = origin_content.read(10)

  52. print('origin_content: ', origin_content)

  53. print('origin_content_read: ', origin_content_read)

  54. response_contents()

1.4 接口其他处理

GitHub示例

(1) 操作cookies

 
  1. import requests

  2. import time

  3. url = 'https://httpbin.org/get'

  4. def operator_cookies():

  5. r = requests.get(url)

  6. print('r.cookies: ', r.cookies)

  7. jar = requests.cookies.RequestsCookieJar()

  8. jar.set('tasty_cookie', 'yum', domain='httpbin.org', path='/cookies')

  9. jar.set('gross_cookie', 'blech', domain='httpbin.org', path='/elsewhere')

  10. r2 = requests.get(url=url, cookies=jar)

  11. print('r2.text', r2.text)

  12. operator_cookies()

(2) 请求历史

 
  1. import requests

  2. url = 'https://httpbin.org/get'

  3. def request_history():

  4. r = requests.get(url=url)

  5. print('r.history: ', r.history)

  6. request_history()

(3) 超时请求

  requests 在经过 timeout 参数设定的秒数时间之后停止等待响应。

 
  1. import requests

  2. import time

  3. def timeout():

  4. print(time.time())

  5. url = 'https://httpbin.org/get'

  6. print(time.time())

  7. r = requests.get(url, timeout=5)

  8. print(time.time())

  9. timeout()

(4) 错误与异常

  常见的错误异常有:

  1. · 遇到网络问题(如:DNS 查询失败、拒绝连接等时),requests 会抛出一个 ConnectionError 异常。

  2. · 如果 HTTP 请求返回了不成功的状态码, Response.raise_for_status() 会抛出一个 HTTPError异常。

  3. · 若请求超时,则超出一个 Timeout 异常。

  4. · 若请求超过了设定的最大重定向次数,则会抛出一个 TooManyRedirects 异常。

  5. · 所有 Requests 显式抛出的异常都继承自 requests.exceptions.RequestsException。

2. requests 高级应用
2.1 会话对象
2.2 请求与响应对象
2.3 准备的请求
2.4 SSL证书验证
2.5 客户端证书

三、接口测试实战

1. 百度翻译接口测试

  理论千千万万,实战才是真理。百度翻译提供了一套成熟的翻译接口(不是恰饭😂),我们就用此接口对前面理论进行实战。【GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : baidu_translate.py

  5. @Time : 2019/9/2 20:05

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. import requests

  13. import random

  14. import hashlib

  15. import urllib

  16. import json

  17. class BaiduTranslate(object):

  18. def __init__(self, word):

  19. # 你要翻译的元素

  20. self.q = word

  21. self.fromLang = 'en'

  22. self.toLang = 'zh'

  23. self.baidu_translate = 'https://api.fanyi.baidu.com'

  24. self.translate_api_url = '/api/trans/vip/translate'

  25. # 百度开发者配置信息

  26. self.appid = 'XXXXXXXX'

  27. self.secretKey = 'XXXXXXXX'

  28. # 开发配置

  29. self.salt = random.randint(32768, 65536)

  30. self.sign = self.appid + self.q + str(self.salt) + self.secretKey

  31. m1 = hashlib.md5()

  32. m1.update(self.sign.encode('utf-8'))

  33. self.sign = m1.hexdigest()

  34. self.my_url = self.translate_api_url + '?appid=' + self.appid + '&q=' + urllib.request.quote(self.q) + '&from=' + self.fromLang + '&to=' + self.toLang + '&salt=' + str(self.salt) + '&sign=' + self.sign

  35. def en_translate_zh(self):

  36. re = requests.request('post', self.baidu_translate + self.translate_api_url)

  37. print('\n\t re.text', re.text)

  38. re_json = json.loads(re.text)

  39. print('\n\t re_json', re_json)

  40. if __name__ == "__main__":

  41. bt = BaiduTranslate('test')

  42. bt.en_translate_zh()

2. urllib请求接口

  有了requests库请求接口了,为什么要再用urllib来请求接口呢?因为urllib是python的基础库,不需要下载安装,在对环境要求甚高的环境下,在不破坏原来的环境下,依然可以让自动化代码依然运行。【GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : urllib_request.py

  5. @Time : 2019/9/2 20:49

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. from urllib import request

  13. from urllib import parse

  14. def urllib_request():

  15. base_url = 'http://www.tuling123.com/openapi/api'

  16. payload = {

  17. 'key1': 'Your',

  18. 'key2': '你好'

  19. }

  20. ur = request.Request(url=base_url)

  21. ur_response = request.urlopen(ur)

  22. print('\n ur_response: \n\t', ur_response)

  23. print('\n ur_response_getcode: \n\t ', ur_response.getcode)

  24. print('\n ur_response_headers: \n\t ', ur_response.headers)

  25. data = parse.urlencode(payload).encode('utf-8')

  26. url_payload = request.Request(url=base_url, data=data)

  27. url_payload_response = request.urlopen(url_payload)

  28. print('\n url_payload_response: \n\t', url_payload_response)

  29. print('\n url_payload_response_getcode: \n\t ', url_payload_response.getcode)

  30. print('\n url_payload_response_headers: \n\t ', url_payload_response.headers)

  31. print('\n url_payload_response_msg: \n\t ', url_payload_response.msg)

  32. print('\n url_payload_response_read: \n\t ', url_payload_response.read)

  33. urllib_request()


四、搭建测试接口平台

  自搭建的接口平台使用Django框架进行开发,基于当前接口的需求(接口的增、删、改、查)功能,搭建一个满足需要的接口测试平台。

1. 环境搭建
1.1 项目创建

GitHub示例

 
  1. # 下载 django 框架库

  2. pip install django

  3. # 创建 django 工程

  4. django-admin startproject InterfaceTestingMock

  5. # 创建 api_crud app

  6. cd InterfaceTestingMock

  7. python manage.py startapp interface_crud

  8. # 创建 api_mock 工程的虚拟运行环境

  9. viutualenv env

  10. # 激活虚拟环境

  11. source env/Scripts/activate

  12. # 退出虚拟环境

  13. deactivate

  14. # 导出虚拟环境 env 所需要的库

  15. pip freeze >> requirements.txt

1.2 接口开发配置

(1) 创建表结构

  1. python manage.py migrat

(2) 编写模型层代码,以下语句相当于创建了两张表:User,Article

 
  1. # interface_crud.models.py

  2. from django.db import models

  3. # Create your models here.

  4. class User(models.Model):

  5. id = models.AutoField(primary_key=True)

  6. user_name = models.CharField(max_length=50)

  7. user_password = models.CharField(max_length=100)

  8. # active inactive

  9. status = models.CharField(max_length=10)

  10. class Article(models.Model):

  11. id = models.AutoField(primary_key=True)

  12. title = models.CharField(max_length=50)

  13. content = models.TextField()

  14. # delete alive

  15. status = models.CharField(max_length=10)

(3) 新增表,执行下面语句让 django 知道表发生了变化

  1. python manage.py makemigrations interface_crud

(4) 再次创建表

  1. python manage.py migrate

(5) 生成创建超级管理员账号

  1. # 依次数据用户名、邮箱地址、密码、重复密码、确认(y)

  2. python manage.py createsuperuser

(6) 配置接口请求地址

 
  1. # InterfaceTestingMock.urls.py

  2. from django.contrib import admin

  3. from django.urls import path

  4. from interface_crud.views import add_article, modify_article

  5. urlpatterns = [

  6. path('admin/', admin.site.urls),

  7. path('articles/', add_article),

  8. path('articles<int: art_id>', modify_article)

  9. ]

2. 接口开发

&emsp:&emsp:就目前常用的接口参数传参形式分别有:表单类接口传参,多用于提供给前端页面(后续学习跟进总结);另一种常用的就是 json 传参形式的,这种传参形式能够满足开发处业务逻辑更为复杂的接口,本次接口开发就采用该形式。【GitHub示例】---【GitHub示例

备注:2.1-2.6是根据【** [秦无殇的博客](https://www.cnblogs.com/webDepOfQWS/p/10693152.html)**】学习整理而来,谢谢这位老哥❀

2.1 查询文章接口

 
  1. from interface_crud.models import Article

  2. from django.http import JsonResponse, HttpResponse

  3. import json

  4. # Create your views here.

  5. # 查询文章

  6. def query_article(request):

  7. if request.method == 'GET':

  8. articles = {}

  9. query_articles = Article.objects.all()

  10. print('query_articles: ', query_articles)

  11. for title in query_articles:

  12. articles[title.title] = title.status

  13. return JsonResponse({"status": "BS.200", "all_titles": articles, "msg": "query articles success."})

  14. print("request.body", request.body)

  15. else:

  16. return HttpResponse("方法错误")

2.2 增加文章接口

 
  1. # 增加文章

  2. def add_article(request):

  3. auth_res = user_auth(request)

  4. if auth_res == "auth_fail":

  5. return JsonResponse({"status": "BS.401", "msg": "user auth failed."})

  6. else:

  7. if request.method == "POST":

  8. # b''

  9. print('request.body: ', request.body)

  10. print('request.body: ', type(request.body))

  11. req_dict = json.loads(request.body)

  12. print('req_json: ', req_dict)

  13. print('req_json: ', type(req_dict))

  14. key_flag = req_dict.get('title') and req_dict.get('content') and len(req_dict) == 2

  15. print('key_flag: ', key_flag)

  16. # 判断请求体是否正确

  17. if key_flag:

  18. title = req_dict['title']

  19. content = req_dict['content']

  20. # title返回的是一个list

  21. title_exist = Article.objects.filter(title=title)

  22. # 判断是否存在同名的title

  23. if len(title_exist) != 0:

  24. return JsonResponse({"status": "BS.400", "msg": "title already exist, fail to publish."})

  25. """

  26. 插入数据

  27. """

  28. add_art = Article(title=title, content=content, status='alive')

  29. add_art.save()

  30. return HttpResponse(add_art)

  31. return JsonResponse({"status": "BS.200", "msg": "add article success."})

  32. else:

  33. return JsonResponse({"status": "BS.400", "message": "please check param."})

  34. else:

  35. return HttpResponse("方法错误,你应该使用POST请求方式")

2.3 修改文章接口
 
  1. # 更新文章

  2. def modify_article(request, article_id):

  3. auth_res = user_auth(request)

  4. if auth_res == "auth_fail":

  5. return JsonResponse({"status": "BS.401", "msg": "user auth failed."})

  6. else:

  7. if request.method == 'POST':

  8. modify_req = json.loads(request.body)

  9. try:

  10. article = Article.objects.get(id=article_id)

  11. print("article", article)

  12. key_flag = modify_req.get('title') and modify_req.get('content') and len(modify_req) == 2

  13. if key_flag:

  14. title = modify_req['title']

  15. content = modify_req['content']

  16. title_exist = Article.objects.filter(title=title)

  17. if len(title_exist) > 1:

  18. return JsonResponse({"status": "BS.400", "msg": "title already exist."})

  19. # 更新文章

  20. old_article = Article.objects.get(id=article_id)

  21. old_article.title = title

  22. old_article.content = content

  23. old_article.save()

  24. return JsonResponse({"status": "BS.200", "msg": "modify article sucess."})

  25. except Article.DoesNotExist:

  26. return JsonResponse({"status": "BS.300", "msg": "article is not exists,fail to modify."})

  27. else:

  28. return HttpResponse("方法错误,你应该使用POST请求方式")

2.4 删除文章接口
 
  1. # 删除文章

  2. def delete_article(request, article_id):

  3. auth_res = user_auth(request)

  4. if auth_res == "auth_fail":

  5. return JsonResponse({"status": "BS.401", "msg": "user auth failed."})

  6. else:

  7. if request.method == 'DELETE':

  8. try:

  9. article = Article.objects.get(id=article_id)

  10. article_id = article.id

  11. article.delete()

  12. return JsonResponse({"status": "BS.200", "msg": "delete article success."})

  13. except Article.DoesNotExist:

  14. return JsonResponse({"status": "BS.300", "msg": "article is not exists,fail to delete."})

  15. else:

  16. return HttpResponse("方法错误,你应该使用DELETE请求方式")

2.5 token认证
 
  1. # 用户认证

  2. # 四个简单的接口已经可以运行了,但是在发请求之前没有进行鉴权,毫无安全性可言。下面来实现简单的认证机制。需要用到内建模块hashlib,hashlib提供了常见的摘要算法,如MD5,SHA1等。

  3. def user_auth(request):

  4. token = request.META.get("HTTP_X_TOKEN", b'')

  5. print("token: ", token)

  6. if token:

  7. # 暂时写上 auth 接口返回的数据

  8. if token == '0a6db4e59c7fff2b2b94a297e2e5632e':

  9. return "auth_success"

  10. else:

  11. return "auth_fail"

  12. else:

  13. return "auth_fail"

2.6 接口测试

  在接口开发是不断开发不断测试是一个非常好的习惯。

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : interface_crud_tests.py

  5. @Time : 2019/9/4 14:22

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. import requests

  13. import unittest

  14. class TestInterfaceCrud(unittest.TestCase):

  15. @unittest.skip("跳过 test_query_article 测试")

  16. def test_query_article(self):

  17. payload = {}

  18. res = requests.get('http://127.0.0.1:8000/query_article/', params=payload)

  19. print("test_query_article: ", res.text)

  20. @unittest.skip("跳过 test_add_article 测试")

  21. def test_add_article(self):

  22. payload = {

  23. "title": "title5",

  24. "content": "content5",

  25. }

  26. Headers = {

  27. # "Authorization": '通用的token,但是该接口使用的是X-Token',

  28. "Content-Type": "application/json; charset=utf-8",

  29. "Accept": "application/json",

  30. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Safari/537.36 Core/1.70.3730.400 QQBrowser/10.5.3805.400",

  31. "X-Token": "0a6db4e59c7fff2b2b94a297e2e5632e"

  32. }

  33. res = requests.post('http://127.0.0.1:8000/add_article/', headers=Headers, json=payload)

  34. print(res.request)

  35. print(res.text)

  36. @unittest.skip("跳过 test_modify_article 测试")

  37. def test_modify_article(self):

  38. payload = {

  39. "title": "title1",

  40. "content": "content1",

  41. }

  42. Headers = {

  43. # "Authorization": '通用的token,但是该接口使用的是X-Token',

  44. "Content-Type": "application/json; charset=utf-8",

  45. "Accept": "application/json",

  46. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Safari/537.36 Core/1.70.3730.400 QQBrowser/10.5.3805.400",

  47. "X-Token": "0a6db4e59c7fff2b2b94a297e2e5632e"

  48. }

  49. res = requests.post('http://127.0.0.1:8000/modify_article/1', headers=Headers, json=payload)

  50. print(res.request)

  51. print(res.text)

  52. # @unittest.skip("跳过 test_delete_article 测试")

  53. def test_delete_article(self):

  54. payload = {

  55. "title": "title2",

  56. "content": "content2",

  57. }

  58. Headers = {

  59. # "Authorization": '通用的token,但是该接口使用的是X-Token',

  60. "Content-Type": "application/json; charset=utf-8",

  61. "Accept": "application/json",

  62. "User-Agent": "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.25 Safari/537.36 Core/1.70.3730.400 QQBrowser/10.5.3805.400",

  63. "X-Token": "0a6db4e59c7fff2b2b94a297e2e5632e"

  64. }

  65. res = requests.delete('http://127.0.0.1:8000/delete_article/2', headers=Headers, json=payload)

  66. print(res.request)

  67. print(res.text)

  68. @unittest.skip("跳过 test_test_api 测试")

  69. def test_test_api(self):

  70. payload = {

  71. 'title': 'title1',

  72. 'content': 'content1',

  73. 'status': 'alive'

  74. }

  75. res = requests.post('http://127.0.0.1:8000/test_api/')

  76. print(res.text)

  77. if __name__ == '__main__':

  78. unittest.main()


五、接口自动化

1. 数据处理
1.1 Excel中数据

  获取 excel 的第几 sheet 页,行数,列数,单元格值,数据写入 excel操作。【GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : operate_excel.py

  5. @Time : 2019/9/5 10:07

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : 对 Excel 的读写操作

  11. """

  12. import xlrd

  13. from xlutils.copy import copy

  14. class OperateExcel(object):

  15. def __init__(self, file_name=None, sheet_id=None):

  16. """

  17. :param file_name: excel文件的具体路径名称

  18. :param sheet_id: 要操作的第几 sheet 页

  19. """

  20. if file_name:

  21. self.file_name = file_name

  22. else:

  23. self.file_name = '../data/util_data/operate_excel.xls'

  24. if sheet_id:

  25. self.sheet_id = sheet_id

  26. else:

  27. self.sheet_id = 0

  28. self.sheet_table = self.get_sheet()

  29. # 获取 sheet 页操作对象

  30. def get_sheet(self):

  31. data = xlrd.open_workbook(self.file_name)

  32. sheet_table = data.sheets()[self.sheet_id]

  33. return sheet_table

  34. # 获取该 sheet 页的行数和列数,拿到的是一个元组

  35. def get_sheet_nrows_ncols(self):

  36. return self.sheet_table.nrows, self.sheet_table.ncols

  37. # 获取该 sheet 页的行数

  38. def get_sheet_nrows(self):

  39. return self.sheet_table.nrows

  40. # 获取该 sheet 页的列数

  41. def get_sheet_ncols(self):

  42. return self.sheet_table.ncols

  43. # 获取具体单元格的数据

  44. def get_sheet_cell(self, row, col):

  45. """

  46. :param row: 单元格的行值

  47. :param col: 单元格的列值

  48. :return: cell_data

  49. """

  50. cell_data = self.sheet_table.cell_value(row, col)

  51. return cell_data

  52. # 写入数据到 excel 中

  53. def write_to_excel(self, row, col, value):

  54. # 同样的先打开 excel 操作句柄

  55. data = xlrd.open_workbook(self.file_name)

  56. copy_data = copy(data)

  57. # 选择写入的 sheet 页

  58. copy_data_sheet = copy_data.get_sheet(0)

  59. # 写入数据

  60. copy_data_sheet.write(row, col, value)

  61. # 保存数据

  62. copy_data.save(self.file_name)

  63. if __name__ == "__main__":

  64. oe = OperateExcel()

  65. print("获取 excel 表的行数和列表,返回元组形式:", oe.get_sheet_nrows_ncols())

  66. print("获取 excel 表的行数:", oe.get_sheet_nrows())

  67. print("获取 excel 表的列数:", oe.get_sheet_ncols())

  68. print("获取单元格(1, 1)的值:", oe.get_sheet_cell(1, 1))

  69. print("获取单元格(1, 2)的值:", oe.get_sheet_cell(1, 2))

  70. print("获取单元格(2, 2)的值:", oe.get_sheet_cell(2, 2))

  71. oe.write_to_excel(17, 7, '写入的数据')

1.2 JSON中数据

GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : operate_json.py

  5. @Time : 2019/9/5 12:24

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : 操作 JSON 文件中的数据

  11. """

  12. import json

  13. class OperateJson(object):

  14. def __init__(self, file_name=None):

  15. if file_name:

  16. self.file_name = file_name

  17. else:

  18. self.file_name = '../data/util_data/operate_json.json'

  19. self.data = self.get_json()

  20. # 读取 json 文件

  21. def get_json(self):

  22. with open(self.file_name, encoding='utf-8') as fp:

  23. data = json.load(fp)

  24. return data

  25. # 根据关键词读取数据

  26. def get_key_data(self, key):

  27. return self.data[key]

  28. if __name__ == '__main__':

  29. oj = OperateJson()

  30. print('login: ', oj.get_key_data("login"))

  31. print('login.username: ', oj.get_key_data("login")["username"])

  32. print('login.password: ', oj.get_key_data("login")["username"])

  33. print('logout: ', oj.get_key_data("logout"))

  34. print('logout.code: ', oj.get_key_data("logout")["code"])

  35. print('logout.info: ', oj.get_key_data("logout")["info"])

 
  1. {

  2. "login": {

  3. "username": "kevin",

  4. "password": "121345"

  5. },

  6. "logout": {

  7. "code": 200,

  8. "info": "logout"

  9. }

  10. }

'

运行

运行

1.3 数据库中的数据

  数据库用的常用的MySQL。【GitHub示例

  远程连接数据库可能会连接出错的解决方法:GRANT ALL PRIVILEGES ON . TO 'root'@'%' IDENTIFIED BY '你的密码' WITH GRANT OPTION;

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : operate_mysql.py

  5. @Time : 2019/9/5 16:10

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : 操作 数据库 中的数据

  11. """

  12. import pymysql

  13. import json

  14. class OperateMysql(object):

  15. def __init__(self):

  16. # 数据库初始化连接

  17. self.connect_interface_testing = pymysql.connect(

  18. "XXX.XXX.XXX.XXX",

  19. "XXX",

  20. "XXXXXXXX",

  21. "InterfaceTesting",

  22. cursorclass=pymysql.cursors.DictCursor

  23. )

  24. # 创建游标操作数据库

  25. self.cursor_interface_testing = self.connect_interface_testing.cursor()

  26. def select_data(self, sql):

  27. # 执行 sql 语句

  28. self.cursor_interface_testing.execute(sql)

  29. # 获取查询到的第一条数据

  30. first_data = self.cursor_interface_testing.fetchone()

  31. # 将返回结果转换成 str 数据格式

  32. first_data = json.dumps(first_data)

  33. return first_data

  34. if __name__ == "__main__":

  35. om = OperateMysql()

  36. res = om.select_data(

  37. """

  38. SELECT * FROM test_table;

  39. """

  40. )

  41. print(res)

2. 邮件告警

  通常我们做接口自动化测试的时候,自动化用例执行结束后,我们需要首先需要看自动化用例是不是执行结束了,另外它的执行结果是什么。我们不可能一直紧盯着脚本执行,所以当自动化执行结束后,我们需要发送邮件来进行提醒并把自动化的执行情况邮件通知。

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : email_config.py

  5. @Time : 2019/9/5 18:58

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : 发送邮件配置

  11. """

  12. import smtplib

  13. from email.mime.text import MIMEText

  14. class EmailConfig(object):

  15. global send_user

  16. global mail_host

  17. global password

  18. send_user = '发送者邮箱@163.com'

  19. mail_host = 'smtp.163.com'

  20. password = '邮箱服务器密码'

  21. def send_config(self, user_lists, subject, content):

  22. user = "发件人昵称" + "<" + send_user + ">"

  23. message = MIMEText(content, _subtype="plain", _charset="utf-8")

  24. message['Subject'] = subject

  25. message['From'] = user

  26. message['To'] = ";".join(user_lists)

  27. server = smtplib.SMTP()

  28. server.connect(mail_host)

  29. server.login(send_user, password)

  30. server.sendmail(user, user_lists, message.as_string())

  31. server.close()

  32. def send_mail(self, pass_cases, fail_cases, not_execute_cases):

  33. pass_num = float(len(pass_cases))

  34. fail_num = float(len(fail_cases))

  35. not_execute_num = float(len(not_execute_cases))

  36. execute_num = float(pass_num + fail_num)

  37. total_cases = float(pass_num + fail_num + not_execute_num)

  38. pass_ratio = "%.2f%%" % (pass_num / total_cases * 100)

  39. fail_ratio = "%.2f%%" % (fail_num / total_cases * 100)

  40. user_lists = ['crisimple@foxmail.com']

  41. subject = "【邮件配置测试】"

  42. content = "一共 %f 个用例, 执行了 %f 个用例,未执行 %f 个用例;成功 %f 个,通过率为 %s;失败 %f 个,失败率为 %s" % (total_cases, execute_num, not_execute_num, pass_num, pass_ratio, fail_num, fail_ratio)

  43. self.send_config(user_lists, subject, content)

  44. if __name__ == "__main__":

  45. ec = EmailConfig()

  46. ec.send_mail([1, 3, 5], [2, 4, 6], [1, 2, 3])

3. 封装测试
3.1 多种请求方式兼容

  通过第四模块的接口开发,我们知道接口的请求方式有多种,在接口测试时我们不可能针对不同请求方式的接口不断的改变它的请求方法形式和参数,所以可以将多种不同请求方式统一整合,只改变请求方法(GET、POST、DELETE、UPDATE)来切换不同的请求形式。【GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : intergrate_request.py

  5. @Time : 2019/9/6 7:56

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : 多种请求方法集成

  11. """

  12. import requests

  13. import json

  14. class IntergrateRequest():

  15. # 请求 request方法

  16. def get_req(self, url, data=None, headers=None):

  17. if headers is not None:

  18. res = requests.get(url, data, headers).json()

  19. else:

  20. res = requests.get(url, data).json()

  21. return res

  22. # post 请求方式

  23. def post_req(self, url, data=None, headers=None):

  24. if headers is not None:

  25. res = requests.post(url, data, headers).json()

  26. else:

  27. res = requests.post(url, data).json()

  28. return res

  29. # delete 请求方式

  30. def delete_req(self, url, data=None, headers=None):

  31. if headers is not None:

  32. res = requests.delete(url, data, headers).json()

  33. else:

  34. res = requests.delete(url, data).json()

  35. return res

  36. def main_req(self, method, url, data=None, headers=None):

  37. if method == "get":

  38. res = self.get_req(url, data, headers)

  39. elif method == 'post':

  40. res = self.post_req(url, data, headers)

  41. elif method == 'delete':

  42. res = self.delete_req(url, data, headers)

  43. else:

  44. res = "你的请求方式暂未开放,请耐心等待"

  45. return json.dumps(res, ensure_ascii=False, indent=4, sort_keys=True)

  46. if __name__ == "__main__":

  47. ir = IntergrateRequest()

  48. method = 'get'

  49. url = 'http://127.0.0.1:8000/query_article/'

  50. data = None

  51. headers = None

  52. print(ir.main_req(method, url, data, headers))

3.2 自动化封装

  前面已经把相当一部分的准备工作做好了,接下来就该进行对各个模块进行封装。

(1) 获取试用例关键字段

  等一下详细说明:【GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : testcases_keyword.py

  5. @Time : 2019/9/6 16:21

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. class TestcasesKeyword(object):

  13. CASE_ID = '0'

  14. CASE_NAME = '1'

  15. IS_EXECUTE = '2'

  16. INTERFACE_URL = '3'

  17. METHOD = '4'

  18. HEADER = '5'

  19. PAYLOAD = '6'

  20. EXPECTED_RESULT = '7'

  21. ACTUAL_RESULT = '8'

  22. # 获取自动化用例 ID

  23. def get_case_id():

  24. return TestcasesKeyword.CASE_ID

  25. def get_case_name():

  26. return TestcasesKeyword.CASE_NAME

  27. def get_is_execute():

  28. return TestcasesKeyword.IS_EXECUTE

  29. def get_interface_url():

  30. return TestcasesKeyword.INTERFACE_URL

  31. def get_method():

  32. return TestcasesKeyword.METHOD

  33. def get_header():

  34. return TestcasesKeyword.HEADER

  35. def get_payload():

  36. return TestcasesKeyword.PAYLOAD

  37. def get_expected_result():

  38. return TestcasesKeyword.EXPECTED_RESULT

  39. def get_actual_result():

  40. return TestcasesKeyword.ACTUAL_RESULT

  41. if __name__ == "__main__":

  42. print(get_case_id())

(2) 业务场景封装

  写代码的作用就是为业务场景服务,是的前面各个模块只是我们的技术栈的积累。这里开始我们算是真正进入业务层面逻辑的设计。比如对于接口自动化这块的测试,拿到自动化用例,我们怎么处理这些用例呢?如果自动化用例是存放在 Excel 中的话,我们首选要拿到每条测试用例各个关键的字段值,根据这些关键字的特定含义看是否执行,是否给接口传header,或是将用例的最后执行结果写回到 execel 中去。是的没错,通过这样的描述我们就是在对自动化用例做业务层面的具体封装。【GitHub示例

 
  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : get_excel_testcases.py

  5. @Time : 2019/9/6 18:14

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. from util.operate_excel import OperateExcel

  13. from basic import testcases_keyword

  14. class GetExcelTestcases(object):

  15. def __init__(self):

  16. self.oe = OperateExcel()

  17. # 获取测试用例条数,也就是 Excel 中的行数

  18. def get_cases_num(self):

  19. return self.oe.get_sheet_nrows()

  20. # 判断是否携带 headers

  21. def is_header(self, row):

  22. col = int(testcases_keyword.get_header())

  23. header = self.oe.get_sheet_cell(row, col)

  24. if header is not None:

  25. return header

  26. else:

  27. print("你的 header 呢?")

  28. return None

  29. # 判断该条用例是否执行

  30. def get_is_run(self, row):

  31. flag = None

  32. col = int(testcases_keyword.get_is_execute())

  33. is_run = self.oe.get_sheet_cell(row, col)

  34. if is_run is not None:

  35. flag = True

  36. else:

  37. flag = False

  38. return flag

  39. # 获取不同接口的请求方式

  40. def get_method(self, row):

  41. col = int(testcases_keyword.get_method())

  42. method = self.oe.get_sheet_cell(row, col)

  43. return method

  44. # 获取要测试的接口链接

  45. def get_url(self, row):

  46. col = int(testcases_keyword.get_interface_url())

  47. url = self.oe.get_sheet_cell(row, col)

  48. return url

  49. # 获取接口参数

  50. def get_payload(self, row):

  51. col = int(testcases_keyword.get_payload())

  52. payload = self.oe.get_sheet_cell(row, col)

  53. if payload is None:

  54. return None

  55. return payload

  56. # 获取预期结果

  57. def get_expected_result(self, row):

  58. col = int(testcases_keyword.get_expected_result())

  59. expected_result = self.oe.get_sheet_cell(row, col)

  60. if expected_result is None:

  61. return None

  62. return expected_result

  63. # 写入实际结果

  64. def write_actual_result(self, row, value):

  65. col = int(testcases_keyword.get_actual_result())

  66. self.oe.write_to_excel(row, col, value)

  67. if __name__ == "__main__":

  68. gety = GetExcelTestcases()

  69. print(gety.get_cases_num())

  70. print(gety.is_header(1))

3. 执行自动化用例

  接下来就是执行测试用例了。【GitHub示例

  1. #!/usr/bin/env python

  2. # -*- encoding: utf-8 -*-

  3. """

  4. @File : run_excel_testcases.py

  5. @Time : 2019/9/7 13:05

  6. @Author : Crisimple

  7. @Github : https://crisimple.github.io/

  8. @Contact : Crisimple@foxmail.com

  9. @License : (C)Copyright 2017-2019, Micro-Circle

  10. @Desc : None

  11. """

  12. from basic.get_excel_testcases import GetExcelTestcases

  13. from basic.intergrate_request import IntergrateRequest

  14. from util.email_config import EmailConfig

  15. from util.operate_json import OperateJson

  16. from util.compare_str import CompareStr

  17. class RunExcelTestcases(object):

  18. def __init__(self):

  19. self.gtc = GetExcelTestcases()

  20. self.ir = IntergrateRequest()

  21. self.ec = EmailConfig()

  22. self.oj = OperateJson()

  23. self.cs = CompareStr()

  24. # 执行测试用例

  25. def run_testcases(self):

  26. # 定义空列表,存放执行成功和失败的测试用例

  27. pass_lists = []

  28. fail_lists = []

  29. no_execute_lists = []

  30. # no_execute_case_name = []

  31. # 获取总的用例条数

  32. cases_num = self.gtc.get_cases_num()

  33. # 遍历执行每一条测试用例

  34. for case in range(1, cases_num):

  35. # 用例是否执行

  36. is_run = self.gtc.get_is_run(case)

  37. # print("is_run: ", is_run)

  38. # 接口的请求方式

  39. method = self.gtc.get_method(case)

  40. # 请求测试接口

  41. url = self.gtc.get_url(case)

  42. # 要请求的数据

  43. data = self.gtc.get_payload(case)

  44. # 取出 header

  45. if case == 1:

  46. header = None

  47. else:

  48. header = self.oj.get_json()

  49. # 获取预期结果值 expected_result

  50. expected_result = self.gtc.get_expected_result(case)

  51. if is_run is True:

  52. res = self.ir.main_req(method, url, data, header)

  53. if self.cs.is_contain(expected_result, res):

  54. self.gtc.write_actual_result(case, 'pass')

  55. pass_lists.append(case)

  56. else:

  57. self.gtc.write_actual_result(case, res)

  58. fail_lists.append(case)

  59. else:

  60. no_execute_lists.append(case)

  61. print("没有执行的测试用例有, 按序号有:", no_execute_lists)

  62. self.ec.send_mail(pass_lists, fail_lists, no_execute_lists)

  63. if __name__ == "__main__":

  64. rts = RunExcelTestcases()

  65. rts.run_testcases()

4. 持续集成

  为什么要使用持续继承环境呢?通过前面的开发测试整个流程,我们清晰的发现,不管是接口还是自动化程序执行,都需要人为来控制,这是个很低技术含量但是又是不得不做的一个事。引进持续继承,就是让它来做一些重复的事情。

4.1 Jenkins环境搭建

(1) 环境配置

  1. # jenkins是基于 Java 环境的,所以首先安装Java SDK

  2. sudo apt-get install openjdk-8-jdk

  3. # 将 jenkins 存储库密钥添加到系统

  4. wget -q -O - https://pkg.jenkins.io/debian/jenkins-ci.org.key | sudo apt-key add -

  5. # 将Debian包存储库地址附加到服务器的sources.list

  6. echo deb http://pkg.jenkins.io/debian-stable binary/ | sudo tee /etc/apt/sources.list.d/jenkins.list

  7. # 更新存储库

  8. sudo apt-get update

  9. # 安装 Jenkins

  10. sudo apt-get install jenkins

(2) Jenkins 的常用命令

  1. # Jenkins 启动 | 查看状态 | 重启 | 停止

  2. sudo service jenkins start|status|restart|stop

  3. # jenkins启动后的访问地址:http:// ip_address_or_domain_name :8080

  4. # 访问上面的地址会发现需要输入初始密码,查看获取初始密码

  5. sudo cat /var/lib/jenkins/secrets/initialAdminPassword

4.2 接口继承

(1) 新建模拟接口(InterfaceTestingMock)任务

(2) 源码管理

(3) 配置构建脚本

  构建的是否,你可能会出现一些问题。我遇到就是无法创建超级用户,解决方案是:是权限的问题,我的解决方案是sudo python manage.py createsuperuser,执行创建超级用户前加sudo就可以了。如有问题可以留言

4.3 接口自动化集成

对于在命令行中执行程序时,通常会报错NoMoudle的错误的 解决方案

 

总结:

感谢每一个认真阅读我文章的人!!!

作为一位过来人也是希望大家少走一些弯路,如果你不想再体验一次学习时找不到资料,没人解答问题,坚持几天便放弃的感受的话,在这里我给大家分享一些自动化测试的学习资源,希望能给你前进的路上带来帮助。

软件测试面试文档

我们学习必然是为了找到高薪的工作,下面这些面试题是来自阿里、腾讯、字节等一线互联网大厂最新的面试资料,并且有字节大佬给出了权威的解答,刷完这一套面试资料相信大家都能找到满意的工作。

 

          视频文档获取方式:
这份文档和视频资料,对于想从事【软件测试】的朋友来说应该是最全面最完整的备战仓库,这个仓库也陪伴我走过了最艰难的路程,希望也能帮助到你!以上均可以分享,点下方小卡片即可自行领取。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值